Skip to content

Commit 0670808

Browse files
committed
feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278)
The descriptor-schemaless builtins (decision/script/subflow/wait/ connector_action) deliberately publish no configSchema, so their Studio forms live only in objectui's hand-written FLOW_NODE_CONFIG table — and nothing reconciled that table against the executors. Publish the machine-readable half of that reconciliation from spec, written from the executors, not from any form: - automation/schemaless-node-config.zod.ts: ScriptConfigSchema / SubflowConfigSchema / DecisionConfigSchema (+ DecisionConditionSchema), plus SCRIPT_BUILTIN_ACTION_TYPES and SCRIPT_INVOKE_FUNCTION_ACTION_TYPE. Contract exports only — no engine path parses node config with them. wait / connector_action need no new schema: their contracts are the existing FlowNodeSchema sibling blocks. - The script executor now builds its dispatch set from the published constant, so the designer's options, the dispatch set, and the "not a built-in action" failure message cannot disagree. New pins: every published action type runs the built-in branch, the failure message names exactly the published set, and the Zod accepts the canonical authoring shapes. - subflow's undeclared `flow` alias graduates into the ADR-0087 D2 conversion layer (flow-node-subflow-flow-alias, protocol 17, live window) and the bare `cfg.flowName ?? cfg.flow` executor fallback is deleted (PD #12 — the map.flow path). Proved at the registerFlow seam. objectui's flow-node-config.spec-reconciliation test is the other half: it compares the hand-written form groups against these exports, closing the cross-repo seam #4278 documented (script offered three broken options and could not author the function path that works). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ki9WjmmSY19koz9hNkeL1P
1 parent 05154a1 commit 0670808

20 files changed

Lines changed: 599 additions & 13 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": patch
4+
---
5+
6+
feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278)
7+
8+
The five descriptor-schemaless builtins (`decision` / `script` / `subflow` /
9+
`wait` / `connector_action`) deliberately publish no `configSchema`, so their
10+
Studio form lives only in objectui's hand-written `FLOW_NODE_CONFIG` table —
11+
and nothing reconciled that table against the executors. `script` had drifted:
12+
the form offered an `outputVariables` key nothing reads, two `actionType`
13+
options (`sms` / `notification`) that fail every run, a no-op default (`code`),
14+
and could not author the `function` / `inputs` / `outputVariable` path that
15+
works.
16+
17+
New in `@objectstack/spec/automation` (contract exports only — no engine path
18+
parses node config with them):
19+
20+
- `ScriptConfigSchema` / `SubflowConfigSchema` / `DecisionConfigSchema` (+
21+
`DecisionConditionSchema`) — written from the executors in
22+
`service-automation`, the machine-readable half of the cross-repo
23+
reconciliation objectui's `flow-node-config` test now performs. `wait` and
24+
`connector_action` need no new schema — their contracts are the existing
25+
`FlowNodeSchema` sibling blocks (`waitEventConfig` / `connectorConfig`).
26+
- `SCRIPT_BUILTIN_ACTION_TYPES` (`['email', 'slack']`) and
27+
`SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` (`'invoke_function'`) — the `script`
28+
executor now builds its dispatch set from the published constant, so the
29+
designer's options, the dispatch set, and the "not a built-in action"
30+
failure message can no longer disagree.
31+
32+
Undeclared-alias graduation in the same change (Prime Directive #12, the
33+
`map.flow` path): the `subflow` executor's bare `cfg.flowName ?? cfg.flow`
34+
fallback is deleted, replaced by the ADR-0087 D2 conversion
35+
`flow-node-subflow-flow-alias` — a stored `subflow` node authored with
36+
`config.flow` is rewritten to the canonical `config.flowName` at load
37+
(including the `AutomationEngine.registerFlow` rehydration seam). FROM
38+
`config.flow` TO `config.flowName`; one-line fix for hand-maintained sources:
39+
rename the key.

content/docs/references/automation/builtin-node-config.mdx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,19 @@ reason instead of pretending a shape.
6565

6666
- `decision` / `script` / `subflow` / `wait` / `connector_action` — the
6767

68-
deliberately-schemaless class (config-schemas.test.ts); their contracts
68+
descriptor-schemaless class (config-schemas.test.ts). `wait` and
6969

70-
live elsewhere (edges, sibling blocks, conditional forms).
70+
`connector_action` keep their contracts in FlowNodeSchema's sibling
71+
72+
blocks (`waitEventConfig` / `connectorConfig`); the other three publish
73+
74+
executor-derived config contracts in `schemaless-node-config.zod.ts`
75+
76+
(#4278) — separate from this module because they must NOT grow into
77+
78+
descriptor `configSchema`s (the forms they describe stay hand-written in
79+
80+
objectui, reconciled by a test there).
7181

7282
<Callout type="info">
7383
**Source:** `packages/spec/src/automation/builtin-node-config.zod.ts`

content/docs/references/automation/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
1515
<Card href="/docs/references/automation/flow" title="Flow" description="Source: packages/spec/src/automation/flow.zod.ts" />
1616
<Card href="/docs/references/automation/io-node-config" title="Io Node Config" description="Source: packages/spec/src/automation/io-node-config.zod.ts" />
1717
<Card href="/docs/references/automation/node-executor" title="Node Executor" description="Source: packages/spec/src/automation/node-executor.zod.ts" />
18+
<Card href="/docs/references/automation/schemaless-node-config" title="Schemaless Node Config" description="Source: packages/spec/src/automation/schemaless-node-config.zod.ts" />
1819
<Card href="/docs/references/automation/state-machine" title="State Machine" description="Source: packages/spec/src/automation/state-machine.zod.ts" />
1920
<Card href="/docs/references/automation/sync" title="Sync" description="Source: packages/spec/src/automation/sync.zod.ts" />
2021
<Card href="/docs/references/automation/time-relative-trigger" title="Time Relative Trigger" description="Source: packages/spec/src/automation/time-relative-trigger.zod.ts" />

content/docs/references/automation/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"job",
2222
"---More---",
2323
"builtin-node-config",
24-
"io-node-config"
24+
"io-node-config",
25+
"schemaless-node-config"
2526
]
2627
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
---
2+
title: Schemaless Node Config
3+
description: Schemaless Node Config protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
@module automation/schemaless-node-config
9+
10+
Config contracts for the **descriptor-schemaless** builtins whose designer
11+
12+
form lives ONLY in objectui's hand-written `FLOW_NODE_CONFIG` table —
13+
14+
`script`, `subflow` and `decision` (#4278).
15+
16+
## Why these nodes publish no descriptor `configSchema` — and still need this
17+
18+
`config-schemas.test.ts` in `service-automation` pins the schemaless class
19+
20+
with each member's reason: `decision`'s virtual Target column is derived from
21+
22+
the out-edges, `script`'s form switches on `actionType`, `subflow` carries a
23+
24+
top-level `timeoutMs` — a published partial schema would DROP those editors
25+
26+
(the #4210 `connector_action` incident). So the Studio form for these types
27+
28+
is objectui's hand-written group, and until #4278 **nothing reconciled that
29+
30+
hand-written table against the executors**: `script`'s form offered an
31+
32+
`outputVariables` key nothing reads, two `actionType` options that fail every
33+
34+
run, a no-op default — and could not author the `function`/`inputs`/
35+
36+
`outputVariable` path that works.
37+
38+
These schemas are the machine-readable half of that reconciliation. They are
39+
40+
**written from the executors** (`service-automation/builtin/screen-nodes.ts`
41+
42+
for `script`, `subflow-node.ts`, `logic-nodes.ts` for `decision`), not from
43+
44+
any form, and objectui's `flow-node-config` reconciliation test compares its
45+
46+
hand-written key sets against them — the same bidirectional ledger the
47+
48+
descriptor-schema'd builtins get from `builtin-node-form-zod-ledger.test.ts`,
49+
50+
carried across the repo seam by the `@objectstack/spec` dependency objectui
51+
52+
already has.
53+
54+
`wait` and `connector_action` — the other two schemaless members — need no
55+
56+
entry here: their contracts are the spec-structured sibling blocks on
57+
58+
`FlowNodeSchema` (`waitEventConfig` / `connectorConfig`), which the
59+
60+
same objectui test reconciles directly.
61+
62+
## What these schemas are (and are not) wired to
63+
64+
Contract exports only — like `builtin-node-config.zod.ts`, no engine path
65+
66+
`parse()`s a node config with them today, so registering a flow behaves
67+
68+
exactly as before.
69+
70+
Undeclared aliases are NOT part of these contracts: `subflow`'s historical
71+
72+
`flow` spelling graduated into the ADR-0087 D2 conversion
73+
74+
`flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only
75+
76+
ever sees `flowName`.
77+
78+
<Callout type="info">
79+
**Source:** `packages/spec/src/automation/schemaless-node-config.zod.ts`
80+
</Callout>
81+
82+
## TypeScript Usage
83+
84+
```typescript
85+
import { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation';
86+
import type { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation';
87+
88+
// Validate data
89+
const result = DecisionCondition.parse(data);
90+
```
91+
92+
---
93+
94+
## DecisionCondition
95+
96+
### Properties
97+
98+
| Property | Type | Required | Description |
99+
| :--- | :--- | :--- | :--- |
100+
| **label** | `string` || Branch label; the winning branch resumes down the out-edge with this label ('true' expression = default/else path) |
101+
| **expression** | `string` || Bare CEL predicate deciding this branch |
102+
103+
104+
---
105+
106+
## DecisionConfig
107+
108+
### Properties
109+
110+
| Property | Type | Required | Description |
111+
| :--- | :--- | :--- | :--- |
112+
| **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) |
113+
114+
115+
---
116+
117+
## ScriptConfig
118+
119+
### Properties
120+
121+
| Property | Type | Required | Description |
122+
| :--- | :--- | :--- | :--- |
123+
| **actionType** | `string` | optional | How this step runs: a built-in side effect ('email' \| 'slack'), the 'invoke_function' marker, or shorthand for a registered-function name |
124+
| **function** | `string` | optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType |
125+
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
126+
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to |
127+
| **template** | `string` | optional | Built-in side effects only: message template id |
128+
| **recipients** | `string[]` | optional | Built-in side effects only: recipients (user ids, field refs, or addresses) |
129+
| **variables** | `Record<string, any>` | optional | Built-in side effects only: values injected into the template |
130+
| **script** | `string` | optional | Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead |
131+
132+
133+
---
134+
135+
## SubflowConfig
136+
137+
### Properties
138+
139+
| Property | Type | Required | Description |
140+
| :--- | :--- | :--- | :--- |
141+
| **flowName** | `string` || Flow invoked as this step (it may pause — approval / screen / wait) |
142+
| **input** | `Record<string, any>` | optional | Values passed to the subflow's input variables (interpolate `{token}` templates) |
143+
| **outputVariable** | `string` | optional | Parent flow variable the subflow's output is bound to |
144+
145+
146+
---
147+

docs/protocol-upgrade-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` (#424
157157
| `flow-node-wait-event-config-lift` | `flow.node.wait.waitEventConfig` | wait flow-node loose config keys → the declared `waitEventConfig` block: 'eventType', 'timerDuration'/'duration' → 'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (#4045) | live — protocol 17 loader accepts the old shape |
158158
| `flow-node-connector-config-lift` | `flow.node.connector_action.connectorConfig` | connector_action flow-node loose config keys 'connectorId' / 'actionId' / 'input' → the declared `connectorConfig` block (#4045) | live — protocol 17 loader accepts the old shape |
159159
| `flow-node-map-flow-alias` | `flow.node.map.config.flowName` | map flow-node config key 'flow' → 'flowName' (#4045 — undeclared executor fallback graduation) | live — protocol 17 loader accepts the old shape |
160+
| `flow-node-subflow-flow-alias` | `flow.node.subflow.config.flowName` | subflow flow-node config key 'flow' → 'flowName' (#4278 — undeclared executor fallback graduation) | live — protocol 17 loader accepts the old shape |
160161
| `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape |
161162
| `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only |
162163
| `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only |

packages/services/service-automation/src/builtin/config-schemas.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
* and `connector_action` (top-level `connectorConfig` block, #4045) — a
1818
* partial schema would drop those editors, and a schema rooted at `config`
1919
* would actively re-route sibling-block authoring to keys nothing reads.
20+
*
21+
* Schemaless ≠ contractless (#4278): `script` / `subflow` / `decision` publish
22+
* executor-derived config Zods in spec `automation/schemaless-node-config.zod.ts`
23+
* (wait / connector_action keep theirs as FlowNodeSchema sibling blocks), and
24+
* objectui reconciles its hand-written form groups against those exports. The
25+
* descriptors below still publish NO configSchema — that is what this file pins.
2026
*/
2127

2228
import { describe, it, expect } from 'vitest';

packages/services/service-automation/src/builtin/screen-nodes.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, beforeEach } from 'vitest';
4+
import {
5+
SCRIPT_BUILTIN_ACTION_TYPES,
6+
SCRIPT_INVOKE_FUNCTION_ACTION_TYPE,
7+
ScriptConfigSchema,
8+
} from '@objectstack/spec/automation';
49
import { AutomationEngine, type FlowFunctionHandler } from '../engine.js';
510
import { registerScreenNodes } from './screen-nodes.js';
611

@@ -155,6 +160,65 @@ it('canonicalizes a stored `functionName` key to `function` at load (#1870 DX, #
155160
});
156161
});
157162

163+
/**
164+
* #4278 — the script node's contract is the spec-published one. The designer
165+
* form for `script` is objectui's hand-written group (this node deliberately
166+
* publishes no descriptor configSchema — config-schemas.test.ts), so the only
167+
* machine-readable statement of what it accepts is
168+
* `SCRIPT_BUILTIN_ACTION_TYPES` / `ScriptConfigSchema` in
169+
* `@objectstack/spec/automation`. These pins are the objectstack half of the
170+
* cross-repo reconciliation: the executor dispatches exactly the published
171+
* built-in set (it now builds its dispatch set FROM the constant), and its
172+
* failure message names that same set — objectui's side reconciles its form
173+
* options and key set against the same exports.
174+
*/
175+
describe('script contract ↔ spec-published constants (#4278)', () => {
176+
let engine: AutomationEngine;
177+
178+
beforeEach(() => {
179+
engine = new AutomationEngine(createTestLogger());
180+
registerScreenNodes(engine, createCtx());
181+
});
182+
183+
it.each([...SCRIPT_BUILTIN_ACTION_TYPES])(
184+
"every published built-in actionType runs the built-in branch: '%s'",
185+
async (actionType) => {
186+
engine.registerFlow('script_flow', scriptFlow({ actionType, template: 't', recipients: ['a'] }));
187+
const result = await engine.execute('script_flow', {} as any);
188+
expect(result.success).toBe(true);
189+
},
190+
);
191+
192+
it('an actionType outside the published set fails naming exactly that set (the #4278 sms repro)', async () => {
193+
// The old objectui form offered 'sms' / 'notification'; neither is in
194+
// the published set, so they resolve as function names and fail. The
195+
// error must name the published members — it is the message the #4278
196+
// report quoted, and the form's options now come from the same constant.
197+
engine.registerFlow('script_flow', scriptFlow({ actionType: 'sms' }));
198+
const result = await engine.execute('script_flow', {} as any);
199+
expect(result.success).toBe(false);
200+
for (const builtin of SCRIPT_BUILTIN_ACTION_TYPES) {
201+
expect(result.error).toContain(builtin);
202+
}
203+
expect(result.error).toMatch(/'sms' is not a built-in action/);
204+
});
205+
206+
it('the published Zod accepts the canonical authoring shapes (contract sanity)', () => {
207+
// Function path — the only shape that does real work.
208+
expect(ScriptConfigSchema.parse({
209+
actionType: SCRIPT_INVOKE_FUNCTION_ACTION_TYPE,
210+
function: 'score_lead',
211+
inputs: { leadId: '{record.id}' },
212+
outputVariable: 'score',
213+
})).toMatchObject({ function: 'score_lead' });
214+
// Built-in side effect.
215+
expect(ScriptConfigSchema.parse({ actionType: 'email', template: 't', recipients: ['a'], variables: { x: 1 } }))
216+
.toMatchObject({ actionType: 'email' });
217+
// Inline script — recognized (and documented as not executed).
218+
expect(ScriptConfigSchema.parse({ script: 'return 1;' })).toMatchObject({ script: 'return 1;' });
219+
});
220+
});
221+
158222
/** A one-`screen`-node flow whose screen node carries `config`. */
159223
function screenFlow(config: Record<string, unknown>) {
160224
return {

packages/services/service-automation/src/builtin/screen-nodes.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { PluginContext } from '@objectstack/core';
4-
import { defineActionDescriptor } from '@objectstack/spec/automation';
4+
import { defineActionDescriptor, SCRIPT_BUILTIN_ACTION_TYPES } from '@objectstack/spec/automation';
55
import type { AutomationEngine } from '../engine.js';
66
import { interpolate } from './template.js';
77

@@ -29,9 +29,12 @@ import { interpolate } from './template.js';
2929

3030
/**
3131
* Built-in `script` side-effect action types with a (logger-backed) handler.
32-
* Anything else is treated as a registered-function name (#1870).
32+
* Anything else is treated as a registered-function name (#1870). The member
33+
* list is the spec-published `SCRIPT_BUILTIN_ACTION_TYPES` — the same constant
34+
* the designer's `actionType` options reconcile against (#4278), so the form,
35+
* this dispatch set, and the failure message below cannot disagree.
3336
*/
34-
const SCRIPT_BUILTIN_ACTION_TYPES = new Set(['email', 'slack']);
37+
const SCRIPT_BUILTINS = new Set<string>(SCRIPT_BUILTIN_ACTION_TYPES);
3538

3639
export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void {
3740
// screen — server-side pass-through (input vars already injected by engine).
@@ -206,7 +209,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
206209

207210
// Built-in side-effect actions keep their logger-backed behavior — but
208211
// only when an explicit `function` isn't set (that always wins).
209-
if (!fnName && actionType && SCRIPT_BUILTIN_ACTION_TYPES.has(actionType)) {
212+
if (!fnName && actionType && SCRIPT_BUILTINS.has(actionType)) {
210213
ctx.logger.info(
211214
`[Script:${actionType}] template=${String(cfg.template)} ` +
212215
`recipients=${JSON.stringify(cfg.recipients)} ` +
@@ -255,7 +258,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
255258
success: false,
256259
error:
257260
`script node '${node.id}': '${target}' is not a built-in action ` +
258-
`(${[...SCRIPT_BUILTIN_ACTION_TYPES].join(', ')}) and no function named '${target}' is registered. ` +
261+
`(${[...SCRIPT_BUILTINS].join(', ')}) and no function named '${target}' is registered. ` +
259262
`Register it via \`defineStack({ functions: { '${target}': fn } })\`, or fix the name (#1870).`,
260263
};
261264
}

packages/services/service-automation/src/builtin/subflow-node.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,4 +362,23 @@ describe('subflow node executor', () => {
362362
expect(result.success).toBe(false);
363363
expect(result.error).toMatch(/depth|recursive/i);
364364
});
365+
366+
// #4278 — this proves the CONVERSION, not executor tolerance. The executor
367+
// reads only the canonical `flowName` (its bare `cfg.flow` fallback was
368+
// deleted with the graduation); a stored flow on the undeclared `flow`
369+
// spelling reaches it because `registerFlow` applies
370+
// 'flow-node-subflow-flow-alias', which renames it at load.
371+
it('accepts the legacy `config.flow` spelling via the conversion layer (#4278)', async () => {
372+
engine.registerFlow('parent_flow', parentFlow({
373+
flow: 'child_flow',
374+
input: { msg: '{greeting}' },
375+
outputVariable: 'subResult',
376+
}));
377+
378+
const result = await engine.execute('parent_flow', { params: { greeting: 'hi' } });
379+
380+
expect(result.success).toBe(true);
381+
expect(ran).toEqual(['child']);
382+
expect(captured).toEqual([{ result: 'CHILD_DONE' }]);
383+
});
365384
});

0 commit comments

Comments
 (0)