Skip to content

Commit fd3013a

Browse files
os-zhuangclaude
andauthored
feat(spec,automation)!: converge script to a function call and parse script/subflow config at execute time (#4343) (#4516)
* feat(spec,automation)!: converge `script` to a function call and parse script/subflow config at execute time (#4343) A `script` node had four ways to name what it ran and only one of them ran anything. `actionType: 'email' | 'slack'` were logger-backed stubs that wrote a 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). Every other `actionType` value was shorthand for a registered-function name, and `'invoke_function'` was a marker that named nothing on its own. All five keys are tombstoned (`retiredKey`) and `config.function` becomes required, which is also what made the contract parseable: while the legal key set depended on `actionType`, a flat parse would either reject valid shapes or wave everything through. `script` and `subflow` now run their config through the execute-time contract parse #4277 gave the flat builtins — a violation refuses the node as a guard, un-routable by a `fault` edge (#3863). `decision` stays export-only: its one key is optional, so a parse would check nothing. The ADR-0087 D2 conversion `flow-node-script-branch-keys-removed` rewrites stored sources — a shorthand `actionType` moves into `function` (that is what it named) unless `function` already won; the other keys drop, nothing having read them. Retired from the load path with the rest of the keys retired for misdescribing themselves, so `os migrate meta --from 16` is what rewrites an authored source. `registerFlow` still replays it (#3903 — a stored row has no author to teach), so an old email-stub node arrives stripped and then refuses for naming no callable, where it used to report success. Also: the `SCRIPT_BUILTIN_ACTION_TYPES` / `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` constants and `ScriptBuiltinActionType` are removed; `os validate` names a retired key and its replacement; the examples move to `notify` (real delivery) and `http` (Slack webhook), and the showcase gains a registered function so its `script` node demonstrates the one form that works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ct9NXp2JumjKuARtQnrbPf * fix(spec): accept a lowered handler ref in `functions`, so `defineStack({ functions })` survives a build (#4343) `objectstack build` lowers every inline callable to a serialisable string ref BEFORE the stack is parsed — it must, since `z.function()` wraps callables and would break the ref mapping — so a built manifest holds `{ myFn: 'myFn' }`. `FlowFunctionEntrySchema` accepted only a function or a `{ handler, effect }` declaration, so the parse rejected what the build had just produced: a documented, first-class authoring mechanism could not survive a build. Nothing had noticed because no bundled example used `functions`. #4343 turns that from latent into blocking: `config.function` becomes the only thing a `script` node runs, so registering one is now mandatory for any app with a script node — which is what the showcase demo in this branch hit. `Hook.handler` already declared exactly this pair (a string post-build, an inline function pre-build), so this puts `functions` on the platform's existing shape rather than a new one. A string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the real functions ride in the sibling ESM module the build emits and are merged by name — so hand-authoring one registers nothing and fails loudly at execute rather than silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ct9NXp2JumjKuARtQnrbPf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 061406d commit fd3013a

28 files changed

Lines changed: 1209 additions & 410 deletions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': minor
4+
'@objectstack/lint': patch
5+
---
6+
7+
feat(spec,automation)!: converge `script` to a function call — retire the `actionType` branches — and parse `script` / `subflow` config at execute time (#4343)
8+
9+
A `script` node had four ways to name what it ran and only one of them ran anything.
10+
Protocol 17 keeps that one and retires the rest.
11+
12+
- **`config.actionType: 'email' | 'slack'`** were **logger-backed stubs**. They wrote a
13+
line, reported success, and delivered nothing — under any configuration, installed
14+
messaging service or not. Every bundled example used one; none of them ever sent
15+
anything.
16+
- **`config.template` / `.recipients` / `.variables`** fed those stubs, so they addressed
17+
a message no channel sent. (The examples did not even reach them: they passed the
18+
payload in `inputs`, which the built-in branch never read.)
19+
- **inline `config.script`** was recognized and **never executed** — the built-in runtime
20+
has no server-side JS sandbox, so the node warned and completed as a no-op.
21+
- **any other `actionType`** was shorthand for a registered-function name — a second
22+
spelling of `config.function` — and `'invoke_function'` was a marker that named nothing
23+
on its own.
24+
25+
What remains is what worked: `config.function` (now **required**) names a registered
26+
function, `config.inputs` feeds it, `config.outputVariable` binds its return value.
27+
28+
**The replacements are three different mechanisms, not one rename.**
29+
30+
| Retired | Use instead |
31+
| --- | --- |
32+
| `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a `notify` node — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed |
33+
| `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook — `notify` has no Slack channel |
34+
| `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you |
35+
| `script: '…'` (inline JS) | move the logic into a registered function and call it via `config.function` |
36+
37+
**Execute-time parse.** `script` and `subflow` now run their config through the contract
38+
before executing, the seam #4277 gave the flat builtins — a violation refuses the node as
39+
a **guard** (wrong metadata; no `fault` edge may route it, #3863). `script` could not join
40+
that seam while its legal key set depended on `actionType`: a flat parse would either
41+
reject valid shapes or wave everything through. Converging the node is what made the
42+
contract fit. `subflow`'s hand-written `flowName` check became the same parse, so its
43+
message is now `subflow 'n1': config does not satisfy the subflow contract —
44+
config.flowName: …`. `decision` deliberately stays export-only: its one key is optional,
45+
so a parse would check nothing.
46+
47+
**Migration.** `os migrate meta --from 16` rewrites stored sources; authoring one of these
48+
keys in TypeScript is a compile error carrying the same prescription. A shorthand
49+
`actionType` **converts into `function`** — that is what it named — unless `function` is
50+
already set, in which case it was dead metadata the executor never reached. The other four
51+
keys are dropped outright: nothing read them, so there is no value to preserve, and
52+
rebuilding the intent is an authoring decision (the table above) rather than something a
53+
mechanical rewrite can guess.
54+
55+
The keys leave the **load path** (`retiredFromLoadPath`) with the rest of the keys retired
56+
for *misdescribing themselves* rather than for being renamed: absorbing
57+
`actionType: 'email'` silently would let an author keep believing the flow sends mail. The
58+
one seam that still replays it is `registerFlow`, which rehydrates data at rest (#3903) —
59+
a row in `sys_metadata` has no author for a tombstone to teach. So a stored email-stub node
60+
arrives stripped of the keys nothing read and then **refuses for naming no callable**,
61+
where it used to log a line and report success. That flip is the behavior change to expect.
62+
63+
**A build gap this surfaced, fixed here.** `FlowFunctionEntrySchema` now also accepts a
64+
**lowered handler ref** (a non-empty string), the form `objectstack build` produces: the
65+
CLI lowers every inline callable to a serialisable ref *before* the stack is parsed (it
66+
must — `z.function()` wraps callables and would break the ref mapping), so a built
67+
manifest holds `{ myFn: 'myFn' }`, which neither previous member accepted. The result was
68+
that `defineStack({ functions })` — a documented, first-class mechanism — could not
69+
survive a build at all. Nothing had noticed because no bundled example used it; #4343
70+
turns that from latent into blocking, since `config.function` becomes the only thing a
71+
`script` node can run. `Hook.handler` already declared exactly this pair (`z.union([
72+
z.string(), <function> ])`, "string, post-build / inline function, pre-build"), so this
73+
brings `functions` onto the platform's established shape rather than inventing one. A
74+
string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the
75+
real functions ride in the sibling ESM module the build emits, merged by name — so
76+
hand-authoring one registers nothing and fails loudly at execute ("no function named '…'
77+
is registered"), never silently.
78+
79+
Also in this change: the retired constants `SCRIPT_BUILTIN_ACTION_TYPES`,
80+
`SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` and the `ScriptBuiltinActionType` type are removed
81+
(they described the dispatch set that no longer exists); `os validate` names a retired key
82+
and its replacement instead of reporting a generic missing callable; and the `#3796`
83+
alias fixture, which carried `actionType: 'invoke_function'` through both sides, no longer
84+
describes an end state protocol 17 can reach — the rename itself is untouched. No liveness
85+
ledger row moves: the gate walks `FlowSchema`, whose `nodes[].config` is
86+
`z.record(z.unknown())`, so these keys were never governed by one.

content/docs/automation/flows.mdx

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Each node performs a specific action in the flow.
113113
| `get_record` | Query records |
114114
| `http` | Make an HTTP API call |
115115
| `notify` | Send an outbound notification via the messaging service |
116-
| `script` | Call a named callable — a registered function (`config.function`) or a built-in side-effect marker (`config.actionType`) |
116+
| `script` | Call a registered function named by `config.function` |
117117
| `screen` | Display a user form/screen (durable pause) |
118118
| `wait` | Pause for a timer or named signal (durable pause; timers auto-resume) |
119119
| `subflow` | Invoke another flow — a pause inside the child suspends both runs as a linked chain |
@@ -214,25 +214,40 @@ or missing-`required` violation (#4277). A node type that publishes no
214214
**Script:**
215215

216216
The built-in `script` executor never evaluates an arbitrary JavaScript string —
217-
it **names a callable**. Two forms:
218-
219-
- **`config.function`** — a function registered through
220-
`defineStack({ functions })`. `config.inputs` is `{var}`-interpolated and
221-
handed to it; `config.outputVariable` binds the returned value as a flow
222-
variable, so a later declarative node persists it. This is the supported way
223-
to run server logic.
224-
- **`config.actionType`** — one of the two built-in side-effect markers,
225-
`'email'` or `'slack'`. These are **logger-backed**: they record the intent
226-
and succeed, they do not deliver anything — reach for a `notify` node when you
227-
want real delivery. Any other `actionType` value is treated as a
228-
registered-function name — except the marker `'invoke_function'`, which means
229-
"call the function named in `config.function`" and errors if that key is
230-
missing.
231-
232-
Inline `config.script` (a JS source body) is *recognized* but **not executed**
233-
the built-in runtime has no server-side JS sandbox, so such a node warns and
234-
no-ops. A script node that names neither a built-in action nor a registered
235-
function fails the step loudly rather than passing silently.
217+
it **calls a registered function**, and that is the whole of what it does.
218+
219+
**`config.function`** names a function registered through
220+
`defineStack({ functions })`, and it is **required**. `config.inputs` is
221+
`{var}`-interpolated and handed to it; `config.outputVariable` binds the
222+
returned value as a flow variable, so a later declarative node persists it. A
223+
node that names no function refuses before it runs; one naming a function
224+
nothing registered fails the step loudly rather than passing silently.
225+
226+
<Callout type="warn" title="The other dispatch forms were retired in spec 17">
227+
228+
`config.actionType`, `config.template`, `config.recipients`, `config.variables`
229+
and inline `config.script` were removed in `@objectstack/spec` 17 ([#4343]).
230+
None of them ran: the `'email'` / `'slack'` action types were **logger-backed
231+
stubs** that recorded the intent, reported success and delivered nothing under
232+
any configuration, and an inline JS body was recognized but never executed (the
233+
built-in runtime has no server-side sandbox). Every other `actionType` value was
234+
shorthand for a registered-function name.
235+
236+
Replace them per branch — they are different mechanisms, not one rename:
237+
238+
| Retired shape | Use instead |
239+
| --- | --- |
240+
| `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a [`notify` node](#notify) — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed |
241+
| `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook |
242+
| `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you |
243+
| inline `config.script` | move the logic into a registered function and call it via `config.function` |
244+
245+
Stored flows are rewritten by `os migrate meta --from 16`; authoring one of
246+
these keys in TypeScript is now a compile error carrying the same prescription.
247+
248+
[#4343]: https://github.com/objectstack-ai/objectstack/issues/4343
249+
250+
</Callout>
236251

237252
```typescript
238253
{

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

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,27 @@ form lives ONLY in objectui's hand-written `FLOW_NODE_CONFIG` table —
1919

2020
with each member's reason: `decision`'s virtual Target column is derived from
2121

22-
the out-edges, `script`'s form switches on `actionType`, `subflow` carries a
22+
the out-edges, `subflow` carries a top-level `timeoutMs` — a published
2323

24-
top-level `timeoutMs` — a published partial schema would DROP those editors
24+
partial schema would DROP those editors (the #4210 `connector_action`
2525

26-
(the #4210 `connector_action` incident). So the Studio form for these types
26+
incident). So the Studio form for these types is objectui's hand-written
2727

28-
is objectui's hand-written group, and until #4278 **nothing reconciled that
28+
group, and until #4278 **nothing reconciled that hand-written table against
2929

30-
hand-written table against the executors**: `script`'s form offered an
30+
the executors**: `script`'s form offered an `outputVariables` key nothing
3131

32-
`outputVariables` key nothing reads, two `actionType` options that fail every
32+
reads, two `actionType` options that fail every run, a no-op default — and
3333

34-
run, a no-op default — and could not author the `function`/`inputs`/
34+
could not author the `function`/`inputs`/`outputVariable` path that works.
3535

36-
`outputVariable` path that works.
36+
`script`'s own reason for staying schemaless was that its form switched on
37+
38+
`actionType`. #4343 retired that switch, so the node is now three flat keys
39+
40+
and could graduate to a published descriptor `configSchema` the way `map`
41+
42+
did — a follow-up, deliberately not folded into the retirement.
3743

3844
These schemas are the machine-readable half of that reconciliation. They are
3945

@@ -59,37 +65,57 @@ entry here: their contracts are the spec-structured sibling blocks on
5965

6066
same objectui test reconciles directly.
6167

62-
## What these schemas are (and are not) wired to
68+
## What these schemas are wired to
69+
70+
`script` and `subflow` are **parsed at execute time** since #4343, through
71+
72+
the same `parseNodeConfig()` seam #4277 gave the flat builtins
73+
74+
(`service-automation`'s `parse-config.ts`): a config that fails its contract
75+
76+
refuses the node as a GUARD — wrong metadata, so a rerun cannot help and no
77+
78+
`fault` edge may route it (#3863).
79+
80+
`script` could not be parsed while its legal key set depended on
81+
82+
`actionType`; #4343 removed that dependence instead of modelling it.
83+
84+
Converging the node to its one real path — call a registered function — left
85+
86+
a flat three-key contract a flat parse fits exactly, and the five keys the
87+
88+
other branches read became `retiredKey` tombstones.
6389

64-
Contract exports only — no engine path `parse()`s a node config with them,
90+
The two halves reach different audiences, which is why they shipped together:
6591

66-
so registering a flow behaves exactly as before. This is where they differ
92+
- the **tombstones** teach whoever authors the key — `tsc` types it `never`,
6793

68-
from their `builtin-node-config.zod.ts` siblings, which #4277 wired into
94+
and a direct parse raises the prescription. They do NOT reach a stored
6995

70-
execute-time parsing (`service-automation`'s `parse-config.ts`) and into the
96+
flow: `FlowNodeSchema.config` is `z.record(z.unknown())`, so no load-path
7197

72-
`registerFlow()` unknown-key rejection.
98+
parse ever descends into a node's config;
7399

74-
That difference is deliberate, and it is the same reason these three publish
100+
- the **execute-time parse** is what a stored flow meets. `registerFlow`
75101

76-
no descriptor `configSchema`: **their key set is not the whole contract.**
102+
canonicalizes data at rest through the retired conversion too (#3903), so
77103

78-
`script`'s legal keys depend on `actionType` (a built-in side effect reads
104+
a stored `actionType: 'email'` node arrives here stripped of the keys
79105

80-
`template`/`recipients`/`variables`; the function path reads
106+
nothing read — and then refuses, naming the `function` it does not have,
81107

82-
`function`/`inputs`/`outputVariable`), and `decision` may carry no
108+
instead of logging a line and reporting success as it used to.
83109

84-
`conditions` at all when it branches purely on edge predicates. A flat parse
110+
`decision` stays export-only, deliberately: it may carry no `conditions` at
85111

86-
would either reject those shapes or wave everything through — neither is the
112+
all when it branches purely on edge predicates (a plain BPMN exclusive
87113

88-
contract. Wiring them in needs a discriminated form first; until then the
114+
gateway), and `conditions` is its only key — so a parse would have nothing
89115

90-
enforcement they DO get is the objectui reconciliation test, which is what
116+
left to check. Its enforcement remains the objectui reconciliation test,
91117

92-
#4278 was actually about (a form authoring keys nothing reads).
118+
which is what #4278 was actually about (a form authoring keys nothing reads).
93119

94120
Undeclared aliases are NOT part of these contracts: `subflow`'s historical
95121

@@ -144,14 +170,14 @@ const result = DecisionCondition.parse(data);
144170

145171
| Property | Type | Required | Description |
146172
| :--- | :--- | :--- | :--- |
147-
| **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 |
148-
| **function** | `string` | optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType. Contractually pure — it returns a value a later declarative node persists |
173+
| **function** | `string` || Registered function to call (defineStack(`{ functions }`)). Contractually pure — it returns a value a later declarative node persists |
149174
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
150175
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to |
151-
| **template** | `string` | optional | Built-in side effects only: message template id |
152-
| **recipients** | `string[]` | optional | Built-in side effects only: recipients (user ids, field refs, or addresses) |
153-
| **variables** | `Record<string, any>` | optional | Built-in side effects only: values injected into the template |
154-
| **script** | `string` | optional | Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead |
176+
| **actionType** | `any` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 (#4343) — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to rewrite it automatically. |
177+
| **template** | `any` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to rewrite it automatically. |
178+
| **recipients** | `any` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to rewrite it automatically. |
179+
| **variables** | `any` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values into a template no side effect ever rendered. Delete the key. A `notify` node carries structured data in `payload`; a registered function takes it in `config.inputs`. Run `os migrate meta --from 16` to rewrite it automatically. |
180+
| **script** | `any` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (`defineStack({ functions })`) and name it in `config.function`. Run `os migrate meta --from 16` to rewrite it automatically. |
155181

156182

157183
---

0 commit comments

Comments
 (0)