Skip to content

Commit c492db1

Browse files
committed
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
1 parent 12a7dbc commit c492db1

3 files changed

Lines changed: 63 additions & 3 deletions

File tree

.changeset/script-branch-keys-retired.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ a row in `sys_metadata` has no author for a tombstone to teach. So a stored emai
6060
arrives stripped of the keys nothing read and then **refuses for naming no callable**,
6161
where it used to log a line and report success. That flip is the behavior change to expect.
6262

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+
6379
Also in this change: the retired constants `SCRIPT_BUILTIN_ACTION_TYPES`,
6480
`SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` and the `ScriptBuiltinActionType` type are removed
6581
(they described the dispatch set that no longer exists); `os validate` names a retired key

packages/spec/src/automation/flow-function.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ describe('FlowFunctionEntrySchema', () => {
6969
it('rejects a declaration whose handler is not callable', () => {
7070
expect(FlowFunctionEntrySchema.safeParse({ handler: 'scoreLead' }).success).toBe(false);
7171
});
72+
73+
// #4343 — what `objectstack build` produces. The CLI lowers every inline
74+
// callable to a serialisable ref BEFORE the stack is parsed, so a built
75+
// manifest holds `{ scoreLead: 'scoreLead' }`. Rejecting that made
76+
// `defineStack({ functions })` — a documented, first-class mechanism —
77+
// unbuildable, which #4343 turned from latent into blocking by making
78+
// `config.function` the only thing a `script` node can run.
79+
it('accepts a lowered handler ref, the form a built artifact carries', () => {
80+
expect(FlowFunctionEntrySchema.safeParse('scoreLead').success).toBe(true);
81+
// Empty is not a name.
82+
expect(FlowFunctionEntrySchema.safeParse('').success).toBe(false);
83+
});
84+
85+
it('drops a lowered ref when normalizing — it names a function without carrying one', () => {
86+
// The callable for that name comes from the sidecar ESM module the build
87+
// emits; binding the string would register a name pointing at nothing.
88+
expect(normalizeFlowFunctionEntry('scoreLead')).toBeUndefined();
89+
});
7290
});
7391

7492
describe('defineStack({ functions }) — the authoring surface (#4396)', () => {

packages/spec/src/automation/flow-function.zod.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,33 @@ export type FlowFunctionDeclaration = z.infer<typeof FlowFunctionDeclarationSche
112112
export type FlowFunctionDeclarationInput = z.input<typeof FlowFunctionDeclarationSchema>;
113113

114114
/**
115-
* One entry of the `functions` map as authors may write it: the handler alone
116-
* (pure), or a {@link FlowFunctionDeclarationSchema} that states its effect.
115+
* One entry of the `functions` map: the handler alone (pure), a
116+
* {@link FlowFunctionDeclarationSchema} that states its effect, or the
117+
* **lowered handler ref** a built artifact carries.
118+
*
119+
* The first two are what an author writes. The third is what `objectstack
120+
* build` produces and was, until #4343, the reason `defineStack({ functions })`
121+
* could not survive a build at all: the CLI lowers every inline callable to a
122+
* serialisable string ref BEFORE the stack is parsed (it must — `z.function()`
123+
* wraps callables and would break the ref mapping), so the manifest reaching
124+
* this schema holds `{ myFn: 'myFn' }`, which neither of the other two members
125+
* accepts. The build failed on a mechanism its own docs call first-class.
126+
*
127+
* A string entry carries no callable, and that is correct rather than lossy:
128+
* the real functions ride in the sibling ESM module esbuild emits, and
129+
* {@link collectBundleFunctionEntries} merges both sources by name. The string
130+
* is the artifact's record that the NAME exists — which is why
131+
* {@link normalizeFlowFunctionEntry} deliberately drops it (see there).
132+
*
133+
* Authoring a string by hand therefore registers nothing. It fails loudly, not
134+
* silently: a `script` node naming it refuses at execute with "no function
135+
* named '…' is registered" (#1870).
117136
*/
118137
export const FlowFunctionEntrySchema = lazySchema(() => z.union([
119138
z.function(),
120139
FlowFunctionDeclarationSchema,
121-
]).describe('A named handler function, or a declaration record stating its effect'));
140+
z.string().min(1).describe('A lowered handler ref (built artifacts) — the callable rides in the sibling ESM module'),
141+
]).describe('A named handler function, a declaration record stating its effect, or a lowered handler ref'));
122142

123143
export type FlowFunctionEntry = z.infer<typeof FlowFunctionEntrySchema>;
124144

@@ -153,6 +173,12 @@ export function isFlowFunctionEffect(value: unknown): value is FlowFunctionEffec
153173
* Deliberately hand-written rather than a `FlowFunctionEntrySchema.parse()`:
154174
* the entry holds a live function, and the collectors that call this run on the
155175
* boot path where re-parsing every handler buys nothing.
176+
*
177+
* A lowered string ref (the third member of that schema) returns `undefined`
178+
* here BY DESIGN — it names a function without carrying one. The callable for
179+
* that name comes from the built sidecar module, which the same collector
180+
* merges in; treating the string as an entry would register a name bound to
181+
* nothing.
156182
*/
157183
export function normalizeFlowFunctionEntry(entry: unknown): NormalizedFlowFunction | undefined {
158184
if (typeof entry === 'function') {

0 commit comments

Comments
 (0)