Skip to content

Commit 308eef0

Browse files
committed
feat(flow-designer): the script node's form authors what the executor runs (framework#4278)
The script flow node's designer form is hand-written here (the engine publishes no configSchema for it, deliberately) and had drifted from the executor: of four offered actionType options, `code` was a recognized no-op, `sms` / `notification` failed every run, the plural "Output variables" field was read by nothing — and the one path that runs real logic (function + inputs + outputVariable) could not be authored. - actionType now offers Call function (default) / Email / Slack — the executor's dispatch set (SCRIPT_BUILTIN_ACTION_TYPES + the invoke_function marker). The function-path fields are first-class. - The inline `script` body becomes render-only: hidden for new nodes, shown (with a not-executed warning) whenever a stored node carries one. The dead plural outputVariables field is removed; stored values surface in the Advanced (JSON) block. - A scalar select whose stored value was dropped from the options renders it as "<value> (deprecated)" instead of blanking it — the rule FlowObjectListField already applied to select cells. - flow-scope (data picker) and the flow simulator stop pretending the legacy outputVariables[] list binds variables — the engine never binds those names; only the singular outputVariable does. - New reconciliation test compares the hand-written script / subflow / decision groups bidirectionally against the executor-derived contracts @objectstack/spec/automation publishes for exactly this purpose, and the wait / connector_action / boundary_event groups against the FlowNodeSchema sibling blocks. `retiredKey()` tombstones are excluded from the contract key set, so a form field for a retired key fails loudly instead of false-passing — verified against the framework#4278 spec build: script/subflow/decision reconcile green, and the wait panel correctly fires on `waitEventConfig.timeoutMs` / `.onTimeout` (retired at spec 18, framework#4198), which the 18 bump must drop from the wait form. The spec-export panels feature-detect and skip on the installed rc.0 spec, arming themselves on the next bump. Verified in the preview gallery (?only=flow): the legacy code sample renders the stored body under "Code (not executed)", the function-path fields show by default, and the select offers exactly the three options. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ki9WjmmSY19koz9hNkeL1P
1 parent 3cb9646 commit 308eef0

15 files changed

Lines changed: 452 additions & 66 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
feat(flow-designer): the script node's form authors what the executor runs — framework#4278
6+
7+
The `script` flow node is one of five builtins whose designer form lives only
8+
in this package's hand-written `FLOW_NODE_CONFIG` table (the engine publishes
9+
no `configSchema` for them, deliberately), and nothing reconciled that table
10+
against the executor. It had drifted user-visibly: of the four `actionType`
11+
options offered, `code` was a recognized no-op (the built-in runtime has no
12+
server-side JS sandbox) and `sms` / `notification` failed every run (neither
13+
is a built-in — they resolve as function names); the "Output variables"
14+
(plural) field was read by nothing; and the one path that runs real logic —
15+
`function` + `inputs` + `outputVariable` — could not be authored at all.
16+
17+
- The `actionType` select now offers **Call function** (default) / **Email** /
18+
**Slack**, mirroring the executor's dispatch set
19+
(`SCRIPT_BUILTIN_ACTION_TYPES` + the `invoke_function` marker). The function
20+
path fields (`function`, `inputs`, `outputVariable`) are first-class.
21+
- The inline `script` body becomes render-only: hidden for new nodes (its help
22+
states it is NOT executed and steers to a registered function), still shown
23+
whenever a stored node carries one. The dead plural `outputVariables` field
24+
is removed; stored values surface in the Advanced (JSON) block.
25+
- A scalar select whose stored value was dropped from the options now renders
26+
it as a flagged "`<value> (deprecated)`" entry instead of blanking it —
27+
the same rule FlowObjectListField already applied to select cells.
28+
- The data picker (`flow-scope`) and the flow simulator stop pretending the
29+
legacy `outputVariables[]` list binds variables — the engine never binds
30+
those names; only the singular `outputVariable` does.
31+
- New reconciliation test: the hand-written `script` / `subflow` / `decision`
32+
groups are compared bidirectionally against the executor-derived config
33+
contracts `@objectstack/spec/automation` publishes for exactly this purpose
34+
(framework#4278), and the `wait` / `connector_action` / `boundary_event`
35+
groups against the `FlowNodeSchema` sibling blocks. The spec-export panels
36+
feature-detect and arm themselves on the next `@objectstack/spec` bump.

packages/app-shell/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -442,13 +442,15 @@ be faithfully modelled is surfaced loudly instead of faked.
442442
free-form **Set variables** editor that injects/overrides *any* variable at
443443
start, so **every branch is reachable**. A **Mock outputs** editor lets the
444444
author pin what each mocked side-effect node "returns" (written to its
445-
`outputVariable` / `outputVariables`), so data-dependent logic downstream of a
446-
`get_record` or `script` can be exercised too.
445+
`outputVariable`), so data-dependent logic downstream of a `get_record` or
446+
`script` can be exercised too.
447447
- **Semantics**`start`/`assignment` pass through; a `decision` routes
448448
**edge-first** (first truthy outgoing `condition`, else the `isDefault` edge,
449449
else a surfaced dead-end), evaluating CEL via `@object-ui/core`'s
450450
`ExpressionEvaluator` and **surfacing eval errors** (not swallowing them);
451-
side-effect nodes write their mock to `outputVariable` / `outputVariables[]`;
451+
side-effect nodes write their mock to `outputVariable` (the legacy script
452+
`outputVariables[]` list is ignored — the engine never binds those names,
453+
framework#4278);
452454
`wait` and `screen` **pause** for manual continue; `join_gateway`, `subflow`,
453455
and `boundary_event` are marked **unsupported** (token sync / nested runs are
454456
not modelled) rather than faked.

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3420,14 +3420,19 @@ const FLOW_FIELD_ZH: Record<string, Record<string, FlowFieldZh>> = {
34203420
script: {
34213421
actionType: {
34223422
label: '动作类型',
3423-
help: '本步骤如何运行。原始脚本保持为“代码”。',
3424-
opts: { code: '代码', email: '邮件', sms: '短信', notification: '通知' },
3423+
help: '本步骤如何运行。“调用函数”会调用已注册的函数 —— 真正执行逻辑的路径。',
3424+
opts: { invoke_function: '调用函数', email: '邮件', slack: 'Slack' },
34253425
},
3426+
function: { label: '函数', help: '要调用的已注册函数 —— 经 defineStack({ functions }) 声明。优先于动作类型。' },
3427+
inputs: { label: '输入', help: '传给函数的值;{var} 引用会按流程变量解析。' },
3428+
outputVariable: { label: '输出变量', help: '绑定函数返回值的流程变量,供后续步骤使用。' },
34263429
template: { label: '模板', help: '消息模板 id。' },
34273430
recipients: { label: '收件人', help: '每行一个收件人(用户 id、字段引用或地址)。' },
34283431
variables: { label: '模板变量', help: '注入模板的值。' },
3429-
script: { label: '代码', help: '脚本主体(JS/TS)。' },
3430-
outputVariables: { label: '输出变量', help: '此脚本写回的变量名。' },
3432+
script: {
3433+
label: '代码(不执行)',
3434+
help: '内置运行时不执行内联脚本 —— 此节点为 no-op。请把逻辑移入已注册函数并使用“调用函数”。',
3435+
},
34313436
timeoutMs: { label: '超时(毫秒)' },
34323437
},
34333438
screen: {

packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,30 @@ describe('FlowNodeConfigField — expression vs template validation gating', ()
100100
expect(screen.queryByRole('note')).toBeNull();
101101
});
102102
});
103+
104+
describe('FlowNodeConfigField — select keeps a stored value dropped from the options (framework#4278)', () => {
105+
const ACTION_TYPE: FlowConfigField = {
106+
id: 'actionType',
107+
path: ['config', 'actionType'],
108+
label: 'Action type',
109+
kind: 'select',
110+
options: [
111+
{ value: 'invoke_function', label: 'Call function' },
112+
{ value: 'email', label: 'Email' },
113+
{ value: 'slack', label: 'Slack' },
114+
],
115+
};
116+
117+
it('renders a legacy stored value as a flagged "(deprecated)" option instead of blanking it', () => {
118+
// A stored script node with the retired `sms` actionType must keep showing
119+
// that value — the same rule FlowObjectListField applies to select cells.
120+
render(<FlowNodeConfigField field={ACTION_TYPE} value="sms" onCommit={() => {}} />);
121+
expect(screen.getByText('sms (deprecated)')).toBeInTheDocument();
122+
});
123+
124+
it('offers only the declared options when the stored value is one of them', () => {
125+
render(<FlowNodeConfigField field={ACTION_TYPE} value="email" onCommit={() => {}} />);
126+
expect(screen.getByText('Email')).toBeInTheDocument();
127+
expect(screen.queryByText(/deprecated/)).toBeNull();
128+
});
129+
});

packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,28 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
139139
/>
140140
);
141141
case 'select':
142-
return (
143-
<InspectorSelectField
144-
label={field.label}
145-
value={value != null ? String(value) : ''}
146-
options={field.options ?? []}
147-
onCommit={(v) => onCommit(v)}
148-
disabled={disabled}
149-
/>
150-
);
142+
return (() => {
143+
const current = value != null ? String(value) : '';
144+
const opts = field.options ?? [];
145+
// A stored value dropped from the options (e.g. a script node's
146+
// legacy `code` / `sms` actionType, framework#4278) must still
147+
// render, or editing a legacy node would silently blank it. Surface
148+
// it as selectable but flag it — it is not offered to fresh nodes.
149+
// Same rule as FlowObjectListField's select cells (ADR-0090 D3).
150+
const shown =
151+
current && !opts.some((o) => o.value === current)
152+
? [...opts, { value: current, label: `${current} (deprecated)` }]
153+
: opts;
154+
return (
155+
<InspectorSelectField
156+
label={field.label}
157+
value={current}
158+
options={shown}
159+
onCommit={(v) => onCommit(v)}
160+
disabled={disabled}
161+
/>
162+
);
163+
})();
151164
case 'textarea':
152165
return (
153166
<div className="space-y-1">

packages/app-shell/src/views/metadata-admin/inspectors/FlowStringListField.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
/**
44
* FlowStringListField — a repeatable single-column editor for string-array
5-
* config (e.g. a notification's `recipients`, a script's `outputVariables`).
5+
* config (e.g. a notification's `recipients` or `channels`).
66
*
77
* Mirrors FlowKeyValueField's local-draft pattern: rows live in LOCAL state
88
* with a STABLE id and only flush to `onCommit` on blur / Enter / add / remove,
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* **Hand-written `FLOW_NODE_CONFIG` ↔ spec-published executor contracts**
5+
* (framework#4278).
6+
*
7+
* Five builtins deliberately publish no descriptor `configSchema` (framework
8+
* `config-schemas.test.ts`): the schema-driven online form cannot express
9+
* their editors — decision's virtual Target column, script's
10+
* actionType-conditional groups, the spec-structured sibling blocks. Their
11+
* Studio form is therefore this package's hand-written table, and until #4278
12+
* nothing reconciled that table against what the executors actually read.
13+
* `script` had drifted user-visibly: an `outputVariables` field nothing reads,
14+
* `sms` / `notification` options that fail every run, a no-op `code` default,
15+
* and no way to author the `function` / `inputs` / `outputVariable` path that
16+
* works.
17+
*
18+
* The machine-readable half now lives in `@objectstack/spec/automation`:
19+
* executor-derived config Zods for `script` / `subflow` / `decision`
20+
* (`schemaless-node-config.zod.ts`) plus the `FlowNodeSchema` sibling blocks
21+
* for `wait` / `connector_action` / `boundary_event`. This file is the
22+
* objectui half of the ledger — the same bidirectional key-set comparison
23+
* service-automation's `builtin-node-form-zod-ledger.test.ts` performs for the
24+
* descriptor-schema'd builtins, carried across the repo seam by the
25+
* `@objectstack/spec` dependency this package already has.
26+
*
27+
* The script/subflow/decision panels feature-detect their spec exports and
28+
* skip while the installed spec predates them (they arm themselves on the next
29+
* `@objectstack/spec` bump — see the version-alignment section of AGENTS.md);
30+
* the sibling-block panels run against every spec version this repo supports.
31+
*/
32+
33+
import { describe, it, expect } from 'vitest';
34+
import * as Automation from '@objectstack/spec/automation';
35+
import { fieldsForNodeType, type FlowConfigField } from './flow-node-config';
36+
37+
// Feature-detected exports — absent on a spec that predates framework#4278.
38+
// (Truthiness alone never resolves a lazySchema proxy.)
39+
const spec = Automation as Record<string, unknown>;
40+
const ScriptConfigSchema = spec.ScriptConfigSchema;
41+
const SubflowConfigSchema = spec.SubflowConfigSchema;
42+
const DecisionConfigSchema = spec.DecisionConfigSchema;
43+
const DecisionConditionSchema = spec.DecisionConditionSchema;
44+
const SCRIPT_BUILTIN_ACTION_TYPES = spec.SCRIPT_BUILTIN_ACTION_TYPES as readonly string[] | undefined;
45+
const SCRIPT_INVOKE_FUNCTION_ACTION_TYPE = spec.SCRIPT_INVOKE_FUNCTION_ACTION_TYPE as string | undefined;
46+
47+
/**
48+
* Keys a Zod object schema accepts, read straight off `.shape`.
49+
*
50+
* `retiredKey()` tombstones are excluded: a retired key stays in the shape so
51+
* its rejection carries the upgrade prescription (spec `shared/retired-key.ts`
52+
* marks it with a `[REMOVED]` description), but it is NOT part of the
53+
* authorable contract — a form field writing one produces metadata the loader
54+
* rejects. Counting tombstones as contract keys would false-pass exactly that
55+
* drift (e.g. `waitEventConfig.timeoutMs` / `.onTimeout`, retired at spec 18
56+
* by framework#4198 — this filter is what makes the `wait` panel fire on that
57+
* bump until the form drops the two fields).
58+
*/
59+
function zodKeys(schema: unknown): string[] {
60+
const shape = (schema as { shape?: Record<string, unknown> }).shape;
61+
expect(shape, 'expected a Zod object schema exposing .shape').toBeDefined();
62+
return Object.keys(shape ?? {})
63+
.filter((k) => {
64+
const description = (shape![k] as { description?: string } | undefined)?.description;
65+
return !(typeof description === 'string' && description.startsWith('[REMOVED]'));
66+
})
67+
.sort();
68+
}
69+
70+
/** Unwrap `.optional()` / `.default()` wrappers down to the object schema. */
71+
function unwrapped(schema: unknown): unknown {
72+
let cur = schema as { shape?: unknown; unwrap?: () => unknown } | undefined;
73+
for (let i = 0; cur && !cur.shape && typeof cur.unwrap === 'function' && i < 5; i++) {
74+
cur = cur.unwrap() as typeof cur;
75+
}
76+
return cur;
77+
}
78+
79+
/** A field render-gated behind the never-matching `__legacy__` controller. */
80+
function isLegacyGated(f: FlowConfigField): boolean {
81+
return f.showWhen?.field === '__legacy__';
82+
}
83+
84+
/** Config keys the form OFFERS for new authoring (legacy render-only excluded). */
85+
function offeredConfigKeys(type: string): string[] {
86+
return [...new Set(
87+
fieldsForNodeType(type)
88+
.filter((f) => f.path[0] === 'config' && !isLegacyGated(f))
89+
.map((f) => f.path[1]!),
90+
)].sort();
91+
}
92+
93+
/**
94+
* Reconcile one node type's config-rooted form keys against its executor
95+
* contract. `renderOnly` names contract keys the form deliberately does NOT
96+
* offer for new authoring — each must still be present as a legacy-gated
97+
* field so stored metadata keeps rendering, and each needs its reason here.
98+
*/
99+
function reconcile(type: string, zod: unknown, renderOnly: Record<string, string> = {}) {
100+
const offered = offeredConfigKeys(type);
101+
const contract = zodKeys(zod);
102+
103+
// Read by the executor, absent from the form ⇒ authorable only by hand —
104+
// the exact shape #4278 found for script's function/inputs/outputVariable.
105+
expect(
106+
contract.filter((k) => !offered.includes(k) && !(k in renderOnly)),
107+
`${type}: read by the executor but not offered by the designer form`,
108+
).toEqual([]);
109+
110+
// Offered by the form, never read by the executor ⇒ a Studio field that
111+
// does nothing — the #3528 / outputVariables shape.
112+
expect(
113+
offered.filter((k) => !contract.includes(k)),
114+
`${type}: offered by the designer form but never read by the executor`,
115+
).toEqual([]);
116+
117+
// Every render-only exemption must (a) be part of the executor contract and
118+
// (b) still render for stored metadata via a legacy-gated field.
119+
for (const key of Object.keys(renderOnly)) {
120+
expect(contract, `${type}: render-only exemption '${key}' must be a contract key`).toContain(key);
121+
const field = fieldsForNodeType(type).find((f) => f.path[0] === 'config' && f.path[1] === key);
122+
expect(field, `${type}: render-only key '${key}' must keep a (legacy-gated) field`).toBeDefined();
123+
expect(isLegacyGated(field!), `${type}: '${key}' must be legacy-gated, not offered`).toBe(true);
124+
}
125+
}
126+
127+
describe.skipIf(!ScriptConfigSchema)('script form ↔ ScriptConfigSchema (framework#4278)', () => {
128+
it('offers exactly the executor-read keys; inline `script` stays render-only (a recognized no-op)', () => {
129+
reconcile('script', ScriptConfigSchema, {
130+
script: 'recognized but NOT executed by the built-in runtime (no server-side JS sandbox) — renders for stored nodes, steers authors to `function`',
131+
});
132+
});
133+
134+
it('offers exactly the published action types: invoke_function + the built-in set', () => {
135+
const actionType = fieldsForNodeType('script').find((f) => f.id === 'actionType')!;
136+
expect(actionType.options!.map((o) => o.value).sort()).toEqual(
137+
[SCRIPT_INVOKE_FUNCTION_ACTION_TYPE!, ...SCRIPT_BUILTIN_ACTION_TYPES!].sort(),
138+
);
139+
// The default is the path that runs real logic, not the no-op.
140+
expect(actionType.defaultValue).toBe(SCRIPT_INVOKE_FUNCTION_ACTION_TYPE);
141+
});
142+
});
143+
144+
describe.skipIf(!SubflowConfigSchema)('subflow form ↔ SubflowConfigSchema (framework#4278)', () => {
145+
it('offers exactly the executor-read keys', () => {
146+
reconcile('subflow', SubflowConfigSchema);
147+
});
148+
});
149+
150+
describe.skipIf(!DecisionConfigSchema)('decision form ↔ DecisionConfigSchema (framework#4278)', () => {
151+
it('offers exactly the executor-read keys (legacy single `condition` stays render-only)', () => {
152+
// `condition` (singular) is legacy-gated in the form and deliberately NOT
153+
// in the contract: the decision executor never reads it — branching lives
154+
// in `conditions[]` or on edge conditions. Legacy-gated fields are already
155+
// excluded from `offered`, so plain reconciliation covers it.
156+
reconcile('decision', DecisionConfigSchema);
157+
});
158+
159+
it('branch columns match DecisionConditionSchema one level down (Target is virtual)', () => {
160+
const conditions = fieldsForNodeType('decision').find((f) => f.id === 'conditions')!;
161+
const columnKeys = conditions.columns!.map((c) => c.key).sort();
162+
const contract = zodKeys(DecisionConditionSchema);
163+
// `target` is a VIRTUAL column — projected from / applied to the out-edges
164+
// by flow-decision-edges, never stored on the branch — so it is the one
165+
// legitimate column the stored-shape contract does not carry.
166+
expect(columnKeys.filter((k) => k !== 'target')).toEqual(contract);
167+
expect(columnKeys).toContain('target');
168+
});
169+
});
170+
171+
describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet)', () => {
172+
// These blocks are published by every spec version this repo supports, so
173+
// no feature detection: the hand-written groups for wait / connector_action
174+
// / boundary_event must edit exactly the keys the spec block declares —
175+
// #4161 / #4210 verified them by hand once; this keeps them verified.
176+
const FlowNodeSchema = spec.FlowNodeSchema as { shape: Record<string, unknown> };
177+
178+
const BLOCKS: ReadonlyArray<{ type: string; block: string }> = [
179+
{ type: 'wait', block: 'waitEventConfig' },
180+
{ type: 'connector_action', block: 'connectorConfig' },
181+
{ type: 'boundary_event', block: 'boundaryConfig' },
182+
];
183+
184+
it.each(BLOCKS)('$type: the $block fields match the spec block exactly', ({ type, block }) => {
185+
const formKeys = [...new Set(
186+
fieldsForNodeType(type)
187+
.filter((f) => f.path[0] === block)
188+
.map((f) => f.path[1]!),
189+
)].sort();
190+
const blockKeys = zodKeys(unwrapped(FlowNodeSchema.shape[block]));
191+
192+
expect(
193+
blockKeys.filter((k) => !formKeys.includes(k)),
194+
`${type}: declared by the spec block but absent from the designer form`,
195+
).toEqual([]);
196+
expect(
197+
formKeys.filter((k) => !blockKeys.includes(k)),
198+
`${type}: edited by the designer form but not declared by the spec block`,
199+
).toEqual([]);
200+
});
201+
});

0 commit comments

Comments
 (0)