Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/script-output-variable.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
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 }]);
});
});
14 changes: 12 additions & 2 deletions packages/services/service-automation/src/builtin/screen-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>;
// 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<string, unknown>;
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 {
Expand Down
24 changes: 24 additions & 0 deletions skills/objectstack-automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, …).
Expand Down
Loading