Skip to content

Commit ab942f2

Browse files
os-zhuangclaude
andauthored
feat(automation): accept functionName alias + invoke_function marker on script nodes (#1870 DX) (#1925)
Found while evaluating objectstack-ai/templates with the authoring skills: the AI templates emit `{ actionType: 'invoke_function', functionName: 'helpdesk.aiTriageStub' }`, but the runtime only read `config.function` — so the call resolved nothing (my #1870 fix would try to resolve a function literally named 'invoke_function'). Reduce the footgun (one fix, all future templates benefit): - runtime (screen-nodes): `function ?? functionName`; `actionType: 'invoke_function'` is a marker — the name lives in function/functionName, not actionType. - build (validate-expressions): recognize `functionName`; ERROR on `invoke_function` with no function/functionName (it names no callable). Templates still must register the function via defineStack({ functions }); this just makes the key/marker the AI guessed actually work + flags the missing-name case at build. +2 runtime tests, +2 build tests; service-automation 201, cli 404. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9ff4b9a commit ab942f2

5 files changed

Lines changed: 89 additions & 10 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(automation): accept `functionName` alias + `invoke_function` marker on script nodes (#1870 DX)
7+
8+
AI-authored templates commonly emit `config: { actionType: 'invoke_function', functionName: 'my_fn' }`,
9+
but the runtime only read `config.function`. Now:
10+
- `config.functionName` is accepted as an alias for `config.function` (runtime + build).
11+
- `actionType: 'invoke_function'` is treated as a MARKER ("call the named function") — the
12+
name comes from `function`/`functionName`, not from actionType itself; it no longer
13+
tries to resolve a function literally named `invoke_function`.
14+
- `objectstack build` errors on `actionType: 'invoke_function'` with no `function`/`functionName`
15+
(it names no callable) instead of letting it fail at runtime.

packages/cli/src/utils/validate-expressions.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,34 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
9999
});
100100
expect(issues).toHaveLength(0);
101101
});
102+
103+
// #1870 DX — `functionName` is an accepted alias for `function`.
104+
it('accepts a script node that names a callable via the functionName alias', () => {
105+
const issues = validateStackExpressions({
106+
flows: [{
107+
name: 'helpdesk_flow',
108+
nodes: [
109+
{ id: 'start', type: 'start', config: {} },
110+
{ id: 'triage', type: 'script', config: { actionType: 'invoke_function', functionName: 'helpdesk.aiTriageStub' } },
111+
],
112+
edges: [],
113+
}],
114+
});
115+
expect(issues).toHaveLength(0);
116+
});
117+
118+
it('flags actionType invoke_function with no function/functionName', () => {
119+
const issues = validateStackExpressions({
120+
flows: [{
121+
name: 'helpdesk_flow',
122+
nodes: [
123+
{ id: 'start', type: 'start', config: {} },
124+
{ id: 'triage', type: 'script', config: { actionType: 'invoke_function', inputs: { x: 1 } } },
125+
],
126+
edges: [],
127+
}],
128+
});
129+
expect(issues).toHaveLength(1);
130+
expect(issues[0].message).toMatch(/invoke_function.*no .*function/i);
131+
});
102132
});

packages/cli/src/utils/validate-expressions.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
8484
// not serialized into the artifact — so this is a structural check; the
8585
// runtime verifies the named function is actually registered.)
8686
if (node.type === 'script') {
87-
const fn = typeof cfg.function === 'string' ? cfg.function.trim() : '';
87+
// `function` is canonical; `functionName` is an accepted alias.
88+
const fn =
89+
(typeof cfg.function === 'string' ? cfg.function.trim() : '') ||
90+
(typeof cfg.functionName === 'string' ? cfg.functionName.trim() : '');
8891
const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : '';
8992
// Inline `config.script` (a JS body) is also a declared form — the
9093
// built-in runtime doesn't execute it (warned at run time), but the node
@@ -99,6 +102,16 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
99102
`(\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`,
100103
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
101104
});
105+
} else if (action === 'invoke_function' && !fn) {
106+
// `actionType: 'invoke_function'` is a marker that names no callable on
107+
// its own — the function name must be in `function`/`functionName`.
108+
issues.push({
109+
where: `flow '${flowName}' · node '${node.id}' (script) callable`,
110+
message:
111+
`script node uses \`actionType: 'invoke_function'\` but no \`function\` (or \`functionName\`) — ` +
112+
`it names no callable. Set \`function: 'my_fn'\` and register it via \`defineStack({ functions })\`.`,
113+
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
114+
});
102115
}
103116
}
104117
}

packages/services/service-automation/src/builtin/screen-nodes.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,22 @@ describe('script node (#1870 — callable resolution)', () => {
107107
expect(result.success).toBe(false);
108108
expect(result.error).toMatch(/explode.*failed|failed.*boom|boom/i);
109109
});
110+
it('resolves config.functionName as an alias for function (#1870 DX)', async () => {
111+
let calledWith: any;
112+
engine.setFunctionResolver((name) =>
113+
name === 'helpdesk.aiTriageStub' ? ((c: any) => { calledWith = c.input; return { triaged: true }; }) : undefined);
114+
engine.registerFlow('script_flow', scriptFlow({ actionType: 'invoke_function', functionName: 'helpdesk.aiTriageStub', inputs: { ticketId: 't1' } }));
115+
const r = await engine.execute('script_flow', {} as any);
116+
expect(r.success).toBe(true);
117+
expect(calledWith).toEqual({ ticketId: 't1' });
118+
});
119+
120+
it('treats actionType invoke_function as a marker, not a function name', async () => {
121+
// invoke_function alone (no function/functionName) must NOT try to resolve a
122+
// function literally named 'invoke_function'; it fails with a clear message.
123+
engine.registerFlow('script_flow', scriptFlow({ actionType: 'invoke_function' }));
124+
const r = await engine.execute('script_flow', {} as any);
125+
expect(r.success).toBe(false);
126+
expect(r.error).toMatch(/invoke_function.*requires.*function/i);
127+
});
110128
});

packages/services/service-automation/src/builtin/screen-nodes.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
8585
}),
8686
async execute(node, variables, context) {
8787
const cfg = (node.config ?? {}) as Record<string, unknown>;
88-
const fnName = typeof cfg.function === 'string' && cfg.function.trim() ? cfg.function.trim() : undefined;
88+
// `function` is canonical; `functionName` is an accepted alias — AI/templates
89+
// commonly emit it alongside `actionType: 'invoke_function'` (#1870 DX).
90+
const fnRaw = cfg.function ?? cfg.functionName;
91+
const fnName = typeof fnRaw === 'string' && fnRaw.trim() ? fnRaw.trim() : undefined;
8992
const actionType = typeof cfg.actionType === 'string' && cfg.actionType.trim() ? cfg.actionType.trim() : undefined;
9093

9194
// Built-in side-effect actions keep their logger-backed behavior — but
@@ -118,18 +121,18 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
118121
return { success: true, output: { script: 'not-executed' } };
119122
}
120123

121-
// Otherwise the node names a function to invoke. `function` is canonical;
122-
// a bare `actionType` that matched no built-in is accepted as a shorthand
123-
// function name (so templates that point a node straight at e.g.
124-
// `helpdesk.aiTriageStub` resolve).
125-
const target = fnName ?? actionType;
124+
// `actionType: 'invoke_function'` is a MARKER meaning "call the named
125+
// function" — the name lives in `function`/`functionName`, not in actionType
126+
// itself. A bare actionType that matched no built-in is still accepted as a
127+
// function name (shorthand).
128+
const target = fnName ?? (actionType === 'invoke_function' ? undefined : actionType);
126129
if (!target) {
127-
// Defense in depth: registerFlow already rejects this structurally
128-
// (#1870), so reaching here means a node bypassed registration.
129130
return {
130131
success: false,
131132
error:
132-
`script node '${node.id}': declares neither \`actionType\` nor \`function\` — nothing to run.`,
133+
actionType === 'invoke_function'
134+
? `script node '${node.id}': actionType 'invoke_function' requires \`config.function\` (or \`functionName\`) naming the function to call.`
135+
: `script node '${node.id}': declares neither \`actionType\` nor \`function\` — nothing to run.`,
133136
};
134137
}
135138

0 commit comments

Comments
 (0)