Skip to content

Commit b67be19

Browse files
os-zhuangclaude
andauthored
feat(flow-designer)!: the script node authors a function call, and nothing else (framework#4343) (#3170)
framework#4343 retired the `script` node's other dispatch branches because none of them ran: `actionType: 'email' | 'slack'` were logger-backed stubs that wrote a log line, reported success and delivered nothing under any configuration — with `template` / `recipients` / `variables` addressing a message no channel sent — inline `config.script` was recognized and never executed (no server-side JS sandbox), and any other `actionType` was a second spelling of `function`. The panel follows: `Function` (required), `Inputs` and `Output variable` are offered unconditionally, since there is no action type left to gate them behind. All five retired keys keep a legacy render-only field labelled `(retired)` with its replacement in the help text, so a stored node still shows everything it carries — the rule this group already followed for the `code` / `sms` / `notification` action types #3099 dropped. The canvas subtitle leads with the function name (falling back to the retired keys so an unmigrated node is never blank), and the simulator says what a retired branch actually did instead of pretending it mocked a notification. The reconciliation ledger spans the spec bump: on a spec that still publishes the branches it asserts only that the form offers nothing the executor ignores; on the spec that retires them — `SCRIPT_BUILTIN_ACTION_TYPES` disappearing is the discriminator — the full bidirectional comparison arms itself. Verified against a locally built framework spec: the converged panel reconciles clean both ways. Claude-Session: https://claude.ai/code/session_01Ct9NXp2JumjKuARtQnrbPf Co-authored-by: Claude <noreply@anthropic.com>
1 parent 009e25d commit b67be19

6 files changed

Lines changed: 164 additions & 96 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
Flow designer: the `script` node authors a function call, and nothing else (framework#4343).
6+
7+
**Breaking for authoring**, not for stored metadata: the `script` panel no longer
8+
offers `Action type`, `Template`, `Recipients`, `Template variables` or the inline
9+
`Code` body. What it offers is the function path — `Function` (required),
10+
`Inputs`, `Output variable` — shown unconditionally, since there is no action type
11+
left to gate them behind.
12+
13+
framework#4343 retired those five keys because none of them ran. `actionType:
14+
'email' | 'slack'` were logger-backed stubs: they wrote a log line, reported
15+
success, and delivered nothing under any configuration, with `template` /
16+
`recipients` / `variables` addressing a message no channel sent. Inline
17+
`config.script` was recognized and never executed — the built-in runtime has no
18+
server-side JS sandbox. Any other `actionType` value was a second spelling of
19+
`function`. Real delivery is a **`notify`** node (the messaging service: in-app
20+
inbox by default, email once `@objectstack/plugin-email` is installed); Slack is a
21+
**`connector_action`** with the Slack connector, or an `http` node posting to a
22+
webhook.
23+
24+
**Stored nodes are never hidden.** All five keys keep a legacy render-only field
25+
(`__legacy__` gating — the rule this group already followed for the `code` / `sms`
26+
/ `notification` action types objectui#3099 dropped), each labelled `(retired)`
27+
with its replacement in the help text. `os migrate meta --from 16` rewrites the
28+
metadata; a shorthand `actionType` moves into `function`, which is what it named.
29+
30+
The flow canvas subtitle now leads with the function name (falling back to the
31+
retired keys so an unmigrated node is never blank), and the simulator says what a
32+
retired branch actually did rather than pretending it mocked a notification.
33+
34+
The cross-repo reconciliation ledger spans the spec bump: on a spec that still
35+
publishes the retired branches it asserts only that the form offers nothing the
36+
executor ignores; on the spec that retires them (`SCRIPT_BUILTIN_ACTION_TYPES`
37+
disappearing is the discriminator) the full bidirectional comparison arms itself.
38+
Verified against a locally built framework spec: the converged panel reconciles
39+
clean in both directions.

packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ const ScriptConfigSchema = spec.ScriptConfigSchema;
4141
const SubflowConfigSchema = spec.SubflowConfigSchema;
4242
const DecisionConfigSchema = spec.DecisionConfigSchema;
4343
const DecisionConditionSchema = spec.DecisionConditionSchema;
44+
// Also the #4343 discriminator: the spec that converges `script` removes this
45+
// constant along with the dispatch branches it described.
4446
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;
4647

4748
/**
4849
* Keys a Zod object schema accepts, read straight off `.shape`.
@@ -132,20 +133,55 @@ function reconcile(type: string, zod: unknown, renderOnly: Record<string, string
132133
}
133134
}
134135

135-
describe.skipIf(!ScriptConfigSchema)('script form ↔ ScriptConfigSchema (framework#4278)', () => {
136-
it('offers exactly the executor-read keys; inline `script` stays render-only (a recognized no-op)', () => {
137-
reconcile('script', ScriptConfigSchema, {
138-
script: 'recognized but NOT executed by the built-in runtime (no server-side JS sandbox) — renders for stored nodes, steers authors to `function`',
139-
});
136+
/**
137+
* The `script` panel spans a spec bump, so it asserts what is true on EITHER
138+
* side of it (framework#4343).
139+
*
140+
* The form has converged to the one thing the node does — call a registered
141+
* function — and the five dispatch keys it used to offer are legacy render-only
142+
* here. On the spec that retires them those keys leave the contract too
143+
* (`zodKeys` drops `[REMOVED]` tombstones), so the full bidirectional ledger
144+
* applies. On the spec still installed today they are live contract keys the
145+
* form no longer offers, and only the "offers nothing the executor ignores"
146+
* direction is meaningful — asserting the other one would demand the form keep
147+
* authoring branches that never delivered anything.
148+
*
149+
* `SCRIPT_BUILTIN_ACTION_TYPES` is the discriminator: framework#4343 removes it
150+
* along with the branches it described, so this arms itself on the bump.
151+
*/
152+
const SPEC_PREDATES_SCRIPT_CONVERGENCE = SCRIPT_BUILTIN_ACTION_TYPES !== undefined;
153+
154+
describe.skipIf(!ScriptConfigSchema)('script form ↔ ScriptConfigSchema (framework#4278, #4343)', () => {
155+
it.skipIf(SPEC_PREDATES_SCRIPT_CONVERGENCE)('offers exactly the executor-read keys', () => {
156+
reconcile('script', ScriptConfigSchema);
157+
});
158+
159+
it.skipIf(!SPEC_PREDATES_SCRIPT_CONVERGENCE)(
160+
'offers nothing the executor ignores (pre-#4343 spec: the retired branches are still contract keys)',
161+
() => {
162+
const contract = zodKeys(ScriptConfigSchema);
163+
expect(
164+
offeredConfigKeys('script').filter((k) => !contract.includes(k)),
165+
'script: offered by the designer form but never read by the executor',
166+
).toEqual([]);
167+
},
168+
);
169+
170+
it('offers the function path and nothing else', () => {
171+
// The whole authorable surface, on either spec. `timeoutMs` is node-level,
172+
// so `offeredConfigKeys` (config-rooted only) does not carry it.
173+
expect(offeredConfigKeys('script')).toEqual(['function', 'inputs', 'outputVariable']);
140174
});
141175

142-
it('offers exactly the published action types: invoke_function + the built-in set', () => {
143-
const actionType = fieldsForNodeType('script').find((f) => f.id === 'actionType')!;
144-
expect(actionType.options!.map((o) => o.value).sort()).toEqual(
145-
[SCRIPT_INVOKE_FUNCTION_ACTION_TYPE!, ...SCRIPT_BUILTIN_ACTION_TYPES!].sort(),
146-
);
147-
// The default is the path that runs real logic, not the no-op.
148-
expect(actionType.defaultValue).toBe(SCRIPT_INVOKE_FUNCTION_ACTION_TYPE);
176+
it('keeps every retired key rendering for stored nodes, without offering it', () => {
177+
// Stored metadata is never hidden — the same rule that kept the inline
178+
// `script` body visible after #3099 dropped it from new authoring.
179+
for (const key of ['actionType', 'template', 'recipients', 'variables', 'script']) {
180+
const field = fieldsForNodeType('script').find((f) => f.path[0] === 'config' && f.path[1] === key);
181+
expect(field, `script: retired key '${key}' must keep a field so stored values render`).toBeDefined();
182+
expect(isLegacyGated(field!), `script: '${key}' must be legacy-gated, not offered`).toBe(true);
183+
expect(field!.help, `script: '${key}' must name its replacement`).toMatch(/[Rr]etired in spec 17/);
184+
}
149185
});
150186
});
151187

packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.test.ts

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -211,54 +211,55 @@ describe('loop / map collection is a template, not a CEL predicate', () => {
211211
});
212212
});
213213

214-
describe('script node — the form authors what the executor runs (framework#4278)', () => {
214+
describe('script node — the form authors what the executor runs (framework#4278, #4343)', () => {
215215
const fields = fieldsForNodeType('script');
216-
const actionType = fields.find((f) => f.id === 'actionType')!;
217216

218-
it('offers Call function / Email / Slack — not the broken sms / notification / no-op code', () => {
219-
// `sms` / `notification` were in no dispatch set: the executor resolved
220-
// them as function names and failed every run. `code` was a recognized
221-
// no-op (no server-side JS sandbox). The offered options now mirror the
222-
// executor's SCRIPT_BUILTIN_ACTION_TYPES + the invoke_function marker.
223-
expect(actionType.options!.map((o) => o.value)).toEqual(['invoke_function', 'email', 'slack']);
224-
expect(actionType.defaultValue).toBe('invoke_function');
225-
});
226-
227-
it('authors the function path — the one that runs real logic', () => {
217+
it('authors the function path — the only thing the node does', () => {
228218
for (const id of ['function', 'inputs', 'outputVariable']) {
229219
const f = fields.find((x) => x.id === id);
230220
expect(f, `script.${id} must be authorable`).toBeDefined();
231221
expect(f!.path).toEqual(['config', id]);
232-
// Shown by default (invoke_function is the default action type).
222+
// Unconditional now: there is no action type left to gate them behind.
223+
expect(f!.showWhen).toBeUndefined();
233224
expect(isFieldVisible(f!, { id: 's', type: 'script' }, fields)).toBe(true);
234225
}
235226
expect(fields.find((f) => f.id === 'inputs')!.kind).toBe('keyValue');
236227
});
237228

238-
it('gates the builtin side-effect fields to email / slack', () => {
239-
const template = fields.find((f) => f.id === 'template')!;
240-
expect(template.showWhen).toEqual({ field: 'actionType', equals: ['email', 'slack'] });
241-
expect(isFieldVisible(template, { id: 's', type: 'script', config: { actionType: 'slack' } }, fields)).toBe(true);
242-
expect(isFieldVisible(template, { id: 's', type: 'script' }, fields)).toBe(false);
229+
it('offers nothing else under config — the retired branches are not authorable', () => {
230+
// framework#4343: `actionType`'s built-in side effects were logger-backed
231+
// stubs that delivered nothing, inline `script` was never executed, and any
232+
// other action type was a second spelling of `function`.
233+
const offered = fields
234+
.filter((f) => f.path[0] === 'config' && f.showWhen?.field !== '__legacy__')
235+
.map((f) => f.id);
236+
expect(offered).toEqual(['function', 'inputs', 'outputVariable']);
243237
});
244238

245239
it('drops the dead plural outputVariables field (declared-but-unread — nothing ever bound it)', () => {
246240
expect(fields.find((f) => f.id === 'outputVariables')).toBeUndefined();
247241
});
248242

249-
it('keeps the inline script body render-only: hidden for new nodes, visible when stored', () => {
250-
const script = fields.find((f) => f.id === 'script')!;
251-
expect(script.showWhen).toEqual({ field: '__legacy__', equals: [] });
252-
expect(isFieldVisible(script, { id: 's', type: 'script' }, fields)).toBe(false);
253-
expect(
254-
isFieldVisible(script, { id: 's', type: 'script', config: { script: 'return 1;' } }, fields),
255-
).toBe(true);
256-
});
257-
258-
it('a stored legacy sms node still renders its builtin fields (stored values are never hidden)', () => {
259-
const template = fields.find((f) => f.id === 'template')!;
260-
const node = { id: 's', type: 'script', config: { actionType: 'sms', template: 'notify_owner' } };
261-
expect(isFieldVisible(template, node, fields)).toBe(true);
243+
it.each(['actionType', 'template', 'recipients', 'variables', 'script'])(
244+
'keeps retired `%s` render-only: hidden for new nodes, visible when stored',
245+
(id) => {
246+
const field = fields.find((f) => f.id === id)!;
247+
expect(field, `script.${id} must still render stored values`).toBeDefined();
248+
expect(field.showWhen).toEqual({ field: '__legacy__', equals: [] });
249+
expect(isFieldVisible(field, { id: 's', type: 'script' }, fields)).toBe(false);
250+
expect(
251+
isFieldVisible(field, { id: 's', type: 'script', config: { [id]: 'stored' } }, fields),
252+
).toBe(true);
253+
},
254+
);
255+
256+
it('a stored legacy email node still renders everything it carries', () => {
257+
// Stored values are never hidden — the rule that already covered the
258+
// `code` / `sms` / `notification` action types #3099 dropped.
259+
const node = { id: 's', type: 'script', config: { actionType: 'email', template: 'notify_owner' } };
260+
for (const id of ['actionType', 'template']) {
261+
expect(isFieldVisible(fields.find((f) => f.id === id)!, node, fields), id).toBe(true);
262+
}
262263
});
263264
});
264265

packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts

Lines changed: 33 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -480,70 +480,55 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
480480
cfg('outputVariable', 'Output variable', 'text', { placeholder: 'response' }),
481481
{ id: 'timeoutMs', path: ['timeoutMs'], label: 'Timeout (ms)', kind: 'number', placeholder: '30000' },
482482
],
483-
// Script — a callable step (framework#1870): call a registered function
484-
// (`function` + `inputs` + `outputVariable` — the only path that runs real
485-
// logic), or one of the executor's built-in side effects (email / slack —
486-
// `template` / `recipients` / `variables`). The offered options mirror the
487-
// spec's `SCRIPT_BUILTIN_ACTION_TYPES` + the `invoke_function` marker, and
488-
// the whole group is reconciled against the spec-published
489-
// `ScriptConfigSchema` (framework#4278) — before that reconciliation this
490-
// group offered `sms` / `notification` (fail every run: neither is built in,
491-
// so they resolve as function names), defaulted to `code` (a recognized
492-
// no-op: the built-in runtime has no server-side JS sandbox), declared an
493-
// `outputVariables` list nothing reads (the executor binds the singular
494-
// `outputVariable`), and could not author the function path at all.
483+
// Script — one thing: call a registered function (framework#1870).
484+
// `function` + `inputs` + `outputVariable` is the whole authorable surface,
485+
// reconciled against the spec-published `ScriptConfigSchema` (framework#4278).
495486
//
496-
// A stored legacy node still renders completely: unknown `actionType` values
497-
// (`code` / `sms` / `notification`) show as "(deprecated)" select options,
498-
// and the `script` body / builtin fields surface whenever they hold a value
499-
// (stored values are never hidden).
487+
// framework#4343 retired the other dispatch branches, and the form follows.
488+
// None of them ran: `actionType: 'email' | 'slack'` were logger-backed stubs
489+
// that reported success and delivered nothing (with `template` / `recipients`
490+
// / `variables` addressing a message no channel sent), inline `script` was
491+
// never executed (the built-in runtime has no server-side JS sandbox), and
492+
// any other `actionType` was a second spelling of `function`. Real delivery
493+
// is a `notify` node; Slack is a connector (or an `http` webhook).
494+
//
495+
// The five keys stay as legacy render-only fields (`__legacy__` never
496+
// matches, so they are never OFFERED) because a stored node must still show
497+
// everything it carries — the rule this group already followed for the
498+
// `code` / `sms` / `notification` action types #3099 dropped. Each carries
499+
// the replacement in its help text; `os migrate meta --from 16` rewrites the
500+
// stored metadata.
500501
script: [
501-
cfg('actionType', 'Action type', 'select', {
502-
options: [
503-
{ value: 'invoke_function', label: 'Call function' },
504-
{ value: 'email', label: 'Email' },
505-
{ value: 'slack', label: 'Slack' },
506-
],
507-
defaultValue: 'invoke_function',
508-
help: 'How this step runs. "Call function" invokes a registered function — the path that runs real logic.',
509-
}),
510502
cfg('function', 'Function', 'text', {
511503
placeholder: 'score_lead',
512-
help: 'Registered function to call — declared via defineStack({ functions }). Always wins over Action type.',
513-
showWhen: { field: 'actionType', equals: ['invoke_function'] },
504+
help: 'Registered function to call — declared via defineStack({ functions }). Required: it is what this step runs.',
514505
}),
515506
cfg('inputs', 'Inputs', 'keyValue', {
516507
help: 'Values passed to the function; {var} references resolve against the live flow variables.',
517-
showWhen: { field: 'actionType', equals: ['invoke_function'] },
518508
}),
519509
cfg('outputVariable', 'Output variable', 'text', {
520510
placeholder: 'aiResult',
521511
help: "Flow variable the function's return value is bound to, for later steps.",
522-
showWhen: { field: 'actionType', equals: ['invoke_function'] },
523512
}),
524-
cfg('template', 'Template', 'reference', {
525-
// Polymorphic: an email step picks from the email-template catalog; slack
526-
// has no flat catalog yet, so it degrades to free text.
527-
ref: { kindFrom: 'actionType', map: { email: 'email-template' } },
528-
placeholder: 'case_escalated',
529-
help: 'Message template id.',
530-
showWhen: { field: 'actionType', equals: ['email', 'slack'] },
513+
cfg('actionType', 'Action type (retired)', 'text', {
514+
help: 'Retired in spec 17 — "email"/"slack" never delivered anything, and any other value was just the function name. Use a notify node for messages, a Slack connector for Slack, or move the name into Function.',
515+
showWhen: { field: '__legacy__', equals: [] },
531516
}),
532-
cfg('recipients', 'Recipients', 'stringList', {
533-
help: 'One recipient per row (user id, field ref, or address).',
534-
showWhen: { field: 'actionType', equals: ['email', 'slack'] },
517+
cfg('template', 'Template (retired)', 'text', {
518+
help: 'Retired in spec 17 — it fed a side effect that never rendered or sent a message. A notify node carries its own title/message.',
519+
showWhen: { field: '__legacy__', equals: [] },
520+
}),
521+
cfg('recipients', 'Recipients (retired)', 'stringList', {
522+
help: 'Retired in spec 17 — these addresses were logged, never messaged. Use a notify node, whose recipients reach the messaging service.',
523+
showWhen: { field: '__legacy__', equals: [] },
535524
}),
536-
cfg('variables', 'Template variables', 'keyValue', {
537-
help: 'Values injected into the template.',
538-
showWhen: { field: 'actionType', equals: ['email', 'slack'] },
525+
cfg('variables', 'Template variables (retired)', 'keyValue', {
526+
help: 'Retired in spec 17 — injected into a template nothing rendered. A notify node carries structured data in payload.',
527+
showWhen: { field: '__legacy__', equals: [] },
539528
}),
540-
// Legacy render-only (`__legacy__` never matches): the built-in runtime
541-
// does NOT execute inline script bodies (no server-side JS sandbox — the
542-
// executor warns and completes as a no-op), so the field is not offered
543-
// for new authoring; a stored body still renders so nothing is hidden.
544-
cfg('script', 'Code (not executed)', 'textarea', {
529+
cfg('script', 'Code (not executed, retired)', 'textarea', {
545530
placeholder: 'return { ok: true };',
546-
help: 'Inline scripts are NOT executed by the built-in runtime — this node is a no-op. Move the logic into a registered function and use "Call function".',
531+
help: 'Retired in spec 17 — inline scripts were NEVER executed by the built-in runtime (no server-side sandbox). Move the logic into a registered function and name it in Function.',
547532
refMode: 'expression',
548533
showWhen: { field: '__legacy__', equals: [] },
549534
}),

packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -950,7 +950,10 @@ function nodeSummary(node: FlowDesignerNode): string | undefined {
950950
return pick('condition');
951951
}
952952
if (node.type === 'script') {
953-
return pick('actionType') || pick('template') || (c && c.script ? 'code' : undefined);
953+
// The function IS the step (framework#4343). The rest are retired keys a
954+
// stored node may still carry — kept as fallbacks so its subtitle is never
955+
// blank before someone migrates it.
956+
return pick('function') || pick('actionType') || pick('template') || (c && c.script ? 'code' : undefined);
954957
}
955958
if (node.type === 'approval') {
956959
const approvers = c?.approvers;

packages/app-shell/src/views/metadata-admin/previews/simulator/flow-simulator.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,12 +407,16 @@ export class FlowSimulator {
407407

408408
private mockNote(node: SimNode): string {
409409
if (node.type === 'script') {
410+
// A script node calls a registered function and nothing else
411+
// (framework#4343) — the branches below describe a stored node that has
412+
// not been migrated yet, and say plainly that they never ran.
413+
const fn = str(node.config?.function);
414+
if (fn) return `Mocked call to '${fn}' (no function executed).`;
410415
const action = str(node.config?.actionType);
411416
if (action && action !== 'code') {
412-
const recips = Array.isArray(node.config?.recipients) ? (node.config!.recipients as unknown[]).length : 0;
413-
return `Mocked ${action} notification${recips ? ` to ${recips} recipient(s)` : ''}.`;
417+
return `Retired '${action}' action — it never delivered anything; use a notify node (or a connector for Slack).`;
414418
}
415-
return 'Mocked code script (no real code executed).';
419+
return 'Retired inline script — the runtime never executed it; move the logic into a registered function.';
416420
}
417421
return `Mocked ${node.type.replace(/_/g, ' ')} (no backend call).`;
418422
}

0 commit comments

Comments
 (0)