Skip to content

Commit 5333ad8

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, with submission routed through a pluggable backend so each deployment picks where drafts go for review. Endpoints (all gated by a new CAN_EDIT_RULE_DRAFTS ability, granted to super_user): get-source, validate, vocabulary, submit, pending, and parse-into-builder. Validate and submit splice the draft into the engine's loaded sources and re-run the same AST validation the engine uses; submit re-validates server-side before touching any backend. Backends implement the RuleSubmissionBackend Protocol and are selected by OSPREY_RULES_SUBMISSION_BACKEND: - null (default): fails fast with 503 so an unconfigured install never writes anything - github: opens a PR via the REST API; supports GitHub Enterprise - local: writes into a mounted rules directory Contract and safety details: - SubmissionResult/PendingDraft.to_json spread extras first so a backend-specific extra can't shadow the canonical title/url/ main_sml_updated fields the UI depends on - Forge transport failures (connection refused, timeout) become the structured 502 the UI renders, not an unhandled 500, via a shared _rule_drafts_git_common.request() helper that also holds the branch-name and main.sml Require helpers - main.sml is rejected as a draft path: wholesale-replacing the engine entry point is not a draft; wiring a rule in is the controlled wire_into_main append Adopter docs for the env vars are in docs/user/manage.md. Follow-ups add the rule-editor UI, a GitLab backend, and a Tangled (ATProto) backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZ4RQtuHCCgurfpjfPXXAM
1 parent f7fc4ca commit 5333ad8

13 files changed

Lines changed: 2225 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: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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+
# Spread extras first so the canonical fields always win: a backend that
55+
# happens to name an extra `title`/`url`/`main_sml_updated` can't shadow
56+
# the contract fields the UI depends on.
57+
return {
58+
**self.extras,
59+
'title': self.title,
60+
'url': self.url,
61+
'main_sml_updated': self.main_sml_updated,
62+
}
63+
64+
65+
@dataclass(frozen=True)
66+
class PendingDraft:
67+
"""Backend-neutral entry for the pending-drafts list."""
68+
69+
title: str
70+
url: str
71+
author: str
72+
created_at: str
73+
touched_files: list[str]
74+
extras: dict[str, Any] = field(default_factory=dict)
75+
76+
def to_json(self) -> dict[str, Any]:
77+
# Spread extras first so backend-specific keys can't shadow the
78+
# canonical fields the UI depends on.
79+
return {
80+
**self.extras,
81+
'title': self.title,
82+
'url': self.url,
83+
'author': self.author,
84+
'created_at': self.created_at,
85+
'touched_files': self.touched_files,
86+
}
87+
88+
89+
class RuleSubmissionBackend(Protocol):
90+
"""The contract every submission backend implements.
91+
92+
Implementations:
93+
- submit a draft (create whatever the backend's review unit is)
94+
- optionally wire the new rule into main.sml as part of the same submission
95+
- list whatever's currently in review
96+
97+
Implementations raise `RuleDraftBackendError` for any failure path.
98+
"""
99+
100+
name: str
101+
102+
def submit_draft(
103+
self,
104+
*,
105+
draft_path: str,
106+
sml_source: str,
107+
rule_name: str,
108+
summary: str,
109+
author_email: str,
110+
is_new_rule: bool,
111+
wire_into_main: bool,
112+
) -> SubmissionResult: ...
113+
114+
def list_pending_drafts(self) -> list[PendingDraft]: ...
115+
116+
117+
def load_backend() -> RuleSubmissionBackend:
118+
"""Select and instantiate the configured backend.
119+
120+
`OSPREY_RULES_SUBMISSION_BACKEND` picks one of: github, local, null.
121+
Unset or empty defaults to `null`. Unknown values raise so a typo doesn't
122+
silently degrade to no-op submission.
123+
"""
124+
name = (os.environ.get('OSPREY_RULES_SUBMISSION_BACKEND') or 'null').strip().lower()
125+
126+
# Imports are deferred to keep the Protocol module dependency-free.
127+
if name == 'null':
128+
from ._rule_drafts_null import NullBackend
129+
130+
return NullBackend()
131+
if name == 'github':
132+
from ._rule_drafts_github import GitHubBackend
133+
134+
return GitHubBackend.from_env()
135+
if name == 'local':
136+
from ._rule_drafts_local import LocalBackend
137+
138+
return LocalBackend.from_env()
139+
raise RuleDraftBackendError(
140+
f'Unknown OSPREY_RULES_SUBMISSION_BACKEND {name!r}; valid values are github, local, null.',
141+
status_code=500,
142+
)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Shared helpers for git-forge submission backends.
2+
3+
The GitHub, GitLab, and Tangled adapters all need the same three things: a
4+
cosmetic branch name, a check for whether main.sml already wires a rule in, and
5+
the append that adds the wiring. They also all talk to a remote over HTTP and
6+
must turn a dropped connection into the same structured error the UI renders
7+
rather than an unhandled 500. This module is the one place those live.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import re
13+
import time
14+
from typing import Any
15+
16+
import requests
17+
18+
from ._rule_drafts_backend import RuleDraftBackendError
19+
20+
DEFAULT_TIMEOUT_SECONDS = 15
21+
22+
23+
def request(method: str, url: str, *, error_action: str, **kwargs: Any) -> requests.Response:
24+
"""Issue an HTTP request, converting transport failures to RuleDraftBackendError.
25+
26+
A forge outage (connection refused, DNS failure, timeout) is an expected
27+
operational state for a backend whose job is talking to a remote host, so it
28+
should surface as the 502 JSON shape the editor knows how to display, not as
29+
an unhandled Flask 500. HTTP status errors are left for the caller to map,
30+
since the right status code depends on what was being attempted.
31+
"""
32+
kwargs.setdefault('timeout', DEFAULT_TIMEOUT_SECONDS)
33+
try:
34+
return requests.request(method, url, **kwargs)
35+
except requests.RequestException as exc:
36+
raise RuleDraftBackendError(
37+
f'Could not reach the git host while {error_action}: {exc}',
38+
status_code=502,
39+
) from exc
40+
41+
42+
def generate_branch_name(rule_name: str, author_email: str, *, prefix: str = 'rule-draft') -> str:
43+
"""Cosmetic source-branch label. Timestamped so retries don't collide."""
44+
short_email = author_email.split('@', 1)[0]
45+
slug = re.sub(r'[^A-Za-z0-9_-]+', '-', short_email).strip('-') or 'osprey-ui'
46+
rule_slug = re.sub(r'[^A-Za-z0-9_-]+', '-', rule_name).strip('-') or 'rule'
47+
return f'{prefix}/{slug}/{rule_slug}-{int(time.time())}'
48+
49+
50+
def require_already_present(main_sml: str, draft_path: str) -> bool:
51+
pattern = re.compile(
52+
r"Require\s*\(\s*rule\s*=\s*['\"]" + re.escape(draft_path) + r"['\"]\s*\)",
53+
re.MULTILINE,
54+
)
55+
return bool(pattern.search(main_sml))
56+
57+
58+
def append_require_to_main(main_sml: str, draft_path: str) -> str:
59+
suffix = f"\nRequire(rule='{draft_path}')\n"
60+
if not main_sml.endswith('\n'):
61+
suffix = '\n' + suffix
62+
return main_sml + suffix

0 commit comments

Comments
 (0)