diff --git a/.changeset/script-output-variable.md b/.changeset/script-output-variable.md new file mode 100644 index 0000000000..f8e8a77c06 --- /dev/null +++ b/.changeset/script-output-variable.md @@ -0,0 +1,18 @@ +--- +"@objectstack/service-automation": minor +--- + +feat(automation): script-node `outputVariable` + interpolated inputs — the pure-function pattern (#1870) + +A flow `function` (script node) is a PURE compute step: it receives `ctx.input` +and RETURNS a value. Two additions make the value usable on the flow graph +without giving functions raw data access (which would hide I/O from the graph +and bypass governance): + +- `config.outputVariable` exposes the function's return value as a flow variable, + so a later declarative node persists it (`update_record fields: { x: '{ai.x}' }`). +- `config.inputs` are now interpolated against the live flow variables, so a + function can consume a prior node's output (`inputs: { id: '{record.id}' }`). + +Data writes stay declarative (visible, governed, build-checkable); data-lifecycle +side effects belong in L2 hooks (which get `ctx.api`), not flow functions. diff --git a/packages/services/service-automation/src/builtin/screen-nodes.test.ts b/packages/services/service-automation/src/builtin/screen-nodes.test.ts index 9e5cd63f03..2a6566e3be 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.test.ts @@ -125,4 +125,29 @@ it('resolves config.functionName as an alias for function (#1870 DX)', async () expect(r.success).toBe(false); expect(r.error).toMatch(/invoke_function.*requires.*function/i); }); + it('exposes the function result via outputVariable for downstream nodes (pure-function pattern)', async () => { + const seen: Array> = []; + engine.setFunctionResolver((name) => { + if (name === 'compute') return () => ({ ai_category: 'billing', ai_confidence: 0.9 }); + if (name === 'consume') return ((c: any) => { seen.push(c.input); return null; }); + return undefined; + }); + engine.registerFlow('chain', { + name: 'chain', label: 'Chain', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'mk', type: 'script', label: 'compute', config: { function: 'compute', outputVariable: 'aiResult' } }, + { id: 'use', type: 'script', label: 'consume', config: { function: 'consume', inputs: { cat: '{aiResult.ai_category}', conf: '{aiResult.ai_confidence}' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mk' }, + { id: 'e2', source: 'mk', target: 'use' }, + { id: 'e3', source: 'use', target: 'end' }, + ], + } as any); + const r = await engine.execute('chain', {} as any); + expect(r.success).toBe(true); + expect(seen).toEqual([{ cat: 'billing', conf: 0.9 }]); + }); }); diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 3dae755a44..c9c18af4ce 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -3,6 +3,7 @@ import type { PluginContext } from '@objectstack/core'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; +import { interpolate } from './template.js'; /** * Screen / Script built-in nodes — 'screen' and 'script' executors. @@ -147,10 +148,19 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext }; } - // Map declared inputs (`config.inputs` | `config.input`) to the function. - const input = (cfg.inputs ?? cfg.input ?? {}) as Record; + // Map declared inputs (`config.inputs` | `config.input`) to the function, + // interpolating `{var}` references against the live flow variables (so a + // function can consume a prior node's output, e.g. `{aiResult.id}`). + const input = interpolate(cfg.inputs ?? cfg.input ?? {}, variables, context) as Record; + const outputVariable = + typeof cfg.outputVariable === 'string' && cfg.outputVariable.trim() ? cfg.outputVariable.trim() : undefined; try { const result = await handler({ input, variables, automation: context, logger: ctx.logger }); + // Pure-function pattern: the function RETURNS its result; `outputVariable` + // exposes it as a flow variable so a later declarative node persists it + // (e.g. `update_record fields: { ai_category: '{aiResult.ai_category}' }`). + // Data I/O stays on the flow graph — the function itself does no writes. + if (outputVariable) variables.set(outputVariable, result); return { success: true, output: { function: target, result } }; } catch (err) { return { diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index a8bb4ad0dd..1fce611d71 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -548,6 +548,30 @@ them right the first time: Inline `config.script` JS is **not executed** by the built-in runtime (no server-side sandbox) — move logic into a registered `function`. + **A flow `function` is a PURE compute step — it does NOT read/write the + database.** It receives `ctx.input` and **returns** a value; `config.outputVariable` + exposes that value as a flow variable, and a later **declarative** node persists + it. Keep data effects on the flow graph (visible, governed, build-checkable): + + ```ts + // ❌ DON'T: expect the function to update the record itself (it has no data API) + // ✅ DO: function returns values → outputVariable → update_record persists + { id: 'ai', type: 'script', config: { + function: 'helpdesk.aiTriageStub', // returns { ai_category, ai_sentiment, … } + inputs: { ticketId: '{record.id}' }, // inputs are interpolated + outputVariable: 'ai', + } }, + { id: 'apply', type: 'update_record', config: { + objectName: 'helpdesk_ticket', + filter: { id: '{record.id}' }, + fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' }, + } }, + ``` + + `defineStack({ functions: { 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other', … }) } })`. + If you genuinely need data-lifecycle **side effects** (read/write other records), + that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`; flow functions don't. + 10. **Conditions are bare CEL — only the stdlib is callable.** `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`, `isBlank(v)`, `coalesce(a, b)`, `trim(s)`, plus CEL built-ins (`has`, `size`, `contains`, `startsWith`, …).