Skip to content

Commit 1402be0

Browse files
os-zhuangclaude
andauthored
feat(automation): script-node outputVariable + interpolated inputs (pure-function pattern, #1870) (#1926)
Long-term design for flow functions (per the templates evaluation): a flow `function` is a PURE compute step — inputs → return value — and ALL data I/O stays declarative on the flow graph. This keeps the data layer visible, governed (RLS/tenancy/transactions via the engine's data path), and build-checkable (create/update_record nodes the build already validates) — rather than giving functions a raw data API that hides writes and bypasses governance. (Data- lifecycle side effects remain L2 hooks, which legitimately get ctx.api.) Two enablers: - `config.outputVariable` exposes the function's return value as a flow variable → a later update_record persists it (`fields: { ai_category: '{ai.ai_category}' }`). - `config.inputs` are now interpolated against live flow variables, so a function can consume a prior node's output (`inputs: { ticketId: '{record.id}' }`). Skill: automation pitfall #9 now teaches the pure-function pattern (function returns → outputVariable → update_record; hooks for data side effects). +1 end-to-end test (compute → outputVariable → downstream node interpolates it); service-automation 202; check:skill-docs passes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab942f2 commit 1402be0

4 files changed

Lines changed: 79 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
feat(automation): script-node `outputVariable` + interpolated inputs — the pure-function pattern (#1870)
6+
7+
A flow `function` (script node) is a PURE compute step: it receives `ctx.input`
8+
and RETURNS a value. Two additions make the value usable on the flow graph
9+
without giving functions raw data access (which would hide I/O from the graph
10+
and bypass governance):
11+
12+
- `config.outputVariable` exposes the function's return value as a flow variable,
13+
so a later declarative node persists it (`update_record fields: { x: '{ai.x}' }`).
14+
- `config.inputs` are now interpolated against the live flow variables, so a
15+
function can consume a prior node's output (`inputs: { id: '{record.id}' }`).
16+
17+
Data writes stay declarative (visible, governed, build-checkable); data-lifecycle
18+
side effects belong in L2 hooks (which get `ctx.api`), not flow functions.

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,29 @@ it('resolves config.functionName as an alias for function (#1870 DX)', async ()
125125
expect(r.success).toBe(false);
126126
expect(r.error).toMatch(/invoke_function.*requires.*function/i);
127127
});
128+
it('exposes the function result via outputVariable for downstream nodes (pure-function pattern)', async () => {
129+
const seen: Array<Record<string, unknown>> = [];
130+
engine.setFunctionResolver((name) => {
131+
if (name === 'compute') return () => ({ ai_category: 'billing', ai_confidence: 0.9 });
132+
if (name === 'consume') return ((c: any) => { seen.push(c.input); return null; });
133+
return undefined;
134+
});
135+
engine.registerFlow('chain', {
136+
name: 'chain', label: 'Chain', type: 'autolaunched',
137+
nodes: [
138+
{ id: 'start', type: 'start', label: 'Start' },
139+
{ id: 'mk', type: 'script', label: 'compute', config: { function: 'compute', outputVariable: 'aiResult' } },
140+
{ id: 'use', type: 'script', label: 'consume', config: { function: 'consume', inputs: { cat: '{aiResult.ai_category}', conf: '{aiResult.ai_confidence}' } } },
141+
{ id: 'end', type: 'end', label: 'End' },
142+
],
143+
edges: [
144+
{ id: 'e1', source: 'start', target: 'mk' },
145+
{ id: 'e2', source: 'mk', target: 'use' },
146+
{ id: 'e3', source: 'use', target: 'end' },
147+
],
148+
} as any);
149+
const r = await engine.execute('chain', {} as any);
150+
expect(r.success).toBe(true);
151+
expect(seen).toEqual([{ cat: 'billing', conf: 0.9 }]);
152+
});
128153
});

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { PluginContext } from '@objectstack/core';
44
import { defineActionDescriptor } from '@objectstack/spec/automation';
55
import type { AutomationEngine } from '../engine.js';
6+
import { interpolate } from './template.js';
67

78
/**
89
* Screen / Script built-in nodes — 'screen' and 'script' executors.
@@ -147,10 +148,19 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
147148
};
148149
}
149150

150-
// Map declared inputs (`config.inputs` | `config.input`) to the function.
151-
const input = (cfg.inputs ?? cfg.input ?? {}) as Record<string, unknown>;
151+
// Map declared inputs (`config.inputs` | `config.input`) to the function,
152+
// interpolating `{var}` references against the live flow variables (so a
153+
// function can consume a prior node's output, e.g. `{aiResult.id}`).
154+
const input = interpolate(cfg.inputs ?? cfg.input ?? {}, variables, context) as Record<string, unknown>;
155+
const outputVariable =
156+
typeof cfg.outputVariable === 'string' && cfg.outputVariable.trim() ? cfg.outputVariable.trim() : undefined;
152157
try {
153158
const result = await handler({ input, variables, automation: context, logger: ctx.logger });
159+
// Pure-function pattern: the function RETURNS its result; `outputVariable`
160+
// exposes it as a flow variable so a later declarative node persists it
161+
// (e.g. `update_record fields: { ai_category: '{aiResult.ai_category}' }`).
162+
// Data I/O stays on the flow graph — the function itself does no writes.
163+
if (outputVariable) variables.set(outputVariable, result);
154164
return { success: true, output: { function: target, result } };
155165
} catch (err) {
156166
return {

skills/objectstack-automation/SKILL.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,30 @@ them right the first time:
548548
Inline `config.script` JS is **not executed** by the built-in runtime (no
549549
server-side sandbox) — move logic into a registered `function`.
550550

551+
**A flow `function` is a PURE compute step — it does NOT read/write the
552+
database.** It receives `ctx.input` and **returns** a value; `config.outputVariable`
553+
exposes that value as a flow variable, and a later **declarative** node persists
554+
it. Keep data effects on the flow graph (visible, governed, build-checkable):
555+
556+
```ts
557+
// ❌ DON'T: expect the function to update the record itself (it has no data API)
558+
// ✅ DO: function returns values → outputVariable → update_record persists
559+
{ id: 'ai', type: 'script', config: {
560+
function: 'helpdesk.aiTriageStub', // returns { ai_category, ai_sentiment, … }
561+
inputs: { ticketId: '{record.id}' }, // inputs are interpolated
562+
outputVariable: 'ai',
563+
} },
564+
{ id: 'apply', type: 'update_record', config: {
565+
objectName: 'helpdesk_ticket',
566+
filter: { id: '{record.id}' },
567+
fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' },
568+
} },
569+
```
570+
571+
`defineStack({ functions: { 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other', … }) } })`.
572+
If you genuinely need data-lifecycle **side effects** (read/write other records),
573+
that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`; flow functions don't.
574+
551575
10. **Conditions are bare CEL — only the stdlib is callable.** `now()`,
552576
`today()`, `daysFromNow(n)`, `daysAgo(n)`, `isBlank(v)`, `coalesce(a, b)`,
553577
`trim(s)`, plus CEL built-ins (`has`, `size`, `contains`, `startsWith`, …).

0 commit comments

Comments
 (0)