Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/explain-flow-example-parses.md
Original file line numberDiff line numberDiff line change
@@ -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.
36 changes: 27 additions & 9 deletions packages/cli/src/commands/explain.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,25 +107,43 @@ export const SCHEMAS: Record<string, SchemaInfo> = {

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.<field>} 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'],
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/explain-flow-example-parses.md
Original file line numberDiff line numberDiff line change
@@ -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.
36 changes: 27 additions & 9 deletions packages/cli/src/commands/explain.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,25 +107,43 @@ export const SCHEMAS: Record<string, SchemaInfo> = {

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.<field>} 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'],
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/explain-flow-example-parses.md
Original file line numberDiff line numberDiff line change
@@ -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.
36 changes: 27 additions & 9 deletions packages/cli/src/commands/explain.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,25 +107,43 @@ export const SCHEMAS: Record<string, SchemaInfo> = {

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.<field>} 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'],
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/explain-flow-example-parses.md
Original file line numberDiff line numberDiff line change
@@ -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.
36 changes: 27 additions & 9 deletions packages/cli/src/commands/explain.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,25 +107,43 @@ export const SCHEMAS: Record<string, SchemaInfo> = {

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.<field>} 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'],
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/explain-flow-example-parses.md
Original file line numberDiff line numberDiff line change
@@ -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.
36 changes: 27 additions & 9 deletions packages/cli/src/commands/explain.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,25 +107,43 @@ export const SCHEMAS: Record<string, SchemaInfo> = {

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.<field>} 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'],
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/explain-flow-example-parses.md
Original file line numberDiff line numberDiff line change
@@ -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.
36 changes: 27 additions & 9 deletions packages/cli/src/commands/explain.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,25 +107,43 @@ export const SCHEMAS: Record<string, SchemaInfo> = {

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.<field>} 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'],
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/explain-flow-example-parses.md
Original file line numberDiff line numberDiff line change
@@ -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.
36 changes: 27 additions & 9 deletions packages/cli/src/commands/explain.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,25 +107,43 @@ export const SCHEMAS: Record<string, SchemaInfo> = {

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.<field>} 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'],
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/explain-flow-example-parses.md
Original file line numberDiff line numberDiff line change
@@ -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.
36 changes: 27 additions & 9 deletions packages/cli/src/commands/explain.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,25 +107,43 @@ export const SCHEMAS: Record<string, SchemaInfo> = {

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.<field>} 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'],
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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');
});
});
Loading