Skip to content

Commit 2ca73ac

Browse files
os-zhuangclaude
andcommitted
feat(automation): resolve & validate script-node callables; function registration path (#1870)
A flow `script` node pointing at an unregistered callable — or declaring no `actionType`/`function` at all — built fine and silently did nothing at runtime. Close the gap on three surfaces: - Runtime: the built-in `script` executor resolves its target (built-in side-effect → registered function → else fail loud). The old `(no-op handler)` success path is removed, so an unwired callable can no longer quietly skip the step. - Registration path: add `AutomationEngine.setFunctionResolver()`/ `resolveFunction()` and bridge it in the plugin's `start()` to ObjectQL's `resolveFunction` (fed by `bundle.functions` / `defineStack({ functions })`). A `script` node can now invoke an authored function by name: `{ type: 'script', config: { function: 'my_fn', inputs: { … } } }`. - Build: `objectstack build` flags a `script` node that names no callable (the `actionType: undefined` repro). Existence is checked at runtime — functions are code, not serialized into the artifact. Tests: new script-executor suite (function resolution, loud failure, bare actionType shorthand, thrown-function surfacing) + CLI build-validation cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa152c9 commit 2ca73ac

8 files changed

Lines changed: 325 additions & 15 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(automation): resolve & validate `script`-node callables; first-class function registration (#1870)
7+
8+
A flow `script` node that pointed at an unregistered callable (or declared no
9+
`actionType`/`function` at all) built fine and silently did nothing at runtime.
10+
Two changes close that gap:
11+
12+
- **Loud runtime resolution.** The built-in `script` executor now resolves its
13+
target in order — built-in side-effect (`email`/`slack`) → a registered
14+
function (`config.function`, or a bare `config.actionType` that matches no
15+
built-in) → otherwise **fail the step loudly**. The old `(no-op handler)`
16+
success path is gone, so an unwired callable can no longer quietly skip.
17+
- **First-class registration path.** `AutomationEngine.setFunctionResolver()` /
18+
`resolveFunction()` bridge flow nodes to the host function registry. The
19+
automation plugin wires it to ObjectQL's `resolveFunction` (populated from
20+
`bundle.functions` / `defineStack({ functions })`), so an authored package can
21+
register a function and call it from a `script` node:
22+
`{ type: 'script', config: { function: 'my_fn', inputs: { … } } }`.
23+
- **Build-time structural check.** `objectstack build` now flags a `script` node
24+
that declares neither `actionType` nor `function` (the `actionType: undefined`
25+
repro). Function *existence* is verified at runtime — functions are code, not
26+
serialized into the artifact.

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,36 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
6666
expect(issues).toHaveLength(1);
6767
expect(issues[0].where).toContain("validation 'r1'");
6868
});
69+
70+
// #1870 — a `script` node that names no callable is a silent no-op.
71+
it('flags a script node that declares neither actionType nor function (#1870)', () => {
72+
const issues = validateStackExpressions({
73+
flows: [{
74+
name: 'helpdesk_flow',
75+
nodes: [
76+
{ id: 'start', type: 'start', config: {} },
77+
{ id: 'triage', type: 'script', config: { actionType: undefined } },
78+
],
79+
edges: [],
80+
}],
81+
});
82+
expect(issues).toHaveLength(1);
83+
expect(issues[0].where).toContain("node 'triage' (script) callable");
84+
expect(issues[0].message).toMatch(/neither .*actionType.* nor .*function/);
85+
});
86+
87+
it('accepts a script node that names a built-in action or a function (#1870)', () => {
88+
const issues = validateStackExpressions({
89+
flows: [{
90+
name: 'helpdesk_flow',
91+
nodes: [
92+
{ id: 'start', type: 'start', config: {} },
93+
{ id: 'mail', type: 'script', config: { actionType: 'email' } },
94+
{ id: 'triage', type: 'script', config: { function: 'helpdesk.aiTriageStub' } },
95+
],
96+
edges: [],
97+
}],
98+
});
99+
expect(issues).toHaveLength(0);
100+
});
69101
});

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
7878
for (const node of nodes) {
7979
const cfg = (node.config ?? {}) as AnyRec;
8080
check(`flow '${flowName}' · node '${node.id}' (${node.type}) condition`, cfg.condition, objectName);
81+
// #1870 — a `script` node must declare a callable target (`actionType` or
82+
// `function`). A node with neither is a silent no-op that otherwise passes
83+
// build. (Function *existence* isn't checkable here — functions are code,
84+
// not serialized into the artifact — so this is a structural check; the
85+
// runtime verifies the named function is actually registered.)
86+
if (node.type === 'script') {
87+
const fn = typeof cfg.function === 'string' ? cfg.function.trim() : '';
88+
const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : '';
89+
if (!fn && !action) {
90+
issues.push({
91+
where: `flow '${flowName}' · node '${node.id}' (script) callable`,
92+
message:
93+
`script node declares neither \`actionType\` nor \`function\` — it would do nothing at runtime. ` +
94+
`Name a built-in action (e.g. \`actionType: 'email'\`) or a registered function ` +
95+
`(\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`,
96+
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
97+
});
98+
}
99+
}
81100
}
82101
for (const edge of edges) {
83102
check(`flow '${flowName}' · edge '${edge.id}' (${edge.source}${edge.target}) condition`, edge.condition, objectName);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { AutomationEngine, type FlowFunctionHandler } from '../engine.js';
5+
import { registerScreenNodes } from './screen-nodes.js';
6+
7+
function createTestLogger() {
8+
return {
9+
info: () => {},
10+
warn: () => {},
11+
error: () => {},
12+
debug: () => {},
13+
child: () => createTestLogger(),
14+
} as any;
15+
}
16+
17+
function createCtx() {
18+
return { logger: createTestLogger(), getService: () => undefined } as any;
19+
}
20+
21+
/** A one-`script`-node flow whose script node carries `config`. */
22+
function scriptFlow(config: Record<string, unknown>) {
23+
return {
24+
name: 'script_flow',
25+
label: 'Script Flow',
26+
type: 'autolaunched' as const,
27+
nodes: [
28+
{ id: 'start', type: 'start' as const, label: 'Start' },
29+
{ id: 'run', type: 'script' as const, label: 'Run', config },
30+
{ id: 'end', type: 'end' as const, label: 'End' },
31+
],
32+
edges: [
33+
{ id: 'e1', source: 'start', target: 'run' },
34+
{ id: 'e2', source: 'run', target: 'end' },
35+
],
36+
};
37+
}
38+
39+
describe('script node (#1870 — callable resolution)', () => {
40+
let engine: AutomationEngine;
41+
42+
beforeEach(() => {
43+
engine = new AutomationEngine(createTestLogger());
44+
registerScreenNodes(engine, createCtx());
45+
});
46+
47+
it('runs the built-in email side-effect', async () => {
48+
engine.registerFlow('script_flow', scriptFlow({ actionType: 'email', template: 't', recipients: ['a'] }));
49+
const result = await engine.execute('script_flow', {} as any);
50+
expect(result.success).toBe(true);
51+
});
52+
53+
it('invokes a registered function and captures its return value as output', async () => {
54+
const calls: Array<Record<string, unknown>> = [];
55+
const fn: FlowFunctionHandler = (c) => {
56+
calls.push(c.input);
57+
return { triaged: true, priority: 'high' };
58+
};
59+
engine.setFunctionResolver((name) => (name === 'helpdesk.aiTriageStub' ? fn : undefined));
60+
61+
engine.registerFlow('script_flow', scriptFlow({
62+
function: 'helpdesk.aiTriageStub',
63+
inputs: { ticket: 't_1' },
64+
}));
65+
const result = await engine.execute('script_flow', {} as any);
66+
67+
expect(result.success).toBe(true);
68+
expect(calls).toEqual([{ ticket: 't_1' }]);
69+
});
70+
71+
it('resolves a bare actionType that matches no built-in as a function name', async () => {
72+
let called = false;
73+
engine.setFunctionResolver((name) => (name === 'pm.aiRiskAssessmentStub' ? (() => { called = true; return 1; }) : undefined));
74+
engine.registerFlow('script_flow', scriptFlow({ actionType: 'pm.aiRiskAssessmentStub' }));
75+
const result = await engine.execute('script_flow', {} as any);
76+
expect(result.success).toBe(true);
77+
expect(called).toBe(true);
78+
});
79+
80+
it('FAILS LOUDLY for an unregistered function instead of silently no-op (#1870)', async () => {
81+
// No resolver wired → nothing resolves.
82+
engine.registerFlow('script_flow', scriptFlow({ function: 'helpdesk.aiTriageStub' }));
83+
const result = await engine.execute('script_flow', {} as any);
84+
expect(result.success).toBe(false);
85+
expect(result.error).toMatch(/aiTriageStub/);
86+
expect(result.error).toMatch(/no function named|not a built-in/i);
87+
});
88+
89+
it('FAILS LOUDLY when the script node declares no target at all (actionType: undefined repro)', async () => {
90+
engine.registerFlow('script_flow', scriptFlow({ actionType: undefined }));
91+
const result = await engine.execute('script_flow', {} as any);
92+
expect(result.success).toBe(false);
93+
expect(result.error).toMatch(/neither .*actionType.* nor .*function|nothing to run/i);
94+
});
95+
96+
it('surfaces a thrown function as a loud step failure', async () => {
97+
engine.setFunctionResolver(() => () => { throw new Error('boom'); });
98+
engine.registerFlow('script_flow', scriptFlow({ function: 'explode' }));
99+
const result = await engine.execute('script_flow', {} as any);
100+
expect(result.success).toBe(false);
101+
expect(result.error).toMatch(/explode.*failed|failed.*boom|boom/i);
102+
});
103+
});

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

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,22 @@ import type { AutomationEngine } from '../engine.js';
1616
* as bare flow variables). A field-less screen — or one with
1717
* `waitForInput === false` — stays a server pass-through (input vars, if any,
1818
* are already injected from `context.params`).
19-
* - 'script' nodes dispatch by `config.actionType`. Currently only 'email'
20-
* has a (logger-backed) implementation; unknown action types still succeed
21-
* so flows can continue and downstream nodes can react.
19+
* - 'script' nodes name a callable to run (#1870):
20+
* - `config.actionType` selecting a built-in side-effect ('email', 'slack',
21+
* logger-backed), or
22+
* - `config.function` (or a bare `actionType` that matches no built-in)
23+
* naming a registered function — resolved via `engine.resolveFunction()`,
24+
* which the host bridges to `bundle.functions` / `defineStack({ functions })`.
25+
* A target that resolves to neither fails the step LOUDLY rather than the old
26+
* silent "no-op handler" success, so an unwired callable can't quietly skip.
2227
*/
28+
29+
/**
30+
* Built-in `script` side-effect action types with a (logger-backed) handler.
31+
* Anything else is treated as a registered-function name (#1870).
32+
*/
33+
const SCRIPT_BUILTIN_ACTION_TYPES = new Set(['email', 'slack']);
34+
2335
export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void {
2436
// screen — server-side pass-through (input vars already injected by engine).
2537
engine.registerNodeExecutor({
@@ -71,26 +83,62 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
7183
description: 'Run a custom script action.',
7284
icon: 'code', category: 'logic', source: 'builtin',
7385
}),
74-
async execute(node, _variables, _context) {
86+
async execute(node, variables, context) {
7587
const cfg = (node.config ?? {}) as Record<string, unknown>;
76-
const actionType = (cfg.actionType as string | undefined) ?? 'noop';
77-
if (actionType === 'email') {
88+
const fnName = typeof cfg.function === 'string' && cfg.function.trim() ? cfg.function.trim() : undefined;
89+
const actionType = typeof cfg.actionType === 'string' && cfg.actionType.trim() ? cfg.actionType.trim() : undefined;
90+
91+
// Built-in side-effect actions keep their logger-backed behavior — but
92+
// only when an explicit `function` isn't set (that always wins).
93+
if (!fnName && actionType && SCRIPT_BUILTIN_ACTION_TYPES.has(actionType)) {
7894
ctx.logger.info(
79-
`[Script:email] template=${String(cfg.template)} ` +
95+
`[Script:${actionType}] template=${String(cfg.template)} ` +
8096
`recipients=${JSON.stringify(cfg.recipients)} ` +
8197
`vars=${JSON.stringify(cfg.variables)}`,
8298
);
8399
return {
84100
success: true,
85-
output: {
86-
actionType,
87-
template: cfg.template,
88-
recipients: cfg.recipients,
89-
},
101+
output: { actionType, template: cfg.template, recipients: cfg.recipients },
102+
};
103+
}
104+
105+
// Otherwise the node names a function to invoke. `function` is canonical;
106+
// a bare `actionType` that matched no built-in is accepted as a shorthand
107+
// function name (so templates that point a node straight at e.g.
108+
// `helpdesk.aiTriageStub` resolve).
109+
const target = fnName ?? actionType;
110+
if (!target) {
111+
// Defense in depth: registerFlow already rejects this structurally
112+
// (#1870), so reaching here means a node bypassed registration.
113+
return {
114+
success: false,
115+
error:
116+
`script node '${node.id}': declares neither \`actionType\` nor \`function\` — nothing to run.`,
117+
};
118+
}
119+
120+
const handler = engine.resolveFunction(target);
121+
if (!handler) {
122+
return {
123+
success: false,
124+
error:
125+
`script node '${node.id}': '${target}' is not a built-in action ` +
126+
`(${[...SCRIPT_BUILTIN_ACTION_TYPES].join(', ')}) and no function named '${target}' is registered. ` +
127+
`Register it via \`defineStack({ functions: { '${target}': fn } })\`, or fix the name (#1870).`,
128+
};
129+
}
130+
131+
// Map declared inputs (`config.inputs` | `config.input`) to the function.
132+
const input = (cfg.inputs ?? cfg.input ?? {}) as Record<string, unknown>;
133+
try {
134+
const result = await handler({ input, variables, automation: context, logger: ctx.logger });
135+
return { success: true, output: { function: target, result } };
136+
} catch (err) {
137+
return {
138+
success: false,
139+
error: `script function '${target}' (node '${node.id}') failed: ${(err as Error).message}`,
90140
};
91141
}
92-
ctx.logger.info(`[Script:${actionType}] node=${node.id} executed (no-op handler)`);
93-
return { success: true, output: { actionType } };
94142
},
95143
});
96144

packages/services/service-automation/src/engine.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,39 @@ export interface RegisteredConnector {
167167
readonly handlers: Record<string, ConnectorActionHandler>;
168168
}
169169

170+
/**
171+
* Context handed to a named handler function invoked from a `script` node
172+
* (#1870). Mirrors {@link ConnectorActionContext} but carries the node's mapped
173+
* `input` so the function reads its arguments without reaching into the raw
174+
* variable map. The function's return value becomes the node output.
175+
*/
176+
export interface FlowFunctionContext {
177+
/** Inputs mapped from the node's `config.inputs` (already in scope). */
178+
readonly input: Record<string, unknown>;
179+
/** Live flow variable map — read prior-node output / write results. */
180+
readonly variables: Map<string, unknown>;
181+
/** The flow execution / trigger context. */
182+
readonly automation: AutomationContext;
183+
readonly logger: Logger;
184+
}
185+
186+
/**
187+
* A named handler function callable from a `script` node. Returns the node's
188+
* output (any JSON-serializable value); returning `undefined` yields an empty
189+
* output. Authored packages contribute these via `defineStack({ functions })`,
190+
* which the host bridges in through {@link AutomationEngine.setFunctionResolver}.
191+
*/
192+
export type FlowFunctionHandler = (ctx: FlowFunctionContext) => unknown | Promise<unknown>;
193+
194+
/**
195+
* Resolves a function name to its handler. Injected by the host (the automation
196+
* plugin bridges it to ObjectQL's `resolveFunction`, fed by `bundle.functions`),
197+
* so the engine stays decoupled from any specific function registry. Returns
198+
* `undefined` for an unknown name, letting the `script` node fail the step
199+
* loudly instead of silently no-op'ing (#1870).
200+
*/
201+
export type FlowFunctionResolver = (name: string) => FlowFunctionHandler | undefined;
202+
170203
/**
171204
* A designer-facing view of one connector action — identity + its JSON-Schema
172205
* input/output. The runtime handler is intentionally omitted; this is metadata.
@@ -353,6 +386,8 @@ export class AutomationEngine implements IAutomationService {
353386
private boundFlowTriggers = new Map<string, string>();
354387
/** Connectors registered by integration plugins, keyed by connector name (ADR-0018 §Addendum). */
355388
private connectors = new Map<string, RegisteredConnector>();
389+
/** Bridge to the host function registry for `script`-node calls (#1870), if wired. */
390+
private functionResolver: FlowFunctionResolver | null = null;
356391
private executionLogs: ExecutionLogEntry[] = [];
357392
private readonly maxLogSize: number;
358393
private logger: Logger;
@@ -697,6 +732,26 @@ export class AutomationEngine implements IAutomationService {
697732
return this.connectors.get(connectorId)?.handlers[actionId];
698733
}
699734

735+
/**
736+
* Wire the engine to the host's named-function registry (#1870). The
737+
* automation plugin calls this in `start()` with a resolver backed by
738+
* ObjectQL's `resolveFunction` (populated from `bundle.functions` /
739+
* `defineStack({ functions })`), so a `script` node can invoke an
740+
* authored function by name. Passing `null` detaches the bridge.
741+
*/
742+
setFunctionResolver(resolver: FlowFunctionResolver | null): void {
743+
this.functionResolver = resolver;
744+
}
745+
746+
/**
747+
* Resolve a named function for a `script` node. Returns `undefined` when no
748+
* resolver is wired or the name is unregistered — the node then fails the
749+
* step with a clear error rather than silently no-op'ing.
750+
*/
751+
resolveFunction(name: string): FlowFunctionHandler | undefined {
752+
return this.functionResolver?.(name) ?? undefined;
753+
}
754+
700755
/** Get all registered connector names. */
701756
getRegisteredConnectors(): string[] {
702757
return [...this.connectors.keys()];

0 commit comments

Comments
 (0)