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
36 changes: 36 additions & 0 deletions .changeset/script-node-form-authors-what-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@object-ui/app-shell": minor
---

feat(flow-designer): the script node's form authors what the executor runs — framework#4278

The `script` flow node is one of five builtins whose designer form lives only
in this package's hand-written `FLOW_NODE_CONFIG` table (the engine publishes
no `configSchema` for them, deliberately), and nothing reconciled that table
against the executor. It had drifted user-visibly: of the four `actionType`
options offered, `code` was a recognized no-op (the built-in runtime has no
server-side JS sandbox) and `sms` / `notification` failed every run (neither
is a built-in — they resolve as function names); the "Output variables"
(plural) field was read by nothing; and the one path that runs real logic —
`function` + `inputs` + `outputVariable` — could not be authored at all.

- The `actionType` select now offers **Call function** (default) / **Email** /
**Slack**, mirroring the executor's dispatch set
(`SCRIPT_BUILTIN_ACTION_TYPES` + the `invoke_function` marker). The function
path fields (`function`, `inputs`, `outputVariable`) are first-class.
- The inline `script` body becomes render-only: hidden for new nodes (its help
states it is NOT executed and steers to a registered function), still shown
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 now renders
it as a flagged "`<value> (deprecated)`" entry instead of blanking it —
the same rule FlowObjectListField already applied to select cells.
- The data picker (`flow-scope`) 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: the hand-written `script` / `subflow` / `decision`
groups are compared bidirectionally against the executor-derived config
contracts `@objectstack/spec/automation` publishes for exactly this purpose
(framework#4278), and the `wait` / `connector_action` / `boundary_event`
groups against the `FlowNodeSchema` sibling blocks. The spec-export panels
feature-detect and arm themselves on the next `@objectstack/spec` bump.
8 changes: 5 additions & 3 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,13 +442,15 @@ be faithfully modelled is surfaced loudly instead of faked.
free-form **Set variables** editor that injects/overrides *any* variable at
start, so **every branch is reachable**. A **Mock outputs** editor lets the
author pin what each mocked side-effect node "returns" (written to its
`outputVariable` / `outputVariables`), so data-dependent logic downstream of a
`get_record` or `script` can be exercised too.
`outputVariable`), so data-dependent logic downstream of a `get_record` or
`script` can be exercised too.
- **Semantics** — `start`/`assignment` pass through; a `decision` routes
**edge-first** (first truthy outgoing `condition`, else the `isDefault` edge,
else a surfaced dead-end), evaluating CEL via `@object-ui/core`'s
`ExpressionEvaluator` and **surfacing eval errors** (not swallowing them);
side-effect nodes write their mock to `outputVariable` / `outputVariables[]`;
side-effect nodes write their mock to `outputVariable` (the legacy script
`outputVariables[]` list is ignored — the engine never binds those names,
framework#4278);
`wait` and `screen` **pause** for manual continue; `join_gateway`, `subflow`,
and `boundary_event` are marked **unsupported** (token sync / nested runs are
not modelled) rather than faked.
Expand Down
13 changes: 9 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3420,14 +3420,19 @@ const FLOW_FIELD_ZH: Record<string, Record<string, FlowFieldZh>> = {
script: {
actionType: {
label: '动作类型',
help: '本步骤如何运行。原始脚本保持为“代码”。',
opts: { code: '代码', email: '邮件', sms: '短信', notification: '通知' },
help: '本步骤如何运行。“调用函数”会调用已注册的函数 —— 真正执行逻辑的路径。',
opts: { invoke_function: '调用函数', email: '邮件', slack: 'Slack' },
},
function: { label: '函数', help: '要调用的已注册函数 —— 经 defineStack({ functions }) 声明。优先于动作类型。' },
inputs: { label: '输入', help: '传给函数的值;{var} 引用会按流程变量解析。' },
outputVariable: { label: '输出变量', help: '绑定函数返回值的流程变量,供后续步骤使用。' },
template: { label: '模板', help: '消息模板 id。' },
recipients: { label: '收件人', help: '每行一个收件人(用户 id、字段引用或地址)。' },
variables: { label: '模板变量', help: '注入模板的值。' },
script: { label: '代码', help: '脚本主体(JS/TS)。' },
outputVariables: { label: '输出变量', help: '此脚本写回的变量名。' },
script: {
label: '代码(不执行)',
help: '内置运行时不执行内联脚本 —— 此节点为 no-op。请把逻辑移入已注册函数并使用“调用函数”。',
},
timeoutMs: { label: '超时(毫秒)' },
},
screen: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,30 @@ describe('FlowNodeConfigField — expression vs template validation gating', ()
expect(screen.queryByRole('note')).toBeNull();
});
});

describe('FlowNodeConfigField — select keeps a stored value dropped from the options (framework#4278)', () => {
const ACTION_TYPE: FlowConfigField = {
id: 'actionType',
path: ['config', 'actionType'],
label: 'Action type',
kind: 'select',
options: [
{ value: 'invoke_function', label: 'Call function' },
{ value: 'email', label: 'Email' },
{ value: 'slack', label: 'Slack' },
],
};

it('renders a legacy stored value as a flagged "(deprecated)" option instead of blanking it', () => {
// A stored script node with the retired `sms` actionType must keep showing
// that value — the same rule FlowObjectListField applies to select cells.
render(<FlowNodeConfigField field={ACTION_TYPE} value="sms" onCommit={() => {}} />);
expect(screen.getByText('sms (deprecated)')).toBeInTheDocument();
});

it('offers only the declared options when the stored value is one of them', () => {
render(<FlowNodeConfigField field={ACTION_TYPE} value="email" onCommit={() => {}} />);
expect(screen.getByText('Email')).toBeInTheDocument();
expect(screen.queryByText(/deprecated/)).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,28 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
/>
);
case 'select':
return (
<InspectorSelectField
label={field.label}
value={value != null ? String(value) : ''}
options={field.options ?? []}
onCommit={(v) => onCommit(v)}
disabled={disabled}
/>
);
return (() => {
const current = value != null ? String(value) : '';
const opts = field.options ?? [];
// A stored value dropped from the options (e.g. a script node's
// legacy `code` / `sms` actionType, framework#4278) must still
// render, or editing a legacy node would silently blank it. Surface
// it as selectable but flag it — it is not offered to fresh nodes.
// Same rule as FlowObjectListField's select cells (ADR-0090 D3).
const shown =
current && !opts.some((o) => o.value === current)
? [...opts, { value: current, label: `${current} (deprecated)` }]
: opts;
return (
<InspectorSelectField
label={field.label}
value={current}
options={shown}
onCommit={(v) => onCommit(v)}
disabled={disabled}
/>
);
})();
case 'textarea':
return (
<div className="space-y-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/**
* FlowStringListField — a repeatable single-column editor for string-array
* config (e.g. a notification's `recipients`, a script's `outputVariables`).
* config (e.g. a notification's `recipients` or `channels`).
*
* Mirrors FlowKeyValueField's local-draft pattern: rows live in LOCAL state
* with a STABLE id and only flush to `onCommit` on blur / Enter / add / remove,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* **Hand-written `FLOW_NODE_CONFIG` ↔ spec-published executor contracts**
* (framework#4278).
*
* Five builtins deliberately publish no descriptor `configSchema` (framework
* `config-schemas.test.ts`): the schema-driven online form cannot express
* their editors — decision's virtual Target column, script's
* actionType-conditional groups, the spec-structured sibling blocks. Their
* Studio form is therefore this package's hand-written table, and until #4278
* nothing reconciled that table against what the executors actually read.
* `script` had drifted user-visibly: an `outputVariables` field nothing reads,
* `sms` / `notification` options that fail every run, a no-op `code` default,
* and no way to author the `function` / `inputs` / `outputVariable` path that
* works.
*
* The machine-readable half now lives in `@objectstack/spec/automation`:
* executor-derived config Zods for `script` / `subflow` / `decision`
* (`schemaless-node-config.zod.ts`) plus the `FlowNodeSchema` sibling blocks
* for `wait` / `connector_action` / `boundary_event`. This file is the
* objectui half of the ledger — the same bidirectional key-set comparison
* service-automation's `builtin-node-form-zod-ledger.test.ts` performs for the
* descriptor-schema'd builtins, carried across the repo seam by the
* `@objectstack/spec` dependency this package already has.
*
* The script/subflow/decision panels feature-detect their spec exports and
* skip while the installed spec predates them (they arm themselves on the next
* `@objectstack/spec` bump — see the version-alignment section of AGENTS.md);
* the sibling-block panels run against every spec version this repo supports.
*/

import { describe, it, expect } from 'vitest';
import * as Automation from '@objectstack/spec/automation';
import { fieldsForNodeType, type FlowConfigField } from './flow-node-config';

// Feature-detected exports — absent on a spec that predates framework#4278.
// (Truthiness alone never resolves a lazySchema proxy.)
const spec = Automation as Record<string, unknown>;
const ScriptConfigSchema = spec.ScriptConfigSchema;
const SubflowConfigSchema = spec.SubflowConfigSchema;
const DecisionConfigSchema = spec.DecisionConfigSchema;
const DecisionConditionSchema = spec.DecisionConditionSchema;
const SCRIPT_BUILTIN_ACTION_TYPES = spec.SCRIPT_BUILTIN_ACTION_TYPES as readonly string[] | undefined;
const SCRIPT_INVOKE_FUNCTION_ACTION_TYPE = spec.SCRIPT_INVOKE_FUNCTION_ACTION_TYPE as string | undefined;

/**
* Keys a Zod object schema accepts, read straight off `.shape`.
*
* `retiredKey()` tombstones are excluded: a retired key stays in the shape so
* its rejection carries the upgrade prescription (spec `shared/retired-key.ts`
* marks it with a `[REMOVED]` description), but it is NOT part of the
* authorable contract — a form field writing one produces metadata the loader
* rejects. Counting tombstones as contract keys would false-pass exactly that
* drift (e.g. `waitEventConfig.timeoutMs` / `.onTimeout`, retired at spec 18
* by framework#4198 — this filter is what makes the `wait` panel fire on that
* bump until the form drops the two fields).
*/
function zodKeys(schema: unknown): string[] {
const shape = (schema as { shape?: Record<string, unknown> }).shape;
expect(shape, 'expected a Zod object schema exposing .shape').toBeDefined();
return Object.keys(shape ?? {})
.filter((k) => {
const description = (shape![k] as { description?: string } | undefined)?.description;
return !(typeof description === 'string' && description.startsWith('[REMOVED]'));
})
.sort();
}

/** Unwrap `.optional()` / `.default()` wrappers down to the object schema. */
function unwrapped(schema: unknown): unknown {
let cur = schema as { shape?: unknown; unwrap?: () => unknown } | undefined;
for (let i = 0; cur && !cur.shape && typeof cur.unwrap === 'function' && i < 5; i++) {
cur = cur.unwrap() as typeof cur;
}
return cur;
}

/** A field render-gated behind the never-matching `__legacy__` controller. */
function isLegacyGated(f: FlowConfigField): boolean {
return f.showWhen?.field === '__legacy__';
}

/** Config keys the form OFFERS for new authoring (legacy render-only excluded). */
function offeredConfigKeys(type: string): string[] {
return [...new Set(
fieldsForNodeType(type)
.filter((f) => f.path[0] === 'config' && !isLegacyGated(f))
.map((f) => f.path[1]!),
)].sort();
}

/**
* Reconcile one node type's config-rooted form keys against its executor
* contract. `renderOnly` names contract keys the form deliberately does NOT
* offer for new authoring — each must still be present as a legacy-gated
* field so stored metadata keeps rendering, and each needs its reason here.
*/
function reconcile(type: string, zod: unknown, renderOnly: Record<string, string> = {}) {
const offered = offeredConfigKeys(type);
const contract = zodKeys(zod);

// Read by the executor, absent from the form ⇒ authorable only by hand —
// the exact shape #4278 found for script's function/inputs/outputVariable.
expect(
contract.filter((k) => !offered.includes(k) && !(k in renderOnly)),
`${type}: read by the executor but not offered by the designer form`,
).toEqual([]);

// Offered by the form, never read by the executor ⇒ a Studio field that
// does nothing — the #3528 / outputVariables shape.
expect(
offered.filter((k) => !contract.includes(k)),
`${type}: offered by the designer form but never read by the executor`,
).toEqual([]);

// Every render-only exemption must (a) be part of the executor contract and
// (b) still render for stored metadata via a legacy-gated field.
for (const key of Object.keys(renderOnly)) {
expect(contract, `${type}: render-only exemption '${key}' must be a contract key`).toContain(key);
const field = fieldsForNodeType(type).find((f) => f.path[0] === 'config' && f.path[1] === key);
expect(field, `${type}: render-only key '${key}' must keep a (legacy-gated) field`).toBeDefined();
expect(isLegacyGated(field!), `${type}: '${key}' must be legacy-gated, not offered`).toBe(true);
}
}

describe.skipIf(!ScriptConfigSchema)('script form ↔ ScriptConfigSchema (framework#4278)', () => {
it('offers exactly the executor-read keys; inline `script` stays render-only (a recognized no-op)', () => {
reconcile('script', ScriptConfigSchema, {
script: 'recognized but NOT executed by the built-in runtime (no server-side JS sandbox) — renders for stored nodes, steers authors to `function`',
});
});

it('offers exactly the published action types: invoke_function + the built-in set', () => {
const actionType = fieldsForNodeType('script').find((f) => f.id === 'actionType')!;
expect(actionType.options!.map((o) => o.value).sort()).toEqual(
[SCRIPT_INVOKE_FUNCTION_ACTION_TYPE!, ...SCRIPT_BUILTIN_ACTION_TYPES!].sort(),
);
// The default is the path that runs real logic, not the no-op.
expect(actionType.defaultValue).toBe(SCRIPT_INVOKE_FUNCTION_ACTION_TYPE);
});
});

describe.skipIf(!SubflowConfigSchema)('subflow form ↔ SubflowConfigSchema (framework#4278)', () => {
it('offers exactly the executor-read keys', () => {
reconcile('subflow', SubflowConfigSchema);
});
});

describe.skipIf(!DecisionConfigSchema)('decision form ↔ DecisionConfigSchema (framework#4278)', () => {
it('offers exactly the executor-read keys (legacy single `condition` stays render-only)', () => {
// `condition` (singular) is legacy-gated in the form and deliberately NOT
// in the contract: the decision executor never reads it — branching lives
// in `conditions[]` or on edge conditions. Legacy-gated fields are already
// excluded from `offered`, so plain reconciliation covers it.
reconcile('decision', DecisionConfigSchema);
});

it('branch columns match DecisionConditionSchema one level down (Target is virtual)', () => {
const conditions = fieldsForNodeType('decision').find((f) => f.id === 'conditions')!;
const columnKeys = conditions.columns!.map((c) => c.key).sort();
const contract = zodKeys(DecisionConditionSchema);
// `target` is a VIRTUAL column — projected from / applied to the out-edges
// by flow-decision-edges, never stored on the branch — so it is the one
// legitimate column the stored-shape contract does not carry.
expect(columnKeys.filter((k) => k !== 'target')).toEqual(contract);
expect(columnKeys).toContain('target');
});
});

describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet)', () => {
// These blocks are published by every spec version this repo supports, so
// no feature detection: the hand-written groups for wait / connector_action
// / boundary_event must edit exactly the keys the spec block declares —
// #4161 / #4210 verified them by hand once; this keeps them verified.
const FlowNodeSchema = spec.FlowNodeSchema as { shape: Record<string, unknown> };

const BLOCKS: ReadonlyArray<{ type: string; block: string }> = [
{ type: 'wait', block: 'waitEventConfig' },
{ type: 'connector_action', block: 'connectorConfig' },
{ type: 'boundary_event', block: 'boundaryConfig' },
];

it.each(BLOCKS)('$type: the $block fields match the spec block exactly', ({ type, block }) => {
const formKeys = [...new Set(
fieldsForNodeType(type)
.filter((f) => f.path[0] === block)
.map((f) => f.path[1]!),
)].sort();
const blockKeys = zodKeys(unwrapped(FlowNodeSchema.shape[block]));

expect(
blockKeys.filter((k) => !formKeys.includes(k)),
`${type}: declared by the spec block but absent from the designer form`,
).toEqual([]);
expect(
formKeys.filter((k) => !blockKeys.includes(k)),
`${type}: edited by the designer form but not declared by the spec block`,
).toEqual([]);
});
});
Loading
Loading