From c0aa4308ac762f9a6444329b6363a1db3b635b1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 02:44:05 +0000 Subject: [PATCH 1/3] fix(cli): make `os explain flow` teach a flow that actually parses The catalog entry is hand-maintained and does not derive from FlowSchema, so its sample drifted into teaching a shape the spec rejects outright: - `steps` and `trigger` are strictObject ALIASES on FlowSchema (for `nodes` and `type`). Authoring either is a loud parse error, and a record-change flow binds its object on the START node's `config`, 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 missing. - `edges` is required; the sample had no graph at all. - The assignment value `'$currentUser'` is a `$`-prefixed sentinel no resolver in this repo recognises. The flow value dialect is brace-based and the acting user is `{$User.Id}`. Also: an `assignment` node sets a flow VARIABLE, not a record field, so "assign on create" is an `update_record` node. The sample now shows the real shape end to end and is pinned by a test that parses it against FlowSchema, which is the only guard that cannot itself drift. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- packages/cli/src/commands/explain.ts | 36 ++++++++++++----- packages/cli/test/commands.test.ts | 58 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) 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..1db086983f 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,61 @@ 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. + 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 = SCHEMAS.flow.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}'); + for (const [key, info] of Object.entries(SCHEMAS)) { + 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 = [...SCHEMAS.flow.required, ...SCHEMAS.flow.optional].map((f) => f.name); + expect(declared).not.toContain('steps'); + expect(declared).not.toContain('trigger'); + expect(declared).toContain('nodes'); + expect(declared).toContain('edges'); + }); }); From 37a3a287b5009fb816d5049470ac089d9c0805f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 02:56:14 +0000 Subject: [PATCH 2/3] chore(changeset): patch @objectstack/cli for the os explain flow sample fix Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .changeset/explain-flow-example-parses.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/explain-flow-example-parses.md 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. From 2ae166740f83a292278ee7e78707eb092e7cf98c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 03:11:15 +0000 Subject: [PATCH 3/3] test(cli): keep the new explain pins free of implicit any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/cli/test/commands.test.ts` sits outside every tsc program in the repo (the TEST_DEBT ledger records the package), so nothing would have reported an implicit `any` in the pins added for #14782 — and an implicit `any` there silently stops the assertion from checking anything. Measured with an ad-hoc strict pass over the file: origin/main carries 15 errors (13 TS2835 from its extensionless relative imports, 2 TS7006 in the pre-existing ownership test). The first draft of the pins took that to 18. With a local `CatalogField` shape and a typed `Object.entries` cast it is back to exactly the baseline 15 — no new error, and no widening of what the pins actually assert. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- packages/cli/test/commands.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 1db086983f..d7c0db92a1 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -117,6 +117,13 @@ describe('os explain — schema catalog accuracy', () => { // 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; @@ -130,7 +137,7 @@ describe('os explain — schema catalog accuracy', () => { }); it('documents flow.type as the full FlowSchema type enum (#14782)', () => { - const type = SCHEMAS.flow.required.find((f) => f.name === 'type'); + 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( @@ -140,13 +147,14 @@ describe('os explain — schema catalog accuracy', () => { it('teaches the acting user as {$User.Id}, and no catalog example revives $currentUser (#14782)', () => { expect(SCHEMAS.flow.example).toContain('{$User.Id}'); - for (const [key, info] of Object.entries(SCHEMAS)) { + 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 = [...SCHEMAS.flow.required, ...SCHEMAS.flow.optional].map((f) => f.name); + const declared = [...flowFields('required'), ...flowFields('optional')].map((f) => f.name); expect(declared).not.toContain('steps'); expect(declared).not.toContain('trigger'); expect(declared).toContain('nodes');