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_ui/src/App.tsx b/osprey_ui/src/App.tsx index 0b52a3a2..097580bd 100644 --- a/osprey_ui/src/App.tsx +++ b/osprey_ui/src/App.tsx @@ -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'; @@ -105,6 +106,12 @@ const AppRouter: React.FC = () => { + + + + + + diff --git a/osprey_ui/src/Constants.tsx b/osprey_ui/src/Constants.tsx index 7e9769a8..8ebb35d1 100644 --- a/osprey_ui/src/Constants.tsx +++ b/osprey_ui/src/Constants.tsx @@ -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', diff --git a/osprey_ui/src/actions/RulesActions.tsx b/osprey_ui/src/actions/RulesActions.tsx index ad5e63b9..bd32fbce 100644 --- a/osprey_ui/src/actions/RulesActions.tsx +++ b/osprey_ui/src/actions/RulesActions.tsx @@ -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 { const response: HTTPResponse = await HTTPUtils.get('rules'); @@ -8,3 +16,83 @@ export async function getRulesList(): Promise { } throw new Error(response.error.message ?? 'Failed to fetch rules list'); } + +export async function getRuleDraftSource(path: string): Promise { + 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 { + // 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; + } + throw new Error(response.error.message ?? 'Validation request failed'); +} + +export async function parseRuleDraftIntoBuilder(path: string, source: string): Promise { + 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 { + 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 { + 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 { + 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 { + // 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: [] }; +} diff --git a/osprey_ui/src/components/rules/RuleEditorPage.module.css b/osprey_ui/src/components/rules/RuleEditorPage.module.css new file mode 100644 index 00000000..d9f07acf --- /dev/null +++ b/osprey_ui/src/components/rules/RuleEditorPage.module.css @@ -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; + 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; +} diff --git a/osprey_ui/src/components/rules/RuleEditorPage.tsx b/osprey_ui/src/components/rules/RuleEditorPage.tsx new file mode 100644 index 00000000..d719859f --- /dev/null +++ b/osprey_ui/src/components/rules/RuleEditorPage.tsx @@ -0,0 +1,775 @@ +import * as React from 'react'; +import { Alert, Button, Card, Form, Input, Segmented, Select, Space, Tag, Tooltip, Typography, message } from 'antd'; +import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { + createRuleDraft, + getRuleDraft, + getRuleDrafts, + getRuleDraftSource, + getRuleDraftVocabulary, + parseRuleDraftIntoBuilder, + validateRuleDraft, +} from '../../actions/RulesActions'; +import usePromiseResult from '../../hooks/usePromiseResult'; +import { + ParseIntoBuilderResponse, + RuleDraft, + RuleDraftValidationMessage, + RuleDraftValidationResponse, + RuleDraftVocabulary, +} from '../../types/RulesTypes'; +import { renderFromPromiseResult } from '../../utils/PromiseResultUtils'; + +import { + CONDITION_OPERATOR_OPTIONS, + Condition, + ConditionOperator, + EMPTY_BUILDER_MODEL, + Outcome, + OutcomeArg, + RuleBuilderModel, + SML_IDENTIFIER_RE, + applyMissingImports, + generateSmlFromBuilder, + outcomeArgsForEffect, +} from './ruleBuilderSml'; + +import styles from './RuleEditorPage.module.css'; + +const { Title, Text, Paragraph } = Typography; + +type EditorMode = 'builder' | 'code'; + +// Saving stages the rule in the rule_drafts table for a developer to review and +// deploy. Authoring never deploys, so there is no deploy state here. +type SubmitState = { kind: 'idle' } | { kind: 'saving' } | { kind: 'saved' } | { kind: 'error'; message: string }; + +const VALIDATE_DEBOUNCE_MS = 600; + +interface BootstrapData { + vocabulary: RuleDraftVocabulary; + initialSource: string; + initialPath: string; + isNewRule: boolean; + // Every existing draft, used to warn about name/path collisions the server-side + // validator can't see (it only knows deployed rules, not other drafts). + existingDrafts: RuleDraft[]; + // For edit mode: the result of round-tripping the loaded source through the + // backend parser. Determines whether the Rule Builder toggle is enabled and + // what model the builder starts from. + initialBuilderParse?: ParseIntoBuilderResponse; +} + +export const RuleEditorPage: React.FC = () => { + // Two edit entry points, both via query params because react-router v5 has no + // clean repeating-segment param and rule paths contain slashes: + // ?draftId=N -> edit a saved draft (its SML lives in the rule_drafts table) + // ?path=X -> edit an existing rule file loaded from the rules directory + const location = useLocation(); + const isNewRule = location.pathname === '/rules/new'; + const params = new URLSearchParams(location.search); + const draftId = isNewRule ? undefined : (params.get('draftId') ?? undefined); + const editPath = isNewRule ? undefined : (params.get('path') ?? undefined); + + const result = usePromiseResult(async () => { + const [vocabulary, { drafts: existingDrafts }] = await Promise.all([getRuleDraftVocabulary(), getRuleDrafts()]); + if (isNewRule) { + return { + vocabulary, + existingDrafts, + initialSource: '', + initialPath: 'rules/new_rule.sml', + isNewRule: true, + }; + } + // A draft's SML is in the table, so load it from there rather than from disk. + if (draftId) { + const draft = await getRuleDraft(Number(draftId)); + const initialBuilderParse = await parseRuleDraftIntoBuilder(draft.path, draft.source); + return { + vocabulary, + existingDrafts, + initialSource: draft.source, + initialPath: draft.path, + isNewRule: false, + initialBuilderParse, + }; + } + if (!editPath) { + throw new Error('Missing ?draftId= or ?path= query parameter; navigate from the Rules page.'); + } + const source = await getRuleDraftSource(editPath); + const initialBuilderParse = await parseRuleDraftIntoBuilder(source.path, source.contents); + return { + vocabulary, + existingDrafts, + initialSource: source.contents, + initialPath: source.path, + isNewRule: false, + initialBuilderParse, + }; + }, [draftId, editPath, isNewRule]); + + return renderFromPromiseResult(result, (data) => { + return ; + }); +}; + +const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => { + const history = useHistory(); + // Builder is allowed for new rules and for edits whose source round-trips. + const builderAllowed = data.isNewRule || data.initialBuilderParse?.supported === true; + const builderDisabledReason = + !data.isNewRule && data.initialBuilderParse?.supported === false ? data.initialBuilderParse.reason : ''; + const [mode, setMode] = React.useState(builderAllowed && data.isNewRule ? 'builder' : 'code'); + const [path, setPath] = React.useState(data.initialPath); + const [codeSource, setCodeSource] = React.useState(data.initialSource); + const [builder, setBuilder] = React.useState(() => { + if (data.initialBuilderParse?.supported === true) { + return data.initialBuilderParse.model; + } + return EMPTY_BUILDER_MODEL; + }); + const [summary, setSummary] = React.useState(''); + const [validation, setValidation] = React.useState(null); + const [isValidating, setIsValidating] = React.useState(false); + const [submitState, setSubmitState] = React.useState({ kind: 'idle' }); + + const effectiveSource = mode === 'builder' ? generateSmlFromBuilder(builder, data.vocabulary.features) : codeSource; + + React.useEffect(() => { + let cancelled = false; + if (!effectiveSource.trim()) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clearing stale validation on input empty + setValidation(null); + setIsValidating(false); + return; + } + setIsValidating(true); + const handle = window.setTimeout(async () => { + try { + const result = await validateRuleDraft(path, effectiveSource); + if (!cancelled) setValidation(result); + } catch (e) { + if (!cancelled) { + setValidation({ + ok: false, + errors: [ + { + message: e instanceof Error ? e.message : String(e), + hint: '', + source_path: path, + line: 0, + column: 0, + rendered: '', + }, + ], + warnings: [], + }); + } + } finally { + if (!cancelled) setIsValidating(false); + } + }, VALIDATE_DEBOUNCE_MS); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [effectiveSource, path]); + + const ruleNameForSubmit = mode === 'builder' ? builder.ruleName : guessRuleNameFromSource(codeSource); + + // Collisions the server-side validator can't see (it only knows deployed rules): + // a rule name already taken by another draft (a hard conflict — the server rejects + // it on save too), and — for a brand new rule — a path that would overwrite a live + // rule or another draft (a soft warning, since replacing can be intentional). Path + // is only editable for new rules, so path warnings are new-rule-only. + const nameConflictDraft = data.existingDrafts.find((d) => d.rule_name === ruleNameForSubmit && d.path !== path); + const pathOverwritesRule = data.isNewRule && data.vocabulary.source_files.includes(path); + const pathOverwritesDraft = data.isNewRule && data.existingDrafts.some((d) => d.path === path); + + const canSave = + !!validation?.ok && + SML_IDENTIFIER_RE.test(ruleNameForSubmit) && + submitState.kind !== 'saving' && + !!effectiveSource.trim() && + !nameConflictDraft; + + // The builder and code editor hold independent state, so a tab switch has to + // carry content across: builder -> code dumps the generated SML into the + // textarea, code -> builder re-parses the (possibly hand-edited) source so + // the form never silently submits a stale model. + const onModeChange = async (next: EditorMode) => { + if (next === mode) return; + if (next === 'code') { + setCodeSource(generateSmlFromBuilder(builder, data.vocabulary.features)); + setMode('code'); + return; + } + if (!codeSource.trim()) { + setMode('builder'); + return; + } + try { + const parsed = await parseRuleDraftIntoBuilder(path, codeSource); + if (parsed.supported) { + setBuilder(parsed.model); + setMode('builder'); + } else { + message.warning(`Rule Builder can't represent this file: ${parsed.reason}. Keep editing in Code Editor.`); + } + } catch (e) { + message.warning(`Could not parse this file for Rule Builder: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + const onSave = async () => { + if (!canSave) return; + setSubmitState({ kind: 'saving' }); + try { + await createRuleDraft({ + path, + source: effectiveSource, + rule_name: ruleNameForSubmit, + summary, + }); + setSubmitState({ kind: 'saved' }); + message.success('Draft saved.'); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setSubmitState({ kind: 'error', message: msg }); + } + }; + + return ( +
+
+
+
+ + {data.isNewRule ? 'Add rule' : 'Edit rule'} + + + Saves the rule draft into rule_drafts for a developer to review and deploy. This does not + change any live rules. + +
+
+ + { + void onModeChange(value as EditorMode); + }} + options={[ + { label: 'Rule Builder', value: 'builder', disabled: !builderAllowed }, + { label: 'Code Editor', value: 'code' }, + ]} + /> + + + +
+
+ + + + {nameConflictDraft && ( + + )} + {pathOverwritesRule && ( + + )} + {pathOverwritesDraft && !pathOverwritesRule && ( + + )} + +
+
+ +
+ + setPath(e.target.value)} disabled={!data.isNewRule} /> + + + setSummary(e.target.value)} + placeholder={ + data.isNewRule + ? 'Why do we need this rule? What behaviour does it target?' + : 'What are you changing about this rule, and why?' + } + autoSize={{ minRows: 2, maxRows: 4 }} + /> + +
+
+ + {mode === 'code' && validation?.suggested_imports && validation.suggested_imports.length > 0 && ( + + This file references identifiers defined in: {validation.suggested_imports.join(', ')} + + + } + /> + )} + + {mode === 'builder' ? ( + + ) : ( + + )} + + {mode === 'builder' && ( + +
{effectiveSource}
+
+ This is the code that will be saved as the draft. Make further changes in the Code Editor view. +
+
+ )} +
+ + +
+
+
+ ); +}; + +const SubmitBanner: React.FC<{ submitState: SubmitState }> = ({ submitState }) => { + if (submitState.kind === 'idle') return null; + if (submitState.kind === 'saving') { + return ; + } + if (submitState.kind === 'saved') { + return ( + + ); + } + return ( + + ); +}; + +const CodeEditorMode: React.FC<{ source: string; setSource: (next: string) => void }> = ({ source, setSource }) => { + return ( + + setSource(e.target.value)} + placeholder="MyRule = Rule(when_all=[PostText == 'hello'], description='...')" + autoSize={{ minRows: 24, maxRows: 60 }} + spellCheck={false} + /> + + ); +}; + +const ValidationPanel: React.FC<{ + validation: RuleDraftValidationResponse | null; + isValidating: boolean; +}> = ({ validation, isValidating }) => { + return ( + + Validation + {isValidating && checking…} + {!isValidating && validation?.ok === true && valid} + {!isValidating && validation?.ok === false && errors} + + } + > + {!validation && Start typing to see live validation against the engine.} + {validation?.ok === true && validation.warnings.length === 0 && ( + Engine accepts this draft. + )} + {validation?.errors && validation.errors.length > 0 && ( +
+ {validation.errors.map((err, i) => { + return ; + })} +
+ )} + {validation?.warnings && validation.warnings.length > 0 && ( +
+ {validation.warnings.map((w, i) => { + return ; + })} +
+ )} +
+ ); +}; + +const ValidationMessageRow: React.FC<{ kind: 'error' | 'warning'; msg: RuleDraftValidationMessage }> = ({ + kind, + msg, +}) => { + return ( +
+
{msg.message}
+ {msg.hint && ( +
+ {msg.hint} +
+ )} +
+ {msg.source_path}:{msg.line}:{msg.column} +
+
+ ); +}; + +const VocabularyPanel: React.FC<{ vocabulary: RuleDraftVocabulary }> = ({ vocabulary }) => { + return ( + + + Variables you can reference inside conditions. + + + {vocabulary.features.slice(0, 60).map((f) => { + return ( + + {f.name} + + ); + })} + {vocabulary.features.length > 60 && +{vocabulary.features.length - 60} more} + + {vocabulary.effects.length > 0 && ( + <> + + Effects used in existing rules. + + + {vocabulary.effects.map((name) => { + return ( + + {name} + + ); + })} + + + )} + + ); +}; + +const RuleBuilderEditor: React.FC<{ + model: RuleBuilderModel; + setModel: React.Dispatch>; + vocabulary: RuleDraftVocabulary; +}> = ({ model, setModel, vocabulary }) => { + const featureOptions = React.useMemo(() => { + return vocabulary.features.map((f) => { + return { label: f.name, value: f.name }; + }); + }, [vocabulary.features]); + + const effectOptions = React.useMemo(() => { + return vocabulary.effects.map((name) => { + return { label: name, value: name }; + }); + }, [vocabulary.effects]); + + const updateCondition = (idx: number, patch: Partial) => { + setModel((prev) => { + const next = [...prev.conditions]; + next[idx] = { ...next[idx], ...patch }; + return { ...prev, conditions: next }; + }); + }; + const addCondition = () => { + setModel((prev) => ({ + ...prev, + conditions: [...prev.conditions, { feature: '', operator: '==', rhs: '', rhsIsFeature: false }], + })); + }; + const removeCondition = (idx: number) => { + setModel((prev) => ({ + ...prev, + conditions: prev.conditions.filter((_, i) => { + return i !== idx; + }), + })); + }; + + const updateOutcome = (idx: number, patch: Partial) => { + setModel((prev) => { + const next = [...prev.outcomes]; + next[idx] = { ...next[idx], ...patch }; + return { ...prev, outcomes: next }; + }); + }; + const updateOutcomeArg = (oIdx: number, aIdx: number, patch: Partial) => { + setModel((prev) => { + const outcomes = [...prev.outcomes]; + const args = [...outcomes[oIdx].args]; + args[aIdx] = { ...args[aIdx], ...patch }; + outcomes[oIdx] = { ...outcomes[oIdx], args }; + return { ...prev, outcomes }; + }); + }; + const addOutcome = () => { + setModel((prev) => ({ ...prev, outcomes: [...prev.outcomes, { effect: '', args: [] }] })); + }; + const removeOutcome = (idx: number) => { + setModel((prev) => ({ + ...prev, + outcomes: prev.outcomes.filter((_, i) => { + return i !== idx; + }), + })); + }; + + return ( + +
+ + setModel((prev) => ({ ...prev, ruleName: e.target.value }))} + placeholder="ContainsHello" + /> + + + setModel((prev) => ({ ...prev, description: e.target.value }))} + autoSize={{ minRows: 1, maxRows: 3 }} + placeholder="What does the rule detect?" + /> + +
+ +
+ + Conditions + + + Every row must be true for the rule to fire. SML's when_all is AND-only. For OR, write the + extra rule in Code Editor. + + {model.conditions.map((cond, idx) => { + return ( +
+ updateCondition(idx, { rhs: value })} + options={featureOptions} + style={{ width: '100%' }} + filterOption={(input, opt) => { + return String(opt?.label).toLowerCase().includes(input.toLowerCase()); + }} + /> + ) : ( + updateCondition(idx, { rhs: e.target.value })} + /> + )} + + +
+ ); + })} + +
+ +
+ + Outcomes + + + What Osprey does when the conditions above are met, like adding a label or banning the user. + + {model.outcomes.map((outcome, oIdx) => { + return ( +
+
+ updateOutcomeArg(oIdx, aIdx, { value })} + options={featureOptions} + style={{ width: '100%' }} + filterOption={(input, opt) => { + return String(opt?.label).toLowerCase().includes(input.toLowerCase()); + }} + /> + ) : ( + updateOutcomeArg(oIdx, aIdx, { value: e.target.value })} + /> + )} + + + + ); + })} +
+ )} +
+ ); + })} + +
+
+ ); +}; + +function guessRuleNameFromSource(source: string): string { + const m = source.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Rule\s*\(/m); + return m?.[1] ?? ''; +} diff --git a/osprey_ui/src/components/rules/RulesPage.tsx b/osprey_ui/src/components/rules/RulesPage.tsx index 580abadb..d443e398 100644 --- a/osprey_ui/src/components/rules/RulesPage.tsx +++ b/osprey_ui/src/components/rules/RulesPage.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; import { + Alert, + Button, Card, Collapse, Descriptions, @@ -14,11 +16,12 @@ import { Tooltip, Typography, } from 'antd'; -import { SearchOutlined } from '@ant-design/icons'; +import { EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'; +import { Link } from 'react-router-dom'; -import { getRulesList } from '../../actions/RulesActions'; -import usePromiseResult from '../../hooks/usePromiseResult'; -import { RuleInfo, RulesListResponse, SortKey } from '../../types/RulesTypes'; +import { getRuleDrafts, getRulesList } from '../../actions/RulesActions'; +import usePromiseResult, { PromiseResultStatus } from '../../hooks/usePromiseResult'; +import { RuleDraft, RuleDraftsListResponse, RuleInfo, RulesListResponse, SortKey } from '../../types/RulesTypes'; import { renderFromPromiseResult } from '../../utils/PromiseResultUtils'; import styles from './RulesPage.module.css'; @@ -73,13 +76,19 @@ export const RulesPage: React.FC = () => { const result = usePromiseResult(() => { return getRulesList(); }); + const draftsResult = usePromiseResult(() => { + return getRuleDrafts(); + }); return renderFromPromiseResult(result, (data) => { - return ; + return ; }); }; -const RulesPageContent: React.FC<{ data: RulesListResponse }> = ({ data }) => { +const RulesPageContent: React.FC<{ + data: RulesListResponse; + draftsResult: ReturnType>; +}> = ({ data, draftsResult }) => { const [filters, dispatch] = React.useReducer(filtersReducer, INITIAL_FILTERS); const { rules, total, when_rules_total, unused_total } = data; const { search, unusedOnly, sortKey, page, pageSize } = filters; @@ -132,13 +141,32 @@ const RulesPageContent: React.FC<{ data: RulesListResponse }> = ({ data }) => { return (
- - Rules Registry - - - Named rule definitions across the engine — conditions, descriptions, the features each rule references, and - how many WhenRules blocks include it. - +
+
+ + Rules Registry + + + Named rule definitions across the engine — conditions, descriptions, the features each rule references, + and how many WhenRules blocks include it. + +
+ + + +
+ +
@@ -262,11 +290,60 @@ const RuleHeader: React.FC<{ rule: RuleInfo }> = ({ rule }) => { {rule.referenced_by_whenrules} when-rules )} + + { + // Prevent the surrounding Collapse panel from toggling open. + e.stopPropagation(); + }} + > + + +
); }; +const DraftsBanner: React.FC<{ + draftsResult: ReturnType>; +}> = ({ draftsResult }) => { + if (draftsResult.status !== PromiseResultStatus.Resolved) return null; + const { drafts } = draftsResult.value; + if (drafts.length === 0) return null; + return ( + + {drafts.slice(0, 8).map((draft) => { + return ; + })} + {drafts.length > 8 && +{drafts.length - 8} more.} + + } + /> + ); +}; + +const DraftRow: React.FC<{ draft: RuleDraft }> = ({ draft }) => { + return ( +
+ {draft.path}{' '} + {draft.status}{' '} + + by {draft.author} + +
+ ); +}; + const RuleDetail: React.FC<{ rule: RuleInfo }> = ({ rule }) => { return ( ', label: 'is greater than' }, + { value: '<', label: 'is less than' }, + { value: '>=', label: 'is greater than or equal to' }, + { value: '<=', label: 'is less than or equal to' }, + { value: 'includes', label: 'includes' }, + { value: 'excludes', label: 'excludes' }, +]; + +export const EMPTY_BUILDER_MODEL: RuleBuilderModel = { + ruleName: '', + description: '', + conditions: [{ feature: '', operator: '==', rhs: '', rhsIsFeature: false }], + outcomes: [{ effect: '', args: [] }], +}; + +export const SML_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +// SML string literals follow Python escaping rules (the parser is Python's ast +// module), so backslashes must be escaped before quotes or a trailing `\` in +// user input would swallow the closing quote and inject raw SML. +function smlString(value: string): string { + const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r/g, '\\r').replace(/\n/g, '\\n'); + return `'${escaped}'`; +} + +function renderRhs(rhs: string, isFeature: boolean): string { + if (isFeature) return rhs; + if (rhs === '') return "''"; + if (/^-?\d+(\.\d+)?$/.test(rhs)) return rhs; + if (rhs === 'true' || rhs === 'false') return rhs; + return smlString(rhs); +} + +function renderConditionExpression(c: RuleBuilderCondition): string { + const lhs = c.feature || '__missing_feature__'; + const rhs = renderRhs(c.rhs, c.rhsIsFeature); + switch (c.operator) { + case 'includes': + return `TextContains(text=${lhs}, phrase=${rhs})`; + case 'excludes': + return `not TextContains(text=${lhs}, phrase=${rhs})`; + default: + return `${lhs} ${c.operator} ${rhs}`; + } +} + +function renderOutcomeCall(o: RuleBuilderOutcome): string { + const effect = o.effect || '__missing_effect__'; + // Skip args left blank: emitting `entity=` is a syntax error, and emitting `expires_after=''` + // forces an empty literal where the UDF would otherwise use its default. + const parts = o.args + .filter((arg) => { + return arg.value !== ''; + }) + .map((arg) => { + return `${arg.name}=${renderRhs(arg.value, arg.isFeature)}`; + }); + return `${effect}(${parts.join(', ')})`; +} + +function collectReferencedFeatures(model: RuleBuilderModel): Set { + const refs = new Set(); + for (const c of model.conditions) { + if (c.feature) refs.add(c.feature); + if (c.rhsIsFeature && c.rhs) refs.add(c.rhs); + } + for (const o of model.outcomes) { + for (const arg of o.args) { + if (arg.isFeature && arg.value) refs.add(arg.value); + } + } + return refs; +} + +function buildImportBlock(refs: Set, features: RuleDraftVocabularyFeature[]): string { + // Map each referenced identifier to the SML file it's defined in, drop main.sml + // (the entry point cannot import itself), dedupe, and emit a single Import block. + const paths = new Set(); + for (const ref of refs) { + const feat = features.find((f) => { + return f.name === ref; + }); + if (!feat) continue; + if (feat.source_path === 'main.sml') continue; + paths.add(feat.source_path); + } + if (paths.size === 0) return ''; + const sorted = [...paths].sort(); + const entries = sorted + .map((p) => { + return ` '${p}',`; + }) + .join('\n'); + return `Import( + rules=[ +${entries} + ] +) + +`; +} + +export function generateSmlFromBuilder(model: RuleBuilderModel, features: RuleDraftVocabularyFeature[] = []): string { + // Never interpolate a non-identifier rule name: `Foo = Rule(...) # ` as a + // "name" would otherwise inject arbitrary SML that still validates. + const ruleName = SML_IDENTIFIER_RE.test(model.ruleName) ? model.ruleName : 'UnnamedRule'; + const whenAll = model.conditions + .map((c) => { + return ` ${renderConditionExpression(c)},`; + }) + .join('\n'); + const ruleBlock = `${ruleName} = Rule( + when_all=[ +${whenAll} + ], + description=${smlString(model.description)}, +)`; + + const imports = buildImportBlock(collectReferencedFeatures(model), features); + + if (model.outcomes.length === 0) { + return `${imports}${ruleBlock}\n`; + } + + const thenBlock = model.outcomes + .map((o) => { + return ` ${renderOutcomeCall(o)},`; + }) + .join('\n'); + + return `${imports}${ruleBlock} + +WhenRules( + rules_any=[${ruleName}], + then=[ +${thenBlock} + ], +) +`; +} + +/** + * Insert or merge the given source paths into the file's top-level + * `Import(rules=[...])` block. If an Import block already exists, missing + * entries are added in-place; otherwise a new block is prepended. + */ +export function applyMissingImports(source: string, pathsToAdd: string[]): string { + if (pathsToAdd.length === 0) return source; + const existingMatch = source.match(/Import\s*\(\s*rules\s*=\s*\[([^\]]*)\]\s*\)/m); + if (existingMatch) { + const existingPathsBlock = existingMatch[1]; + const existing = new Set( + Array.from(existingPathsBlock.matchAll(/['"]([^'"]+)['"]/g)).map((m) => { + return m[1]; + }) + ); + const merged = new Set(existing); + for (const p of pathsToAdd) merged.add(p); + if (merged.size === existing.size) return source; + const sorted = [...merged].sort(); + const entries = sorted + .map((p) => { + return ` '${p}',`; + }) + .join('\n'); + const replacement = `Import(\n rules=[\n${entries}\n ]\n)`; + return source.replace(existingMatch[0], replacement); + } + const sorted = [...pathsToAdd].sort(); + const entries = sorted + .map((p) => { + return ` '${p}',`; + }) + .join('\n'); + return `Import(\n rules=[\n${entries}\n ]\n)\n\n${source}`; +} + +export function outcomeArgsForEffect(effectName: string, udfs: RuleDraftVocabularyUdf[]): RuleBuilderOutcomeArg[] { + const match = udfs.find((u) => { + return u.name === effectName; + }); + if (!match) return []; + return match.arguments.map((arg) => { + // Defaults the value-vs-feature toggle for the form. Wrong guesses are one click to flip. + const looksLikeFeature = arg.name === 'entity' || /Id$|DID$/i.test(arg.name); + return { name: arg.name, value: '', isFeature: looksLikeFeature }; + }); +} diff --git a/osprey_ui/src/types/RulesTypes.tsx b/osprey_ui/src/types/RulesTypes.tsx index a0d78be1..55ce7fb6 100644 --- a/osprey_ui/src/types/RulesTypes.tsx +++ b/osprey_ui/src/types/RulesTypes.tsx @@ -19,3 +19,100 @@ export enum SortKey { MostReferenced = 'most-referenced', LeastReferenced = 'least-referenced', } + +export interface RuleDraftValidationMessage { + message: string; + hint: string; + source_path: string; + line: number; + column: number; + rendered: string; + identifier?: string | null; + defined_in_source_paths?: string[]; +} + +export interface RuleDraftValidationResponse { + ok: boolean; + errors: RuleDraftValidationMessage[]; + warnings: RuleDraftValidationMessage[]; + suggested_imports?: string[]; +} + +export interface RuleDraftSourceResponse { + path: string; + contents: string; +} + +export interface RuleDraftVocabularyFeature { + name: string; + source_path: string; + source_line: number; +} + +export interface RuleDraftVocabularyUdfArgument { + name: string; + type_name: string; +} + +export interface RuleDraftVocabularyUdf { + name: string; + return_type: string; + arguments: RuleDraftVocabularyUdfArgument[]; +} + +export interface RuleDraftVocabulary { + features: RuleDraftVocabularyFeature[]; + udfs: RuleDraftVocabularyUdf[]; + effects: string[]; + source_files: string[]; +} + +export type RuleDraftStatus = 'draft' | 'deployed'; + +export interface RuleDraft { + id: number; + path: string; + rule_name: string; + source: string; + summary: string; + author: string; + status: RuleDraftStatus; + created_at: string | null; + updated_at: string | null; + deployed_at: string | null; +} + +export type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'includes' | 'excludes'; + +export interface RuleBuilderCondition { + feature: string; + operator: ConditionOperator; + rhs: string; + rhsIsFeature: boolean; +} + +export interface RuleBuilderOutcomeArg { + name: string; + value: string; + isFeature: boolean; +} + +export interface RuleBuilderOutcome { + effect: string; + args: RuleBuilderOutcomeArg[]; +} + +export interface RuleBuilderModel { + ruleName: string; + description: string; + conditions: RuleBuilderCondition[]; + outcomes: RuleBuilderOutcome[]; +} + +export type ParseIntoBuilderResponse = + | { supported: true; model: RuleBuilderModel } + | { supported: false; reason: string }; + +export interface RuleDraftsListResponse { + drafts: RuleDraft[]; +} 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