diff --git a/.gitignore b/.gitignore index afa35534..fdea2ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -307,5 +307,8 @@ Cargo.lock .claude +# Local docker compose overrides (env vars, port remaps, secrets) +docker-compose.override.yaml + # docs output book/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2c7075..92fcbc37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ For more information about each release including git tags and artifacts, see [R - Per-action health metrics in the executor ([#191](https://github.com/roostorg/osprey/pull/191) by [@cmttt](https://github.com/cmttt)) - Option to suppress cached errors to reduce metric bloat ([#180](https://github.com/roostorg/osprey/pull/180) by [@lithium-powered](https://github.com/lithium-powered)) - Experimental asyncio-native worker with metrics and engine/coordinator improvements ([#341](https://github.com/roostorg/osprey/pull/341) by [@cmttt](https://github.com/cmttt)) +- Experimental in-app rule authoring with a `rule_drafts` table and deployment hooks ([#402](https://github.com/roostorg/osprey/pull/402) by [@julietshen](https://github.com/julietshen)) - ATProto JetStream example plugins and rules ([#236](https://github.com/roostorg/osprey/pull/236) by [@haileyok](https://github.com/haileyok)) - `osprey-stress` CLI: closed-loop stress harness that produces synthetic events at a configurable rate, observes their `ExecutionResult`s on the output topic, and reports drop rate and p50/p95/p99 latency, exiting non-zero on threshold breach so it can gate CI on pipeline health ([#367](https://github.com/roostorg/osprey/pull/367) by [@julietshen](https://github.com/julietshen), closes [#324](https://github.com/roostorg/osprey/issues/324)) diff --git a/docs/user/manage.md b/docs/user/manage.md index 36ed4225..4d1c54ea 100644 --- a/docs/user/manage.md +++ b/docs/user/manage.md @@ -56,3 +56,28 @@ The list is paginated (50 per page) and can be filtered and sorted: - **Sort**: by name, most referenced, or least referenced Each row shows the rule's name, source file, description, reference count, and line number within the source file. + +## Rule Authoring (Experimental feature) + +Users can draft SML rules directly in the UI. Drafts are saved to a `rule_drafts` table so the people who operate Osprey can reference, edit, and deploy them without any external code host. + +The editor validates every keystroke against the same AST validator the running engine uses, so compile-time errors surface before a draft is saved. The Rule Builder view expresses the common shape (name, conditions, outcomes) as a form and generates SML; the Code Editor view accepts arbitrary SML for anything the builder can't represent. + +### The draft rules table + +Drafts live in a Postgres table (`rule_drafts`), one row per rule file path. The API (all gated by the `CAN_EDIT_RULE_DRAFTS` ability, granted to `super_user`): + +- `POST /rule-drafts` — re-validates the SML server-side, then upserts the draft. +- `GET /rule-drafts` — lists every draft (the table operators work from). +- `GET /rule-drafts/` — fetches a single draft. +- `POST /rule-drafts//deploy` — re-validates, writes the SML into the rules directory, and marks the draft `deployed`. Pass `wire_into_main: true` to also append a `Require(rule=...)` line to `main.sml` so the rule takes effect (a rule file is inert until something requires it). + +### Deploying + +Deploy writes the draft's SML into a rules directory that the engine's sources provider already loads (a filesystem hand-off: whatever pipeline syncs that directory activates the rule). + +| Var | Default | Notes | +|---|---|---| +| `OSPREY_RULES_LOCAL_PATH` | _required for deploy_ | Absolute path to the rules directory the engine loads. Deploy writes SML here; must already exist. If unset, `POST /deploy` returns 503 (drafting and validation still work). | + +> **Future direction:** a DB-backed `SourcesProvider` could let the engine load deployed drafts straight from the `rule_drafts` table, removing the filesystem hand-off and making rule management work with zero external infrastructure. This PR keeps the filesystem deploy; the table is already the source of truth for drafts. diff --git a/osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json b/osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json index c824783c..2ee45783 100644 --- a/osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json +++ b/osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json @@ -40,6 +40,10 @@ { "name": "CAN_VIEW_EVENTS_BY_ACTION", "allow_all": true + }, + { + "name": "CAN_EDIT_RULE_DRAFTS", + "allow_all": true } ], "ability_groups": ["CAN_VIEW_BASIC_USER_DATA"] diff --git a/osprey_worker/src/osprey/worker/lib/osprey_engine.py b/osprey_worker/src/osprey/worker/lib/osprey_engine.py index 2e6318d4..45618ae8 100644 --- a/osprey_worker/src/osprey/worker/lib/osprey_engine.py +++ b/osprey_worker/src/osprey/worker/lib/osprey_engine.py @@ -157,6 +157,14 @@ def _handle_updated_sources(self) -> None: def execution_graph(self) -> ExecutionGraph: return self._execution_graph + @property + def udf_registry(self) -> UDFRegistry: + return self._udf_registry + + @property + def validator_registry(self) -> ValidatorRegistry: + return self._validator_registry + @property def config(self) -> SourcesConfig: return self._execution_graph.validated_sources.sources.config diff --git a/osprey_worker/src/osprey/worker/lib/storage/postgres.py b/osprey_worker/src/osprey/worker/lib/storage/postgres.py index b0f67045..fb302e1b 100644 --- a/osprey_worker/src/osprey/worker/lib/storage/postgres.py +++ b/osprey_worker/src/osprey/worker/lib/storage/postgres.py @@ -56,6 +56,7 @@ def _init(config: Config) -> None: bulk_label_task, pg_stored_execution, queries, + rule_drafts, temporary_ability_token, ) diff --git a/osprey_worker/src/osprey/worker/lib/storage/rule_drafts.py b/osprey_worker/src/osprey/worker/lib/storage/rule_drafts.py new file mode 100644 index 00000000..069a485a --- /dev/null +++ b/osprey_worker/src/osprey/worker/lib/storage/rule_drafts.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import StrEnum + +from sqlalchemy import BigInteger, Column, DateTime, Enum, Text +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from .postgres import Model, scoped_session + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class RuleDraftStatus(StrEnum): + DRAFT = 'draft' + DEPLOYED = 'deployed' + + +class RuleDraft(Model): + """A staged SML rule draft. + + Drafts are authored and validated in the UI and live here so the people who + operate Osprey can reference, edit, and deploy them without any external code + host. Deploying writes the SML into the configured rules directory; the draft + row stays as the record of what was deployed. + + One row per rule `path` (upserted on submit), so the table reads as the + current set of drafts rather than an append-only history. + """ + + __tablename__ = 'rule_drafts' + + id: int = Column(BigInteger, primary_key=True, autoincrement=True) + path: str = Column(Text, nullable=False, unique=True) + rule_name: str = Column(Text, nullable=False) + sml_source: str = Column(Text, nullable=False) + summary: str = Column(Text, nullable=False, default='') + # Osprey has no users table; identity is just an email with ACLs applied, so + # this stores the author's email rather than a foreign key. + author_email: str = Column(Text, nullable=False) + status: RuleDraftStatus = Column( + Enum(RuleDraftStatus, native_enum=False, length=32), + nullable=False, + default=RuleDraftStatus.DRAFT, + ) + created_at: datetime = Column(DateTime(timezone=True), nullable=False, default=_now) + updated_at: datetime = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now) + deployed_at: datetime | None = Column(DateTime(timezone=True), nullable=True) # type: ignore[misc] + + def to_json(self) -> dict[str, object]: + return { + 'id': self.id, + 'path': self.path, + 'rule_name': self.rule_name, + 'source': self.sml_source, + 'summary': self.summary, + 'author': self.author_email, + 'status': str(self.status), + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + 'deployed_at': self.deployed_at.isoformat() if self.deployed_at else None, + } + + @classmethod + def upsert(cls, *, path: str, rule_name: str, sml_source: str, summary: str, author_email: str) -> 'RuleDraft': + """Create the draft for `path`, or update it in place if one already exists. + + Uses a single `INSERT ... ON CONFLICT DO UPDATE` so two concurrent saves of + the same path can't both see "no row" and then race the unique constraint. + Editing a deployed draft moves it back to `DRAFT` so the table reflects that + the in-flight SML no longer matches what was last deployed. + """ + now = _now() + mutable = { + 'rule_name': rule_name, + 'sml_source': sml_source, + 'summary': summary, + 'author_email': author_email, + 'status': RuleDraftStatus.DRAFT, + 'updated_at': now, + } + statement = ( + pg_insert(cls.__table__) + .values(path=path, created_at=now, **mutable) + .on_conflict_do_update(index_elements=[cls.path], set_=mutable) + ) + with scoped_session(commit=True) as session: + session.execute(statement) + session.flush() + draft = session.query(cls).filter(cls.path == path).one() + session.expunge(draft) + return draft + + @classmethod + def list_all(cls) -> list['RuleDraft']: + with scoped_session() as session: + drafts = session.query(cls).order_by(cls.updated_at.desc()).all() + session.expunge_all() + return drafts + + @classmethod + def get_one(cls, draft_id: int) -> 'RuleDraft | None': + with scoped_session() as session: + draft = session.query(cls).filter(cls.id == draft_id).first() + if draft is not None: + session.expunge(draft) + return draft + + @classmethod + def other_with_rule_name(cls, rule_name: str, *, exclude_path: str) -> 'RuleDraft | None': + """A draft at a different path that already uses `rule_name`, if one exists. + + Rule names are global identifiers in SML, so two drafts sharing a name would + collide once both deploy. Validation only sees deployed rules, not other + drafts, so this catches the draft-vs-draft case that validation can't. + """ + with scoped_session() as session: + draft = session.query(cls).filter(cls.rule_name == rule_name, cls.path != exclude_path).first() + if draft is not None: + session.expunge(draft) + return draft + + @classmethod + def mark_deployed(cls, draft_id: int) -> 'RuleDraft | None': + with scoped_session(commit=True) as session: + draft = session.query(cls).filter(cls.id == draft_id).first() + if draft is None: + return None + draft.status = RuleDraftStatus.DEPLOYED + draft.deployed_at = _now() + session.flush() + session.expunge(draft) + return draft diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/app.py b/osprey_worker/src/osprey/worker/ui_api/osprey/app.py index 407462fe..4292b3df 100644 --- a/osprey_worker/src/osprey/worker/ui_api/osprey/app.py +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/app.py @@ -68,6 +68,7 @@ def create_app() -> Flask: events, features, queries, + rule_drafts, rules, rules_visualizer, saved_queries, @@ -111,6 +112,7 @@ def create_app() -> Flask: _register_with_prefix(app, events.blueprint) _register_with_prefix(app, features.blueprint) _register_with_prefix(app, rules.blueprint) + _register_with_prefix(app, rule_drafts.blueprint) _register_with_prefix(app, queries.blueprint) _register_with_prefix(app, config.blueprint) _register_with_prefix(app, docs.blueprint) diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py b/osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py index e56fffd2..3bdf1748 100644 --- a/osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py @@ -535,6 +535,7 @@ def _get_query_filter(self) -> dict[str, Any] | None: CanViewSavedQueries = register_ability('CAN_VIEW_SAVED_QUERIES')(make_marker_ability()) CanCreateAndEditSavedQueries = register_ability('CAN_CREATE_AND_EDIT_SAVED_QUERIES')(make_marker_ability()) CanBulkAction = register_ability('CAN_BULK_ACTION')(make_marker_ability()) +CanEditRuleDrafts = register_ability('CAN_EDIT_RULE_DRAFTS')(make_marker_ability()) def require_ability_with_request(request_model: ModelT, ability_class: Type[Ability[ModelT, ItemT]]) -> None: diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/rule_drafts.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/rule_drafts.py new file mode 100644 index 00000000..5af4baa5 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/rule_drafts.py @@ -0,0 +1,749 @@ +from __future__ import annotations + +import logging +import re +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from flask import Blueprint, jsonify, request +from osprey.engine.ast.error_utils import SpanWithHint +from osprey.engine.ast.grammar import ( + Assign, + BinaryComparison, + Call, + FormatString, + Name, + Not, + Number, + Source, + String, + UnaryOperation, +) +from osprey.engine.ast.grammar import ( + List as AstList, +) +from osprey.engine.ast.sources import Sources +from osprey.engine.ast_validator import validate_sources +from osprey.engine.ast_validator.validation_context import ( + ValidationError, + ValidationFailed, + ValidationWarning, +) +from osprey.worker.lib.singletons import CONFIG, ENGINE +from osprey.worker.lib.storage.rule_drafts import RuleDraft +from osprey.worker.ui_api.osprey.lib.abilities import CanEditRuleDrafts, require_ability +from osprey.worker.ui_api.osprey.lib.auth import get_current_user_email + +from ._engine_ast_utils import get_func_identifier + +logger = logging.getLogger(__name__) + +blueprint = Blueprint('rule_drafts', __name__) + +_VALID_PATH = re.compile(r'^[A-Za-z0-9_/-]+\.sml$') +_VALID_RULE_NAME = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + + +def _format_validation_message(msg: ValidationError | ValidationWarning) -> dict[str, Any]: + identifier: str | None = None + try: + # `ast_node` is a property that raises RuntimeError when the span has no node. + node = msg.span.ast_node + except (RuntimeError, AttributeError): + node = None + if isinstance(node, Name): + identifier = node.identifier + + defined_in: list[str] = [] + for additional in msg.additional_spans: + span = additional.span if isinstance(additional, SpanWithHint) else additional + defined_in.append(span.source.path) + + return { + 'message': msg.message, + 'hint': msg.hint, + 'source_path': msg.source.path, + 'line': msg.span.start_line, + 'column': msg.span.start_pos, + 'rendered': msg.rendered(), + 'identifier': identifier, + 'defined_in_source_paths': defined_in, + } + + +def _suggest_imports_from_errors( + draft_path: str, + errors: list[dict[str, Any]], +) -> list[str]: + """Collect source paths the draft references but doesn't import. + + Pulled from each error's `defined_in_source_paths`. main.sml is the engine + entry point and is never importable; the draft can't import itself either. + """ + suggested: set[str] = set() + for err in errors: + for path in err.get('defined_in_source_paths', []): + if path == 'main.sml' or path == draft_path: + continue + suggested.add(path) + return sorted(suggested) + + +def _validate_path(path: str) -> str | None: + if not _VALID_PATH.match(path): + return ( + f'Path {path!r} is not a valid SML source path. It must end in .sml and contain only ' + 'letters, numbers, underscores, slashes, and hyphens.' + ) + if path.startswith('/'): + # Rule paths are relative to the rules directory; an absolute path would + # escape it. Deploy also guards this with _resolve_within, but reject early. + return f'Path {path!r} must be relative to the rules directory, not absolute.' + if '..' in path.split('/'): + return f'Path {path!r} contains a parent-directory segment.' + return None + + +def _current_sources_dict() -> dict[str, str]: + engine = ENGINE.instance() + return engine.execution_graph.validated_sources.sources.to_dict() + + +@blueprint.route('/rule-drafts/source', methods=['GET']) +@require_ability(CanEditRuleDrafts) +def get_source() -> Any: + """Return the source of a rule already loaded by the engine (i.e. on disk). + + This is for editing an existing rule; it does not read the rule_drafts table. + A draft's own SML is loaded from the table via `GET /rule-drafts/`. + """ + path = request.args.get('path', '').strip() + err = _validate_path(path) + if err: + return jsonify({'error': err}), 400 + + engine = ENGINE.instance() + source: Source | None = engine.execution_graph.validated_sources.sources.get_by_path(path) + if source is None: + return jsonify({'error': f'No rule found at {path!r}.'}), 404 + return jsonify({'path': source.path, 'contents': source.contents}) + + +@dataclass +class _DraftValidation: + """The outcome of validating a draft spliced into the loaded sources.""" + + ok: bool + errors: list[dict[str, Any]] = field(default_factory=list) + warnings: list[dict[str, Any]] = field(default_factory=list) + suggested_imports: list[str] = field(default_factory=list) + # Set when the sources couldn't even be assembled (e.g. broken main.sml), + # which is distinct from the SML compiling but failing validation. + assemble_error: str | None = None + + +def _validate_draft_source(path: str, source_text: str) -> _DraftValidation: + """Splice the draft into the loaded sources and run AST validation. + + Shared by POST /rule-drafts/validate (which reports the result) and the + server-side re-validation on create/deploy (which rejects on failure), so the + two paths can't drift apart. + """ + spliced = _current_sources_dict() + spliced[path] = source_text + try: + sources = Sources.from_dict(spliced) + except Exception: + # Don't echo the raw exception to the client (it can leak internals). + logger.exception('failed to assemble sources for draft at %r', path) + return _DraftValidation(ok=False, assemble_error='Could not assemble the rule sources for validation.') + + engine = ENGINE.instance() + try: + validated = validate_sources( + sources, + udf_registry=engine.udf_registry, + validator_registry=engine.validator_registry, + ) + except ValidationFailed as exc: + errors = [_format_validation_message(e) for e in exc.errors] + return _DraftValidation( + ok=False, + errors=errors, + warnings=[_format_validation_message(w) for w in exc.warnings], + suggested_imports=_suggest_imports_from_errors(path, errors), + ) + + return _DraftValidation( + ok=True, + warnings=[_format_validation_message(w) for w in validated.warnings], + ) + + +@blueprint.route('/rule-drafts/validate', methods=['POST']) +@require_ability(CanEditRuleDrafts) +def validate_draft() -> Any: + """Splice the draft into the engine's sources and re-run AST validation. + + A 200 with `{ok: false, errors: [...]}` means the SML failed validation; the + response is still JSON so the editor can render structured errors inline. + A 400 means the request itself was malformed (bad path, missing source). + """ + payload = request.get_json(silent=True) or {} + path = (payload.get('path') or '').strip() + source_text = payload.get('source', '') + + path_err = _validate_path(path) + if path_err: + return jsonify({'error': path_err}), 400 + if not isinstance(source_text, str): + return jsonify({'error': 'source must be a string.'}), 400 + + result = _validate_draft_source(path, source_text) + if result.assemble_error is not None: + # main.sml (or another source) is broken; surface it so the editor can show it. + return jsonify( + { + 'ok': False, + 'errors': [{'message': result.assemble_error, 'hint': '', 'source_path': path, 'line': 0, 'column': 0}], + 'warnings': [], + } + ), 400 + + return jsonify( + { + 'ok': result.ok, + 'errors': result.errors, + 'warnings': result.warnings, + 'suggested_imports': result.suggested_imports, + } + ) + + +def _iter_top_level_assigns(sources: Iterable[Source]) -> Iterable[tuple[Source, Assign]]: + for source in sources: + for statement in source.ast_root.statements: + if isinstance(statement, Assign): + yield source, statement + + +def _is_rule_call(node: Any) -> bool: + return isinstance(node, Call) and get_func_identifier(node) == 'Rule' + + +def _collect_features(sources: Iterable[Source]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for source, assign in _iter_top_level_assigns(sources): + name = assign.target.identifier + if name in seen: + continue + # Skip `MyRule = Rule(...)` assigns; the builder dropdown is for values + # a user can reference inside conditions, not for rule definitions. + if _is_rule_call(assign.value): + continue + seen.add(name) + out.append( + { + 'name': name, + 'source_path': source.path, + 'source_line': assign.span.start_line, + } + ) + out.sort(key=lambda item: item['name']) + return out + + +def _collect_udfs() -> list[dict[str, Any]]: + engine = ENGINE.instance() + udf_registry = engine.udf_registry + out: list[dict[str, Any]] = [] + for func in sorted(udf_registry.iter_functions(), key=lambda f: f.__name__): + try: + args_type = func.get_arguments_type() + rvalue_type = func.get_rvalue_type() + except Exception: + continue + arguments: list[dict[str, Any]] = [] + try: + items = args_type.items().items() + except Exception: + items = [] + for arg_name, arg_type in items: + arguments.append( + { + 'name': arg_name, + 'type_name': getattr(arg_type, '__name__', str(arg_type)), + } + ) + out.append( + { + 'name': func.__name__, + 'return_type': getattr(rvalue_type, '__name__', str(rvalue_type)), + 'arguments': arguments, + } + ) + return out + + +def _collect_effects(sources: Iterable[Source]) -> list[str]: + """Names of UDFs that appear inside a `WhenRules(then=[...])` block. + + Used as the effect dropdown. Sourced from real usage rather than from the + UDF registry because the registry holds every UDF and we want a shortlist + of things users actually use as actions. + """ + seen: set[str] = set() + + def _walk(node: Any) -> None: + if isinstance(node, Call): + ident = get_func_identifier(node) + if ident: + seen.add(ident) + for arg in node.arguments: + _walk(arg.value) + elif isinstance(node, AstList): + for item in node.items: + _walk(item) + elif isinstance(node, Assign): + _walk(node.value) + + for source in sources: + for statement in source.ast_root.statements: + call_node: Call | None = None + if isinstance(statement, Call) and get_func_identifier(statement) == 'WhenRules': + call_node = statement + elif ( + isinstance(statement, Assign) + and isinstance(statement.value, Call) + and get_func_identifier(statement.value) == 'WhenRules' + ): + call_node = statement.value + if call_node is None: + continue + then_arg = call_node.find_argument('then') + if then_arg is None: + continue + _walk(then_arg.value) + + return sorted(seen) + + +@blueprint.route('/rule-drafts/vocabulary', methods=['GET']) +@require_ability(CanEditRuleDrafts) +def vocabulary() -> Any: + engine = ENGINE.instance() + sources = list(engine.execution_graph.validated_sources.sources) + features = _collect_features(sources) + udfs = _collect_udfs() + effects = _collect_effects(sources) + source_files = sorted(s.path for s in sources) + return jsonify( + { + 'features': features, + 'udfs': udfs, + 'effects': effects, + 'source_files': source_files, + } + ) + + +def _revalidate(path: str, source_text: str) -> tuple[Any, int] | None: + """Re-run validation server-side, rejecting on failure. Returns a Flask error + tuple to return, or None if the draft validates. Shares `_validate_draft_source` + with the /validate endpoint so create/deploy can't accept SML the editor rejected.""" + result = _validate_draft_source(path, source_text) + if result.assemble_error is not None: + return jsonify({'error': result.assemble_error}), 400 + if not result.ok: + return jsonify({'error': 'Validation failed; fix errors before submitting.', 'errors': result.errors}), 400 + return None + + +@blueprint.route('/rule-drafts', methods=['POST']) +@require_ability(CanEditRuleDrafts) +def create_draft() -> Any: + """Validate a draft and upsert it into the rule_drafts table (one row per path).""" + payload = request.get_json(silent=True) or {} + path = (payload.get('path') or '').strip() + source_text = payload.get('source', '') + rule_name = (payload.get('rule_name') or '').strip() + summary = (payload.get('summary') or '').strip() + + path_err = _validate_path(path) + if path_err: + return jsonify({'error': path_err}), 400 + if path == 'main.sml': + # main.sml is the engine entry point; a draft never replaces it wholesale. + # Deploying a draft optionally wires it into main.sml with a single Require line. + return jsonify( + { + 'error': 'main.sml is the engine entry point and cannot be saved as a draft. ' + 'Deploy a rule with wire_into_main to add a Require line instead.' + } + ), 400 + if not isinstance(source_text, str) or not source_text.strip(): + return jsonify({'error': 'source must be a non-empty string.'}), 400 + if not _VALID_RULE_NAME.match(rule_name): + return jsonify({'error': 'rule_name must be a valid SML identifier ([A-Za-z_][A-Za-z0-9_]*).'}), 400 + + # Re-validate server-side so a client that skips the validate step still cannot store uncompilable SML. + error = _revalidate(path, source_text) + if error is not None: + return error + + # Validation only sees deployed rules; guard the draft-vs-draft name collision it can't. + conflict = RuleDraft.other_with_rule_name(rule_name, exclude_path=path) + if conflict is not None: + return jsonify( + { + 'error': f'Another draft ({conflict.path}) already uses the rule name {rule_name!r}. ' + 'Rename this rule, or edit that draft instead.' + } + ), 409 + + draft = RuleDraft.upsert( + path=path, + rule_name=rule_name, + sml_source=source_text, + summary=summary, + author_email=get_current_user_email(), + ) + return jsonify(draft.to_json()) + + +@blueprint.route('/rule-drafts', methods=['GET']) +@require_ability(CanEditRuleDrafts) +def list_drafts() -> Any: + """The draft rules table: every staged draft, newest-edited first.""" + return jsonify({'drafts': [d.to_json() for d in RuleDraft.list_all()]}) + + +@blueprint.route('/rule-drafts/', methods=['GET']) +@require_ability(CanEditRuleDrafts) +def get_draft(draft_id: int) -> Any: + draft = RuleDraft.get_one(draft_id) + if draft is None: + return jsonify({'error': f'No draft with id {draft_id}.'}), 404 + return jsonify(draft.to_json()) + + +def _rules_dir_or_error() -> tuple[Path | None, tuple[Any, int] | None]: + """The directory deploy writes into (OSPREY_RULES_LOCAL_PATH), or an error tuple. + + Deploying into the engine's own rules source is a filesystem hand-off, the same + contract the retired `local` backend used: whatever pipeline already syncs that + directory (etcd push, file watcher) activates the rule. A DB-backed + SourcesProvider that lets the engine read deployed drafts straight from this + table would remove that dependency entirely; see the PR notes. + """ + raw = CONFIG.instance().get_str('OSPREY_RULES_LOCAL_PATH', '').strip() + if not raw: + return None, ( + jsonify( + { + 'error': 'Deploy is not configured. Set OSPREY_RULES_LOCAL_PATH to the rules ' + 'directory the engine loads so deployed drafts are written there.' + } + ), + 503, + ) + rules_dir = Path(raw) + if not rules_dir.is_dir(): + return None, (jsonify({'error': f'OSPREY_RULES_LOCAL_PATH {raw!r} is not a directory.'}), 503) + return rules_dir, None + + +def _resolve_within(rules_dir: Path, draft_path: str) -> Path | None: + """Resolve draft_path inside rules_dir, or None if it would escape via `..`/symlink.""" + candidate = (rules_dir / draft_path).resolve() + try: + candidate.relative_to(rules_dir.resolve()) + except ValueError: + return None + return candidate + + +def _main_requires(main_sml: str, draft_path: str) -> bool: + pattern = re.compile(r"Require\s*\(\s*rule\s*=\s*['\"]" + re.escape(draft_path) + r"['\"]\s*\)", re.MULTILINE) + return bool(pattern.search(main_sml)) + + +def _append_require(main_sml: str, draft_path: str) -> str: + suffix = f"\nRequire(rule='{draft_path}')\n" + if main_sml and not main_sml.endswith('\n'): + suffix = '\n' + suffix + return main_sml + suffix + + +@blueprint.route('/rule-drafts//deploy', methods=['POST']) +@require_ability(CanEditRuleDrafts) +def deploy_draft(draft_id: int) -> Any: + """Write a draft's SML into the configured rules directory and mark it deployed. + + With `wire_into_main`, also append a `Require(rule=...)` line to main.sml so the + rule takes effect (the file on its own is inert until something requires it). + """ + draft = RuleDraft.get_one(draft_id) + if draft is None: + return jsonify({'error': f'No draft with id {draft_id}.'}), 404 + + payload = request.get_json(silent=True) or {} + wire_into_main = bool(payload.get('wire_into_main', False)) + + # Re-validate at deploy time: the loaded sources may have changed since the draft was saved. + error = _revalidate(draft.path, draft.sml_source) + if error is not None: + return error + + rules_dir, dir_error = _rules_dir_or_error() + if dir_error is not None: + return dir_error + assert rules_dir is not None + + target = _resolve_within(rules_dir, draft.path) + if target is None: + return jsonify({'error': f'Draft path {draft.path!r} escapes the rules directory.'}), 400 + + # If wiring is requested, verify main.sml exists before writing anything, so a + # missing main.sml doesn't leave the rule file written while the deploy 409s. + main_path = rules_dir / 'main.sml' + if wire_into_main and not main_path.exists(): + return jsonify({'error': 'wire_into_main requested but main.sml does not exist in the rules directory.'}), 409 + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(draft.sml_source, encoding='utf-8') + + main_sml_updated = False + if wire_into_main: + main_contents = main_path.read_text(encoding='utf-8') + if not _main_requires(main_contents, draft.path): + main_path.write_text(_append_require(main_contents, draft.path), encoding='utf-8') + main_sml_updated = True + + deployed = RuleDraft.mark_deployed(draft_id) + result = deployed.to_json() if deployed is not None else draft.to_json() + result['main_sml_updated'] = main_sml_updated + # Report the path relative to the rules directory, not the absolute server path + # (which would leak the deployment's directory layout to the client). + result['path_on_disk'] = str(target.relative_to(rules_dir.resolve())) + return jsonify(result) + + +# The set of comparator strings the Rule Builder UI can render. +_BUILDER_COMPARATORS = {'==', '!=', '>', '<', '>=', '<='} + + +def _condition_from_value(node: Any, feature: str, operator: str) -> dict[str, Any] | None: + """Build a builder Condition row from an RHS node, returning None if the node + isn't a literal or a bare Name (the only RHS shapes the builder supports).""" + if isinstance(node, Name): + return {'feature': feature, 'operator': operator, 'rhs': node.identifier, 'rhsIsFeature': True} + if isinstance(node, String): + return {'feature': feature, 'operator': operator, 'rhs': node.value, 'rhsIsFeature': False} + if isinstance(node, Number): + return {'feature': feature, 'operator': operator, 'rhs': str(node.value), 'rhsIsFeature': False} + return None + + +def _parse_text_contains_call(call: Call, operator: str) -> dict[str, Any] | None: + """Convert a `TextContains(text=Name, phrase=...)` call to an includes/excludes row. + + Returns None if the call doesn't have the exact shape the builder emits. + """ + if get_func_identifier(call) != 'TextContains': + return None + text_arg = call.find_argument('text') + phrase_arg = call.find_argument('phrase') + if text_arg is None or phrase_arg is None: + return None + if not isinstance(text_arg.value, Name): + return None + feature = text_arg.value.identifier + return _condition_from_value(phrase_arg.value, feature, operator) + + +def _parse_condition(node: Any) -> dict[str, Any] | None: + if isinstance(node, UnaryOperation) and isinstance(node.operator, Not) and isinstance(node.operand, Call): + return _parse_text_contains_call(node.operand, 'excludes') + if isinstance(node, Call): + return _parse_text_contains_call(node, 'includes') + if isinstance(node, BinaryComparison): + if not isinstance(node.left, Name): + return None + operator = node.comparator.original_comparator + if operator not in _BUILDER_COMPARATORS: + return None + return _condition_from_value(node.right, node.left.identifier, operator) + return None + + +def _parse_outcome_arg(arg: Any) -> dict[str, Any] | None: + """Convert one `Call.arguments[i]` into a builder OutcomeArg, or None for + anything richer than a literal or bare Name reference.""" + val = arg.value + if isinstance(val, Name): + return {'name': arg.name, 'value': val.identifier, 'isFeature': True} + if isinstance(val, String): + return {'name': arg.name, 'value': val.value, 'isFeature': False} + if isinstance(val, Number): + return {'name': arg.name, 'value': str(val.value), 'isFeature': False} + return None + + +def _parse_outcome(node: Any) -> dict[str, Any] | None: + if not isinstance(node, Call): + return None + effect = get_func_identifier(node) + if effect is None: + return None + args: list[dict[str, Any]] = [] + for arg in node.arguments: + parsed = _parse_outcome_arg(arg) + if parsed is None: + return None + args.append(parsed) + return {'effect': effect, 'args': args} + + +def _parse_into_builder_model(source: Source) -> dict[str, Any]: + """Walk the AST of a single draft Source and either return a populated + builder model JSON or `{supported: False, reason: ...}`. + + The builder's expressible subset is deliberately narrow: optional Import + and Require statements (ignored for the model), exactly one + `RuleName = Rule(when_all=[...], description='...')`, and an optional + `WhenRules(rules_any=[RuleName], then=[...])` whose `then` entries are + UDF calls with literal or Name arguments. Anything richer means the file + can't round-trip and the user must use Code Editor. + """ + try: + statements = source.ast_root.statements + except Exception as exc: + return {'supported': False, 'reason': f'could not parse SML: {exc}'} + + rule_assign: Assign | None = None + when_rules_call: Call | None = None + + for stmt in statements: + if isinstance(stmt, Call): + ident = get_func_identifier(stmt) + if ident in ('Import', 'Require'): + continue + if ident == 'WhenRules': + if when_rules_call is not None: + return { + 'supported': False, + 'reason': 'multiple WhenRules blocks; Rule Builder edits one rule at a time', + } + when_rules_call = stmt + continue + return {'supported': False, 'reason': f'top-level call to `{ident}` is not supported by Rule Builder'} + if isinstance(stmt, Assign) and isinstance(stmt.value, Call) and get_func_identifier(stmt.value) == 'Rule': + if rule_assign is not None: + return { + 'supported': False, + 'reason': 'multiple Rule definitions in one file; Rule Builder edits one rule at a time', + } + rule_assign = stmt + continue + if isinstance(stmt, Assign): + return { + 'supported': False, + 'reason': f'helper assignment `{stmt.target.identifier} = ...` is not supported by Rule Builder', + } + return {'supported': False, 'reason': f'unsupported top-level statement: {type(stmt).__name__}'} + + if rule_assign is None: + return {'supported': False, 'reason': 'no Rule(...) definition found in this file'} + + rule_name = rule_assign.target.identifier + rule_call = rule_assign.value + assert isinstance(rule_call, Call) + + description = '' + description_arg = rule_call.find_argument('description') + if description_arg is not None: + if isinstance(description_arg.value, String): + description = description_arg.value.value + elif isinstance(description_arg.value, FormatString): + # Round-trip the raw template; the builder doesn't expose format-string editing. + description = description_arg.value.format_string + else: + return {'supported': False, 'reason': 'rule description must be a string literal'} + + when_all_arg = rule_call.find_argument('when_all') + if when_all_arg is None or not isinstance(when_all_arg.value, AstList): + return {'supported': False, 'reason': 'Rule must have `when_all=[...]`'} + + conditions: list[dict[str, Any]] = [] + for item in when_all_arg.value.items: + cond = _parse_condition(item) + if cond is None: + return { + 'supported': False, + 'reason': 'one or more conditions use expressions Rule Builder cannot represent', + } + conditions.append(cond) + if not conditions: + # Builder needs at least one row to render anything sensible; matching the EMPTY_BUILDER_MODEL default. + conditions = [{'feature': '', 'operator': '==', 'rhs': '', 'rhsIsFeature': False}] + + outcomes: list[dict[str, Any]] = [] + if when_rules_call is not None: + rules_any_arg = when_rules_call.find_argument('rules_any') + if rules_any_arg is not None and isinstance(rules_any_arg.value, AstList): + for item in rules_any_arg.value.items: + if not isinstance(item, Name) or item.identifier != rule_name: + return { + 'supported': False, + 'reason': 'WhenRules.rules_any must reference only the rule being edited', + } + then_arg = when_rules_call.find_argument('then') + if then_arg is not None and isinstance(then_arg.value, AstList): + for item in then_arg.value.items: + outcome = _parse_outcome(item) + if outcome is None: + return { + 'supported': False, + 'reason': 'one or more outcomes use expressions Rule Builder cannot represent', + } + outcomes.append(outcome) + if not outcomes: + outcomes = [{'effect': '', 'args': []}] + + return { + 'supported': True, + 'model': { + 'ruleName': rule_name, + 'description': description, + 'conditions': conditions, + 'outcomes': outcomes, + }, + } + + +@blueprint.route('/rule-drafts/parse-into-builder', methods=['POST']) +@require_ability(CanEditRuleDrafts) +def parse_into_builder() -> Any: + """Attempt to render an existing SML file as a Rule Builder model. + + Returns `{supported: true, model: {...}}` if the file fits the builder's + expressible subset, or `{supported: false, reason: "..."}` otherwise. The + UI uses this to decide whether to enable the Rule Builder toggle when + editing an existing rule. + """ + payload = request.get_json(silent=True) or {} + path = (payload.get('path') or '').strip() + source_text = payload.get('source', '') + + path_err = _validate_path(path) + if path_err: + return jsonify({'error': path_err}), 400 + if not isinstance(source_text, str): + return jsonify({'error': 'source must be a string.'}), 400 + + source = Source(path=path, contents=source_text) + return jsonify(_parse_into_builder_model(source)) diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rule_drafts.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rule_drafts.py new file mode 100644 index 00000000..1d0ac5dd --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rule_drafts.py @@ -0,0 +1,478 @@ +import json +from unittest.mock import patch + +import pytest +from flask import Response, url_for +from flask.testing import FlaskClient +from osprey.worker.lib.singletons import CONFIG +from osprey.worker.lib.snowflake import Snowflake +from osprey.worker.lib.storage.postgres import scoped_session +from osprey.worker.lib.storage.rule_drafts import RuleDraft + + +def _set_rules_dir(monkeypatch: pytest.MonkeyPatch, path: object) -> None: + # Deploy reads OSPREY_RULES_LOCAL_PATH via CONFIG, which is bound once at app + # setup, so set it on the already-bound config for the deploy handler to see. + monkeypatch.setenv('OSPREY_RULES_LOCAL_PATH', str(path)) + CONFIG.instance()._config_dict['OSPREY_RULES_LOCAL_PATH'] = str(path) + + +def _unset_rules_dir(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv('OSPREY_RULES_LOCAL_PATH', raising=False) + CONFIG.instance()._config_dict.pop('OSPREY_RULES_LOCAL_PATH', None) + + +@pytest.fixture(autouse=True) +def _clear_rule_drafts(): + # The test database is session-scoped, so drafts persist across tests. Start each + # test with an empty table, or leaked rows trip the rule-name uniqueness check. + with scoped_session(commit=True) as session: + session.query(RuleDraft).delete() + yield + + +@pytest.fixture(autouse=True) +def _mock_audit_snowflake(): + # The after_request audit hook mints a snowflake id, which normally means an + # HTTP call to the snowflake-id-worker service. Neutralize it here so tests + # don't reach for that service (the audit log's persist() is already mocked + # in the shared conftest). + with patch('osprey.worker.ui_api.osprey.lib.audit.generate_snowflake', return_value=Snowflake(1)): + yield + + +_acl_with_draft_ability = json.dumps( + { + 'ui_config': {}, + 'labels': {}, + 'acl': { + 'users': { + 'local-dev@localhost': { + 'abilities': [ + {'name': 'CAN_VIEW_DOCS', 'allow_all': True}, + {'name': 'CAN_EDIT_RULE_DRAFTS', 'allow_all': True}, + ], + }, + }, + }, + } +) + +_acl_without_draft_ability = json.dumps( + { + 'ui_config': {}, + 'labels': {}, + 'acl': { + 'users': { + 'local-dev@localhost': {'abilities': [{'name': 'CAN_VIEW_DOCS', 'allow_all': True}]}, + }, + }, + } +) + +_base_sources = { + 'config.yaml': _acl_with_draft_ability, + 'models/base.sml': """ + UserId: str = JsonData(path='$.user_id') + PostText: str = JsonData(path='$.post_text') + """, + 'main.sml': """ + Import(rules=['models/base.sml']) + + ContainsHello = Rule( + when_all=[PostText == 'hello'], + description='Post contains hello', + ) + + WhenRules( + rules_any=[ContainsHello], + then=[DeclareVerdict(verdict=UserId)], + ) + """, +} + + +@pytest.mark.use_rules_sources(_base_sources) +def test_get_source_returns_contents(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.get_source'), query_string={'path': 'main.sml'}) + assert res.status_code == 200 + assert res.json is not None + assert res.json['path'] == 'main.sml' + assert 'ContainsHello' in res.json['contents'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_get_source_rejects_bad_path(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.get_source'), query_string={'path': '../etc/passwd.sml'}) + assert res.status_code == 400 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_get_source_404_for_unknown_path(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.get_source'), query_string={'path': 'rules/does_not_exist.sml'}) + assert res.status_code == 404 + + +@pytest.mark.use_rules_sources( + { + 'config.yaml': _acl_without_draft_ability, + 'main.sml': "UserId: str = JsonData(path='$.user_id')", + } +) +def test_endpoints_require_can_edit_rule_drafts(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.get_source'), query_string={'path': 'main.sml'}) + assert res.status_code == 401 + res = client.post( + url_for('rule_drafts.validate_draft'), + json={'path': 'rules/x.sml', 'source': ''}, + ) + assert res.status_code == 401 + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/x.sml', 'source': ''}, + ) + assert res.status_code == 401 + res = client.get(url_for('rule_drafts.vocabulary')) + assert res.status_code == 401 + res = client.get(url_for('rule_drafts.list_drafts')) + assert res.status_code == 401 + res = client.post(url_for('rule_drafts.create_draft'), json={}) + assert res.status_code == 401 + res = client.post(url_for('rule_drafts.deploy_draft', draft_id=1), json={}) + assert res.status_code == 401 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_validate_clean_draft_returns_ok(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + }, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['ok'] is True + assert body['errors'] == [] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_validate_broken_draft_returns_structured_errors(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={ + 'path': 'rules/broken.sml', + 'source': 'this is not valid SML at all *** !!!', + }, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['ok'] is False + assert len(body['errors']) >= 1 + err = body['errors'][0] + assert set(err.keys()) >= {'message', 'hint', 'source_path', 'line', 'column', 'rendered'} + + +@pytest.mark.use_rules_sources( + { + 'config.yaml': _acl_with_draft_ability, + 'main.sml': "Import(rules=['models/post.sml'])", + 'models/post.sml': "PostText: str = JsonData(path='$.post_text')", + } +) +def test_validate_returns_suggested_imports_for_unimported_identifier(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={ + 'path': 'rules/uses_post_text.sml', + 'source': "MyRule = Rule(when_all=[PostText == 'hi'], description='hi')", + }, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['ok'] is False + assert body['suggested_imports'] == ['models/post.sml'] + assert any(e.get('identifier') == 'PostText' for e in body['errors']) + + +@pytest.mark.use_rules_sources(_base_sources) +def test_validate_clean_draft_has_empty_suggested_imports(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + }, + ) + assert res.status_code == 200 + assert res.json is not None + assert res.json['suggested_imports'] == [] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_validate_rejects_bad_path(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={'path': 'rules/x.txt', 'source': ''}, + ) + assert res.status_code == 400 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_vocabulary_returns_features_udfs_effects(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.vocabulary')) + assert res.status_code == 200 + body = res.json + assert body is not None + assert set(body.keys()) == {'features', 'udfs', 'effects', 'source_files'} + + feature_names = {f['name'] for f in body['features']} + assert {'UserId', 'PostText'}.issubset(feature_names) + assert 'ContainsHello' not in feature_names + + udf_names = {u['name'] for u in body['udfs']} + assert 'JsonData' in udf_names + assert 'Rule' in udf_names + assert 'DeclareVerdict' in body['effects'] + + assert 'main.sml' in body['source_files'] + + +# --- Draft table: create / list / get ------------------------------------- + +_VALID_DRAFT = "Import(rules=['models/base.sml'])\nSomeRule = Rule(when_all=[PostText == 'bye'], description='bye')" + + +@pytest.mark.use_rules_sources(_base_sources) +def test_create_draft_persists_and_lists(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/spam.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': 'catch spam'}, + ) + assert res.status_code == 200 + draft = res.json + assert draft is not None + assert draft['path'] == 'rules/spam.sml' + assert draft['rule_name'] == 'SomeRule' + assert draft['summary'] == 'catch spam' + assert draft['status'] == 'draft' + assert draft['id'] is not None + + res = client.get(url_for('rule_drafts.list_drafts')) + assert res.status_code == 200 + assert res.json is not None + assert any(d['path'] == 'rules/spam.sml' for d in res.json['drafts']) + + +@pytest.mark.use_rules_sources(_base_sources) +def test_create_draft_upserts_same_path_in_place(client: 'FlaskClient[Response]') -> None: + first = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/dupe.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': 'v1'}, + ) + assert first.status_code == 200 + assert first.json is not None + original_id = first.json['id'] + + second = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/dupe.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': 'v2'}, + ) + assert second.status_code == 200 + assert second.json is not None + assert second.json['id'] == original_id + assert second.json['summary'] == 'v2' + + res = client.get(url_for('rule_drafts.list_drafts')) + assert res.json is not None + assert len([d for d in res.json['drafts'] if d['path'] == 'rules/dupe.sml']) == 1 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_create_draft_rejects_duplicate_rule_name_across_drafts(client: 'FlaskClient[Response]') -> None: + first = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/first.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert first.status_code == 200 + + # A different path reusing the same rule name collides: rule names are global in SML, + # and validation can't see the other draft (it only knows deployed rules). + second = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/second.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert second.status_code == 409 + assert second.json is not None + assert 'SomeRule' in second.json['error'] + + # Re-saving the same path with the same name is an update, not a collision. + again = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/first.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': 'edit'}, + ) + assert again.status_code == 200 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_create_draft_rejects_invalid_sml(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.create_draft'), + json={ + 'path': 'rules/broken.sml', + 'rule_name': 'Broken', + 'source': "AnotherRule = Rule(when_all=[NonexistentFeature == 'x'], description='x')", + 'summary': '', + }, + ) + assert res.status_code == 400 + assert res.json is not None + assert 'error' in res.json + + +@pytest.mark.use_rules_sources(_base_sources) +def test_create_draft_rejects_main_sml(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'main.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert res.status_code == 400 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_create_draft_rejects_bad_rule_name(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/x.sml', 'rule_name': '9 not valid', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert res.status_code == 400 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_get_draft_returns_one_and_404s(client: 'FlaskClient[Response]') -> None: + created = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/getme.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert created.json is not None + draft_id = created.json['id'] + + res = client.get(url_for('rule_drafts.get_draft', draft_id=draft_id)) + assert res.status_code == 200 + assert res.json is not None + assert res.json['path'] == 'rules/getme.sml' + + res = client.get(url_for('rule_drafts.get_draft', draft_id=999999)) + assert res.status_code == 404 + + +# --- Draft table: deploy -------------------------------------------------- + + +@pytest.mark.use_rules_sources(_base_sources) +def test_deploy_writes_sml_and_marks_deployed( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + _set_rules_dir(monkeypatch, tmp_path) + created = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/deploy.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert created.json is not None + draft_id = created.json['id'] + + res = client.post(url_for('rule_drafts.deploy_draft', draft_id=draft_id), json={}) + assert res.status_code == 200 + assert res.json is not None + assert res.json['status'] == 'deployed' + assert res.json['deployed_at'] is not None + assert res.json['main_sml_updated'] is False + + written = tmp_path / 'rules' / 'deploy.sml' + assert written.exists() + assert written.read_text() == _VALID_DRAFT + + +@pytest.mark.use_rules_sources(_base_sources) +def test_deploy_wire_into_main_appends_require( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + (tmp_path / 'main.sml').write_text("Import(rules=['models/base.sml'])\n") + _set_rules_dir(monkeypatch, tmp_path) + created = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/wired.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert created.json is not None + draft_id = created.json['id'] + + res = client.post(url_for('rule_drafts.deploy_draft', draft_id=draft_id), json={'wire_into_main': True}) + assert res.status_code == 200 + assert res.json is not None + assert res.json['main_sml_updated'] is True + assert "Require(rule='rules/wired.sml')" in (tmp_path / 'main.sml').read_text() + + +@pytest.mark.use_rules_sources(_base_sources) +def test_deploy_wire_into_main_is_idempotent( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + (tmp_path / 'main.sml').write_text("Import(rules=['models/base.sml'])\nRequire(rule='rules/already.sml')\n") + _set_rules_dir(monkeypatch, tmp_path) + created = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/already.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert created.json is not None + draft_id = created.json['id'] + + res = client.post(url_for('rule_drafts.deploy_draft', draft_id=draft_id), json={'wire_into_main': True}) + assert res.status_code == 200 + assert res.json is not None + assert res.json['main_sml_updated'] is False + assert (tmp_path / 'main.sml').read_text().count("Require(rule='rules/already.sml')") == 1 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_deploy_wire_into_main_409_when_main_missing( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + _set_rules_dir(monkeypatch, tmp_path) + created = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/nomain.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert created.json is not None + draft_id = created.json['id'] + + res = client.post(url_for('rule_drafts.deploy_draft', draft_id=draft_id), json={'wire_into_main': True}) + assert res.status_code == 409 + # The rule file must not be written when the deploy 409s on a missing main.sml. + assert not (tmp_path / 'rules' / 'nomain.sml').exists() + + +@pytest.mark.use_rules_sources(_base_sources) +def test_deploy_503_when_rules_dir_unset(client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch) -> None: + _unset_rules_dir(monkeypatch) + created = client.post( + url_for('rule_drafts.create_draft'), + json={'path': 'rules/nodir.sml', 'rule_name': 'SomeRule', 'source': _VALID_DRAFT, 'summary': ''}, + ) + assert created.json is not None + draft_id = created.json['id'] + + res = client.post(url_for('rule_drafts.deploy_draft', draft_id=draft_id), json={}) + assert res.status_code == 503 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_deploy_404_for_unknown_draft( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + _set_rules_dir(monkeypatch, tmp_path) + res = client.post(url_for('rule_drafts.deploy_draft', draft_id=999999), json={}) + assert res.status_code == 404