diff --git a/.changeset/explain-flow-example-parses.md b/.changeset/explain-flow-example-parses.md new file mode 100644 index 0000000000..3c225567ec --- /dev/null +++ b/.changeset/explain-flow-example-parses.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cli': patch +--- + +Fix `os explain flow`, whose example taught a flow shape the spec rejects and an assignment value nothing resolves. + +`os explain` is an authoring aid whose whole audience is authors — increasingly AI authors — writing their first flow, and its catalog is hand-maintained rather than derived from `FlowSchema`. The flow entry had drifted until the sample it printed could not be pasted into a working app: + +- `steps` and `trigger` are strict-object **aliases** on `FlowSchema` (for `nodes` and `type`), so authoring either is a loud parse error rather than a working flow. A record-change flow binds its object on the START node's `config` (`{ objectName, triggerType }`), not at the flow top level. +- A node's per-type data lives under `config`, so the sample's top-level `field`/`value` pair were undeclared keys on a `.strict()` node schema, and the required `id` / `label` were absent. `edges` is required, and the sample declared no graph at all. +- The value `'$currentUser'` was a `$`-prefixed sentinel no resolver in the platform recognises. Flow values interpolate with **single braces**, and the acting user is `{$User.Id}` — the filter surface's `{current_user_id}` is a different dialect that does not carry over, because assignment and `fields` values go through `interpolate`, not `interpolateFilter`. +- An `assignment` node sets a flow **variable**, not a record field, so "auto-assign on create" is an `update_record` node. The old sample would not have written `assigned_to` even with a resolving token. + +The entry's field list now matches `FlowSchema` (`nodes` / `edges` / the full five-value `type` enum / `status` / `runAs`), and the example is pinned by a test that parses it against `FlowSchema` — the one guard that cannot drift alongside the catalog it checks. diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 8197c195d8..32c9efe2ab 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -107,25 +107,43 @@ export const SCHEMAS: Record = { flow: { name: 'Flow', - description: 'Visual logic orchestration for business processes. Flows can be auto-launched, screen-based, or scheduled.', + description: 'Visual logic orchestration for business processes. A flow is a GRAPH — `nodes` plus the `edges` that connect them — auto-launched, record-change, screen-based, scheduled, or API-invoked.', required: [ { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' }, - { name: 'type', type: '"autolaunched" | "screen" | "schedule"', description: 'Trigger type' }, + { name: 'label', type: 'string', description: 'Display name' }, + { name: 'type', type: '"autolaunched" | "record_change" | "schedule" | "screen" | "api"', description: 'Flow type' }, + { name: 'nodes', type: 'FlowNode[]', description: 'Graph nodes, each { id, type, label, config? }. Per-node data lives under `config` — there are no top-level `field`/`value` keys.' }, + { name: 'edges', type: 'FlowEdge[]', description: 'Graph connections, each { id, source, target, condition?, label? }. Bare CEL in `condition` — never {…} braces.' }, ], optional: [ - { name: 'label', type: 'string', description: 'Display name' }, { name: 'description', type: 'string', description: 'Documentation for the flow' }, - { name: 'trigger', type: 'TriggerConfig', description: 'Event that starts the flow' }, - { name: 'steps', type: 'FlowStep[]', description: 'Sequence of actions' }, + { name: 'status', type: '"draft" | "active" | "obsolete" | "invalid"', description: 'Deployment status (default "draft") — the engine arms flows from this' }, { name: 'variables', type: 'Variable[]', description: 'Flow-scoped variables' }, + { name: 'runAs', type: '"system" | "user"', description: 'Execution identity (default "user" — runs as the triggering user, respecting RLS)' }, ], example: `{ name: 'assign_on_create', - type: 'autolaunched', + type: 'record_change', label: 'Auto-Assign on Create', - trigger: { object: 'project_task', event: 'afterInsert' }, - steps: [ - { type: 'assignment', field: 'assigned_to', value: '$currentUser' }, + status: 'active', + nodes: [ + // A record-change flow binds its object on the START node's config, + // not at the flow top level. + { id: 'start', type: 'start', label: 'On Task Create', + config: { objectName: 'project_task', triggerType: 'record-after-create' } }, + // Values interpolate with SINGLE braces. {$User.Id} is the acting user; + // {record.} reads the triggering record. + { id: 'assign', type: 'update_record', label: 'Assign to Actor', + config: { + objectName: 'project_task', + filter: { id: '{record.id}' }, + fields: { assigned_to: '{$User.Id}' }, + } }, + { id: 'done', type: 'end', label: 'Done' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'assign' }, + { id: 'e2', source: 'assign', target: 'done' }, ], }`, related: ['object', 'trigger', 'agent'], diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 27e2996e58..d7c0db92a1 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -12,6 +12,7 @@ import Generate from '../src/commands/generate'; import Lint from '../src/commands/lint'; import Diff from '../src/commands/diff'; import Explain, { SCHEMAS } from '../src/commands/explain'; +import { FlowSchema } from '@objectstack/spec/automation'; describe('CLI Commands (oclif)', () => { it('should have compile command', () => { @@ -94,4 +95,69 @@ describe('os explain — schema catalog accuracy', () => { // …and must never regress back to the contribution-kind values. expect(ownership!.type).not.toBe('"own" | "extend"'); }); + + // ── `os explain flow` ─────────────────────────────────────────────────── + // + // The flow entry shipped a sample that could not parse, and the catalog is + // hand-maintained (it does NOT derive from FlowSchema), so nothing said so: + // • `steps` and `trigger` are strictObject ALIASES on FlowSchema (for + // `nodes` and `type`) — authoring either is a loud parse error; + // • a node's per-type data lives under `config`, so the sample's top-level + // `field`/`value` pair are undeclared keys on a `.strict()` node, and its + // required `id`/`label` were absent; + // • `edges` is required — a graph with no edges was not expressible; + // • the value `'$currentUser'` was a `$`-prefixed sentinel NO resolver in + // the repo recognises. The flow value dialect is brace-based, and the + // acting user is `{$User.Id}` (template.ts `resolveToken`, whose + // `$User.Id` branch returns `context.userId`). The neighbouring FILTER + // dialect's `{current_user_id}` is a different door and does NOT carry + // over: assignment/`fields` values go through plain `interpolate`, not + // `interpolateFilter`. + // + // Parsing the sample against the real schema is the guard that cannot itself + // drift — it re-derives the truth from the spec on every run, which is what + // the hand-maintained catalog otherwise has no way to do. + // The catalog's element shape, stated locally: `SchemaInfo` is not exported, + // and these tests must stay honest even where `SCHEMAS` widens to `any` + // (this file sits outside every tsc program — see the TEST_DEBT ledger — so + // an implicit `any` here would silently stop checking anything). + type CatalogField = { name: string; type: string }; + const flowFields = (kind: 'required' | 'optional'): CatalogField[] => SCHEMAS.flow[kind]; + + it('ships a flow example that actually parses as a Flow (#14782)', () => { + // The catalog stores examples as authored source, so evaluate the literal. + const literal = new Function(`return (${SCHEMAS.flow.example});`)() as unknown; + const result = FlowSchema.safeParse(literal); + expect( + result.success, + `os explain flow's example must parse as a Flow. Issues: ${ + result.success ? '' : JSON.stringify(result.error.issues, null, 2) + }`, + ).toBe(true); + }); + + it('documents flow.type as the full FlowSchema type enum (#14782)', () => { + const type = flowFields('required').find((f) => f.name === 'type'); + expect(type, 'flow schema should document a `type` field').toBeDefined(); + const tokens = (type!.type.match(/'[^']+'|"[^"]+"/g) ?? []).map((t) => t.slice(1, -1)); + expect(new Set(tokens)).toEqual( + new Set(['autolaunched', 'record_change', 'schedule', 'screen', 'api']), + ); + }); + + it('teaches the acting user as {$User.Id}, and no catalog example revives $currentUser (#14782)', () => { + expect(SCHEMAS.flow.example).toContain('{$User.Id}'); + const entries = Object.entries(SCHEMAS) as Array<[string, { example: string }]>; + for (const [key, info] of entries) { + expect(info.example, `os explain ${key} example`).not.toContain('$currentUser'); + } + }); + + it('never re-teaches `steps` / `trigger` as flow keys — both are aliases, not fields (#14782)', () => { + const declared = [...flowFields('required'), ...flowFields('optional')].map((f) => f.name); + expect(declared).not.toContain('steps'); + expect(declared).not.toContain('trigger'); + expect(declared).toContain('nodes'); + expect(declared).toContain('edges'); + }); });