Skip to content

Commit bcd5a3e

Browse files
julietshenclaude
andcommitted
Add rule-drafts API with pluggable submission backends (GitHub, local)
New ui-api blueprint for authoring SML rule drafts from the UI: - /rule-drafts/source, /validate, /vocabulary, /submit, /pending, /parse-into-builder, all gated by a new CAN_EDIT_RULE_DRAFTS ability - Drafts are spliced into the engine's loaded sources and re-run through the same AST validation the engine uses, both on validate and again server-side on submit - Submission routes through a RuleSubmissionBackend Protocol selected by OSPREY_RULES_SUBMISSION_BACKEND: github (opens a PR via the REST API, supports GitHub Enterprise), local (writes to a mounted rules dir), and null (fail-fast default so an unconfigured install never writes) - Adopter docs for the env vars in docs/user/manage.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZ4RQtuHCCgurfpjfPXXAM
1 parent f7fc4ca commit bcd5a3e

12 files changed

Lines changed: 2103 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,5 +307,8 @@ Cargo.lock
307307

308308
.claude
309309

310+
# Local docker compose overrides (env vars, port remaps, secrets)
311+
docker-compose.override.yaml
312+
310313
# docs output
311314
book/

docs/user/manage.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,42 @@ The list is paginated (50 per page) and can be filtered and sorted:
5656
- **Sort**: by name, most referenced, or least referenced
5757

5858
Each row shows the rule's name, source file, description, reference count, and line number within the source file.
59+
60+
## Rule Authoring (Experimental feature)
61+
62+
Users can draft SML rules directly in the UI. Submit opens a review unit against a configured git remote so authoring, review, and merge use the same tools users already have.
63+
64+
The editor validates every keystroke against the same AST validator the running engine uses, so compile-time errors surface before the pull request opens. 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.
65+
66+
### Rule submission backends
67+
68+
The Submit button routes drafts through a pluggable backend. Pick one for your deployment by setting `OSPREY_RULES_SUBMISSION_BACKEND` on the `osprey-ui-api` process:
69+
70+
| Value | What it does | Required env vars |
71+
|---|---|---|
72+
| `null` (default) | Returns 503 on any submit or list call. Ships as the default so an unconfigured install never writes anything. | none |
73+
| `github` | Opens a pull request on a configured repo. Works with github.com and GitHub Enterprise. | `OSPREY_RULES_REPO`, `OSPREY_GITHUB_TOKEN` (+ optionals) |
74+
| `local` | Writes SML directly to a mounted directory. For self-hosted setups whose deploy pipeline already syncs a rules directory into the engine. | `OSPREY_RULES_LOCAL_PATH` |
75+
76+
Env vars shared across every backend that targets a git host:
77+
78+
- `OSPREY_RULES_BASE_BRANCH` (default `main`) — the branch the review targets.
79+
- `OSPREY_RULES_PATH_IN_REPO` (default empty) — subdirectory inside the target repo where rule files live, e.g. `example_rules`. Leave empty if rules sit at the repo root.
80+
81+
#### `github`
82+
83+
| Var | Default | Notes |
84+
|---|---|---|
85+
| `OSPREY_RULES_REPO` | _required_ | `owner/name` of the repo to PR against. |
86+
| `OSPREY_GITHUB_TOKEN` | _required_ | Fine-grained PAT with `Contents: read/write` and `Pull requests: read/write` on the repo. |
87+
| `OSPREY_GITHUB_API_URL` | `https://api.github.com` | Set for GitHub Enterprise: e.g. `https://github.acme.example/api/v3`. |
88+
89+
#### `local`
90+
91+
| Var | Default | Notes |
92+
|---|---|---|
93+
| `OSPREY_RULES_LOCAL_PATH` | _required_ | Absolute path to the directory the backend writes SML into. Must already exist. Submissions take effect immediately; there's no review queue. |
94+
95+
### Adding a rule submission backend
96+
97+
Add a Python module next to `_rule_drafts_github.py` that implements the `RuleSubmissionBackend` Protocol defined in `_rule_drafts_backend.py`, then wire it into `load_backend()`. See the module docstring on `_rule_drafts_backend.py` for the contract; the existing HTTP-backed module (`_rule_drafts_github.py`) is a working template.

osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
{
4141
"name": "CAN_VIEW_EVENTS_BY_ACTION",
4242
"allow_all": true
43+
},
44+
{
45+
"name": "CAN_EDIT_RULE_DRAFTS",
46+
"allow_all": true
4347
}
4448
],
4549
"ability_groups": ["CAN_VIEW_BASIC_USER_DATA"]

osprey_worker/src/osprey/worker/lib/osprey_engine.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ def _handle_updated_sources(self) -> None:
157157
def execution_graph(self) -> ExecutionGraph:
158158
return self._execution_graph
159159

160+
@property
161+
def udf_registry(self) -> UDFRegistry:
162+
return self._udf_registry
163+
164+
@property
165+
def validator_registry(self) -> ValidatorRegistry:
166+
return self._validator_registry
167+
160168
@property
161169
def config(self) -> SourcesConfig:
162170
return self._execution_graph.validated_sources.sources.config

osprey_worker/src/osprey/worker/ui_api/osprey/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def create_app() -> Flask:
6868
events,
6969
features,
7070
queries,
71+
rule_drafts,
7172
rules,
7273
rules_visualizer,
7374
saved_queries,
@@ -111,6 +112,7 @@ def create_app() -> Flask:
111112
_register_with_prefix(app, events.blueprint)
112113
_register_with_prefix(app, features.blueprint)
113114
_register_with_prefix(app, rules.blueprint)
115+
_register_with_prefix(app, rule_drafts.blueprint)
114116
_register_with_prefix(app, queries.blueprint)
115117
_register_with_prefix(app, config.blueprint)
116118
_register_with_prefix(app, docs.blueprint)

osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,7 @@ def _get_query_filter(self) -> dict[str, Any] | None:
535535
CanViewSavedQueries = register_ability('CAN_VIEW_SAVED_QUERIES')(make_marker_ability())
536536
CanCreateAndEditSavedQueries = register_ability('CAN_CREATE_AND_EDIT_SAVED_QUERIES')(make_marker_ability())
537537
CanBulkAction = register_ability('CAN_BULK_ACTION')(make_marker_ability())
538+
CanEditRuleDrafts = register_ability('CAN_EDIT_RULE_DRAFTS')(make_marker_ability())
538539

539540

540541
def require_ability_with_request(request_model: ModelT, ability_class: Type[Ability[ModelT, ItemT]]) -> None:
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Backend abstraction for rule-draft submission.
2+
3+
The Osprey engine doesn't care where rules live. Different deployments use
4+
different hosting: GitHub or Enterprise, GitLab, Tangled, an internal Gerrit,
5+
or a filesystem on a shared volume. Each is one implementation of the
6+
RuleSubmissionBackend Protocol below.
7+
8+
`load_backend()` reads `OSPREY_RULES_SUBMISSION_BACKEND` and instantiates the
9+
chosen backend with its own env vars. Defaults to `null` so an unconfigured
10+
install ships safe; adopters opt into a backend explicitly.
11+
12+
Adopter docs (env vars per backend, how to choose one): see
13+
`docs/user/manage.md`.
14+
15+
Adding a new backend: implement a class with `submit_draft` and
16+
`list_pending_drafts` matching the Protocol below, add a case in
17+
`load_backend()`, and update the "unknown backend" error message here plus
18+
the "no backend configured" message in `_rule_drafts_null.py`. The existing
19+
`_rule_drafts_github.py` module is a working template for HTTP-backed
20+
adapters; `_rule_drafts_local.py` for filesystem.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import os
26+
from dataclasses import dataclass, field
27+
from typing import Any, Protocol
28+
29+
30+
class RuleDraftBackendError(Exception):
31+
"""Raised by any backend method when the operation cannot complete."""
32+
33+
def __init__(self, message: str, status_code: int = 502):
34+
super().__init__(message)
35+
self.message = message
36+
self.status_code = status_code
37+
38+
39+
@dataclass(frozen=True)
40+
class SubmissionResult:
41+
"""Backend-neutral submit_draft return value.
42+
43+
`title` and `url` are what the UI surfaces in the success banner; `extras`
44+
carries backend-specific fields (PR number, branch, etc.) for adopters
45+
whose UI variants want to render more detail.
46+
"""
47+
48+
title: str
49+
url: str | None
50+
main_sml_updated: bool = False
51+
extras: dict[str, Any] = field(default_factory=dict)
52+
53+
def to_json(self) -> dict[str, Any]:
54+
return {
55+
'title': self.title,
56+
'url': self.url,
57+
'main_sml_updated': self.main_sml_updated,
58+
**self.extras,
59+
}
60+
61+
62+
@dataclass(frozen=True)
63+
class PendingDraft:
64+
"""Backend-neutral entry for the pending-drafts list."""
65+
66+
title: str
67+
url: str
68+
author: str
69+
created_at: str
70+
touched_files: list[str]
71+
extras: dict[str, Any] = field(default_factory=dict)
72+
73+
def to_json(self) -> dict[str, Any]:
74+
return {
75+
'title': self.title,
76+
'url': self.url,
77+
'author': self.author,
78+
'created_at': self.created_at,
79+
'touched_files': self.touched_files,
80+
**self.extras,
81+
}
82+
83+
84+
class RuleSubmissionBackend(Protocol):
85+
"""The contract every submission backend implements.
86+
87+
Implementations:
88+
- submit a draft (create whatever the backend's review unit is)
89+
- optionally wire the new rule into main.sml as part of the same submission
90+
- list whatever's currently in review
91+
92+
Implementations raise `RuleDraftBackendError` for any failure path.
93+
"""
94+
95+
name: str
96+
97+
def submit_draft(
98+
self,
99+
*,
100+
draft_path: str,
101+
sml_source: str,
102+
rule_name: str,
103+
summary: str,
104+
author_email: str,
105+
is_new_rule: bool,
106+
wire_into_main: bool,
107+
) -> SubmissionResult: ...
108+
109+
def list_pending_drafts(self) -> list[PendingDraft]: ...
110+
111+
112+
def load_backend() -> RuleSubmissionBackend:
113+
"""Select and instantiate the configured backend.
114+
115+
`OSPREY_RULES_SUBMISSION_BACKEND` picks one of: github, local, null.
116+
Unset or empty defaults to `null`. Unknown values raise so a typo doesn't
117+
silently degrade to no-op submission.
118+
"""
119+
name = (os.environ.get('OSPREY_RULES_SUBMISSION_BACKEND') or 'null').strip().lower()
120+
121+
# Imports are deferred to keep the Protocol module dependency-free.
122+
if name == 'null':
123+
from ._rule_drafts_null import NullBackend
124+
125+
return NullBackend()
126+
if name == 'github':
127+
from ._rule_drafts_github import GitHubBackend
128+
129+
return GitHubBackend.from_env()
130+
if name == 'local':
131+
from ._rule_drafts_local import LocalBackend
132+
133+
return LocalBackend.from_env()
134+
raise RuleDraftBackendError(
135+
f'Unknown OSPREY_RULES_SUBMISSION_BACKEND {name!r}; valid values are github, local, null.',
136+
status_code=500,
137+
)

0 commit comments

Comments
 (0)