Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -307,5 +307,8 @@ Cargo.lock

.claude

# Local docker compose overrides (env vars, port remaps, secrets)
docker-compose.override.yaml

# docs output
book/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
25 changes: 25 additions & 0 deletions docs/user/manage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` — fetches a single draft.
- `POST /rule-drafts/<id>/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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing as this is python, it should be wire_into_main=True.


### 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.
7 changes: 7 additions & 0 deletions osprey_ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getApplicationConfig } from './actions/ConfigActions';
import UdfDocsView from './components/docs/UdfDocsView';
import BulkJobHistoryView from './components/bulk_job_history/BulkJobHistory';
import { FeaturesPage } from './components/features/FeaturesPage';
import { RuleEditorPage } from './components/rules/RuleEditorPage';
import { RulesPage } from './components/rules/RulesPage';
import RulesVisualizerView from './components/rules_visualizer/RulesVisualizer';
import EntityViewBar from './components/entities/EntityViewBar';
Expand Down Expand Up @@ -105,6 +106,12 @@ const AppRouter: React.FC = () => {
<Route path={Routes.FEATURES}>
<FeaturesPage />
</Route>
<Route exact path={Routes.RULES_NEW}>
<RuleEditorPage />
</Route>
<Route exact path={Routes.RULES_EDIT}>
<RuleEditorPage />
</Route>
<Route path={Routes.RULES}>
<RulesPage />
</Route>
Expand Down
2 changes: 2 additions & 0 deletions osprey_ui/src/Constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const Routes = {
ENTITY: '/entity/:entityType/:entityId',
FEATURES: '/features',
RULES: '/rules',
RULES_NEW: '/rules/new',
RULES_EDIT: '/rules/edit',
SAVED_QUERY: '/saved-query/:savedQueryId',
SAVED_QUERY_LATEST: '/saved-query/:savedQueryId/latest',
BULK_JOB_HISTORY: '/bulk-job-history',
Expand Down
90 changes: 89 additions & 1 deletion osprey_ui/src/actions/RulesActions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import HTTPUtils, { HTTPResponse } from '../utils/HTTPUtils';
import { RulesListResponse } from '../types/RulesTypes';
import {
ParseIntoBuilderResponse,
RuleDraft,
RuleDraftSourceResponse,
RuleDraftsListResponse,
RuleDraftValidationResponse,
RuleDraftVocabulary,
RulesListResponse,
} from '../types/RulesTypes';

export async function getRulesList(): Promise<RulesListResponse> {
const response: HTTPResponse = await HTTPUtils.get('rules');
Expand All @@ -8,3 +16,83 @@ export async function getRulesList(): Promise<RulesListResponse> {
}
throw new Error(response.error.message ?? 'Failed to fetch rules list');
}

export async function getRuleDraftSource(path: string): Promise<RuleDraftSourceResponse> {
const response: HTTPResponse = await HTTPUtils.get('rule-drafts/source', { params: { path } });
if (response.ok) {
return response.data;
}
throw new Error(response.error.message ?? `Failed to fetch rule source at ${path}`);
}

export async function validateRuleDraft(path: string, source: string): Promise<RuleDraftValidationResponse> {
// SML validation errors come back as 200 with {ok: false}; backend-shape problems come back as 400
// with the same envelope. Both paths surface the structured errors to the UI without throwing.
const response: HTTPResponse = await HTTPUtils.post('rule-drafts/validate', { path, source });
if (response.ok) {
return response.data;
}
if (response.error.response?.data) {
return response.error.response.data as RuleDraftValidationResponse;
Comment on lines +35 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate the error envelope before returning it.

A 401, 403, 500, or proxy response may contain {error: ...} rather than errors and warnings. The unchecked cast then gives callers a malformed RuleDraftValidationResponse, potentially crashing validation rendering. Only return payloads matching the envelope; otherwise throw.

Proposed fix
-  if (response.error.response?.data) {
-    return response.error.response.data as RuleDraftValidationResponse;
+  const payload = response.error.response?.data;
+  if (
+    typeof payload === 'object' &&
+    payload !== null &&
+    'ok' in payload &&
+    typeof payload.ok === 'boolean' &&
+    'errors' in payload &&
+    Array.isArray(payload.errors) &&
+    'warnings' in payload &&
+    Array.isArray(payload.warnings)
+  ) {
+    return payload as RuleDraftValidationResponse;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (response.error.response?.data) {
return response.error.response.data as RuleDraftValidationResponse;
const payload = response.error.response?.data;
if (
typeof payload === 'object' &&
payload !== null &&
'ok' in payload &&
typeof payload.ok === 'boolean' &&
'errors' in payload &&
Array.isArray(payload.errors) &&
'warnings' in payload &&
Array.isArray(payload.warnings)
) {
return payload as RuleDraftValidationResponse;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@osprey_ui/src/actions/RulesActions.tsx` around lines 35 - 36, Update the
response.error handling in the rule validation action to validate that
response.error.response.data contains the expected RuleDraftValidationResponse
envelope with errors and warnings before returning it. For 401, 403, 500, proxy,
or other malformed payloads, throw instead of returning the unchecked cast,
while preserving valid validation responses.

}
throw new Error(response.error.message ?? 'Validation request failed');
}

export async function parseRuleDraftIntoBuilder(path: string, source: string): Promise<ParseIntoBuilderResponse> {
const response: HTTPResponse = await HTTPUtils.post('rule-drafts/parse-into-builder', { path, source });
if (response.ok) {
return response.data;
}
throw new Error(response.error.message ?? 'Failed to parse rule into builder model');
}

export async function getRuleDraftVocabulary(): Promise<RuleDraftVocabulary> {
const response: HTTPResponse = await HTTPUtils.get('rule-drafts/vocabulary');
if (response.ok) {
return response.data;
}
throw new Error(response.error.message ?? 'Failed to fetch rule vocabulary');
}

export interface CreateRuleDraftBody {
path: string;
source: string;
rule_name: string;
summary: string;
}

// Saves a draft into the rule_drafts table (upserted by path). The draft is staged
// for a developer to review and deploy; saving never changes any live rules.
export async function createRuleDraft(body: CreateRuleDraftBody): Promise<RuleDraft> {
const response: HTTPResponse = await HTTPUtils.post('rule-drafts', body);
if (response.ok) {
return response.data;
}
const errPayload = response.error.response?.data as { error?: string } | undefined;
throw new Error(errPayload?.error ?? response.error.message ?? 'Failed to save rule draft');
}

// Loads a single draft (its SML lives in the table, not on disk, so editing a draft
// reads it from here rather than from the rules directory).
export async function getRuleDraft(id: number): Promise<RuleDraft> {
const response: HTTPResponse = await HTTPUtils.get(`rule-drafts/${id}`);
if (response.ok) {
return response.data;
}
const errPayload = response.error.response?.data as { error?: string } | undefined;
throw new Error(errPayload?.error ?? response.error.message ?? 'Failed to load rule draft');
}

export async function getRuleDrafts(): Promise<RuleDraftsListResponse> {
// Returns an empty list rather than throwing on failure so the RulesPage still renders
// when the caller lacks the rule-drafts ability.
const response: HTTPResponse = await HTTPUtils.get('rule-drafts');
if (response.ok) {
return response.data;
}
const errPayload = response.error.response?.data as RuleDraftsListResponse | undefined;
if (errPayload && Array.isArray(errPayload.drafts)) {
return errPayload;
}
return { drafts: [] };
}
151 changes: 151 additions & 0 deletions osprey_ui/src/components/rules/RuleEditorPage.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
.viewContainer {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}

.scrollArea {
flex: 1;
overflow-y: auto;
padding: 24px;
}

.headerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}

.headerLeft {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}

.headerActions {
display: flex;
gap: 8px;
align-items: center;
}

.editorGrid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
align-items: start;
}

@media (max-width: 1100px) {
.editorGrid {
grid-template-columns: 1fr;
}
}

.codeArea {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the quotes around SFMono-Regular.

Stylelint reports font-family-name-quotes errors at all five declarations.

-  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+  font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;

Also applies to: 67-67, 94-94, 131-131, 137-137

🧰 Tools
🪛 Stylelint (17.14.0)

[error] 51-51: Expected no quotes around "SFMono-Regular" (font-family-name-quotes)

(font-family-name-quotes)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@osprey_ui/src/components/rules/RuleEditorPage.module.css` at line 51, Update
all five font-family declarations in RuleEditorPage.module.css to remove the
quotes around SFMono-Regular, while leaving the remaining fallback fonts and
declaration values unchanged.

Source: Linters/SAST tools

font-size: 13px;
line-height: 1.5;
min-height: 480px;
}

.sidePanel {
display: flex;
flex-direction: column;
gap: 12px;
position: sticky;
top: 0;
}

.validationCard pre {
margin: 0;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: break-word;
}

.errorList {
display: flex;
flex-direction: column;
gap: 8px;
}

.errorItem {
padding: 8px 10px;
background: var(--background-secondary);
border-left: 3px solid var(--status-error);
border-radius: 2px;
}

.warningItem {
padding: 8px 10px;
background: var(--background-secondary);
border-left: 3px solid var(--status-warning);
border-radius: 2px;
}

.errorLocation {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 11px;
color: var(--text-light-secondary);
}

.builderSection {
margin-bottom: 16px;
}

.builderRow {
display: grid;
grid-template-columns: minmax(140px, 1fr) 130px minmax(140px, 1fr) 32px;
gap: 8px;
align-items: start;
margin-bottom: 8px;
}

.builderRowOutcome {
display: grid;
grid-template-columns: minmax(140px, 1fr) 32px;
gap: 8px;
align-items: start;
margin-bottom: 8px;
}

.builderArgsGrid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 6px;
margin-top: 4px;
margin-left: 0;
padding: 8px;
background: var(--background-secondary);
border-radius: 4px;
}

.builderArgLabel {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
align-self: center;
}

.previewBlock {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
padding: 12px;
background: var(--background-secondary);
border: 1px solid var(--divider);
border-radius: 4px;
white-space: pre-wrap;
overflow-wrap: break-word;
}

.footnote {
font-size: 11px;
color: var(--text-light-secondary);
margin-top: 4px;
}
Loading
Loading