Skip to content

Commit 5c439e9

Browse files
authored
Merge pull request #1709 from objectstack-ai/feat-map-node-batch-approval
feat(automation): map node — sequential batch approval (ADR-0037 A2)
2 parents 17a8f00 + 209548d commit 5c439e9

7 files changed

Lines changed: 476 additions & 3 deletions

File tree

content/docs/guides/metadata/flow.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,36 @@ is a separate, larger capability ([ADR-0037](https://github.com/objectstack-ai/f
349349
Track B); the aggregating node covers the common parallel/batch-approval demand
350350
without it. Worked example: `showcase_invoice_signoff` in the showcase app.
351351

352+
### Batch approval — a `map` node, one item at a time
353+
354+
"Sign off **every** task in the release, in turn" is a **`map` node**: it runs a
355+
per-item subflow for each element of a collection, **sequentially**. Unlike
356+
`loop` (whose body runs synchronously and cannot pause), each item is a full
357+
child run, so the per-item subflow may **durably pause** on its own `approval`
358+
the map waits for that item's decision, then moves to the next.
359+
360+
```typescript
361+
{
362+
id: 'signoffs',
363+
type: 'map',
364+
label: 'Sign off each task',
365+
config: {
366+
collection: '{items}', // array (e.g. the release's tasks)
367+
iteratorVariable: 'task',
368+
flowName: 'one_task_signoff', // the per-item subflow (it may pause)
369+
itemObject: 'showcase_task', // when an item is a record, it becomes the child's `$record`
370+
outputVariable: 'signoffResults',// each item's subflow output, collected in order
371+
},
372+
}
373+
```
374+
375+
The run holds a **single program counter** the whole time: only one item's
376+
approval is open at any moment; when it is decided the engine **re-enters** the
377+
map node to start the next item. v1 is sequential and fail-fast (the first item
378+
whose subflow fails fails the map). Concurrent fan-out (all items at once) is the
379+
larger [ADR-0037](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0037-token-scope-tree-execution.md)
380+
Track B work. Worked example: `showcase_release_signoff``showcase_one_task_signoff`.
381+
352382
### Nested pause — pausing inside a subflow
353383

354384
A pausing node inside a `subflow` suspends the **whole chain as linked runs**:

examples/app-showcase/src/flows/index.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,11 +917,113 @@ export const InvoiceDualSignoffFlow = defineFlow({
917917
],
918918
});
919919

920+
/**
921+
* One Task Sign-off — a reusable per-item **approval subflow**, invoked once
922+
* per task by {@link ReleaseSignoffFlow}'s `map` node. The mapped task is
923+
* exposed to this subflow as its record, so the `approval` node opens against
924+
* *that* task.
925+
*/
926+
export const OneTaskSignoffSubflow = defineFlow({
927+
name: 'showcase_one_task_signoff',
928+
label: 'One Task Sign-off (per-item subflow)',
929+
description: 'Reusable subflow: requests sign-off on a single task. Invoked per item by the batch sign-off map.',
930+
type: 'autolaunched',
931+
template: true,
932+
variables: [{ name: 'decision', type: 'text', isOutput: true }],
933+
nodes: [
934+
{ id: 'start', type: 'start', label: 'Start' },
935+
{
936+
id: 'review',
937+
type: 'approval',
938+
label: 'Task Sign-off',
939+
config: {
940+
approvers: [{ type: 'role', value: 'manager' }],
941+
behavior: 'first_response',
942+
lockRecord: false,
943+
},
944+
},
945+
{ id: 'mark_ok', type: 'assignment', label: 'Approved', config: { assignments: { decision: 'approved' } } },
946+
{ id: 'mark_no', type: 'assignment', label: 'Rejected', config: { assignments: { decision: 'rejected' } } },
947+
{ id: 'end_ok', type: 'end', label: 'Signed Off' },
948+
{ id: 'end_no', type: 'end', label: 'Declined' },
949+
],
950+
edges: [
951+
{ id: 'e1', source: 'start', target: 'review' },
952+
{ id: 'e2', source: 'review', target: 'mark_ok', label: 'approve' },
953+
{ id: 'e3', source: 'review', target: 'mark_no', label: 'reject' },
954+
{ id: 'e4', source: 'mark_ok', target: 'end_ok' },
955+
{ id: 'e5', source: 'mark_no', target: 'end_no' },
956+
],
957+
});
958+
959+
/**
960+
* Release Sign-off — the worked **batch-approval** example (ADR-0037 Track A2:
961+
* the sequential `map` / multi-instance node).
962+
*
963+
* "Every task in the release must be signed off, one at a time" is a **single
964+
* `map` node** over the task list. For each task it runs the
965+
* {@link OneTaskSignoffSubflow}, which **pauses** on its `approval`; when that
966+
* task is decided, the map **re-enters** and moves to the next task — the run
967+
* holds a single program counter throughout (no token tree). The per-task
968+
* decisions are collected into `signoffResults`, then the owner is notified.
969+
*
970+
* Trigger it with the tasks to sign off, e.g.:
971+
* POST /api/v1/automation/showcase_release_signoff/trigger
972+
* { "params": { "items": [ {task record}, {task record} ] } }
973+
* then decide each task's approval in turn via /api/v1/approvals.
974+
*/
975+
export const ReleaseSignoffFlow = defineFlow({
976+
name: 'showcase_release_signoff',
977+
label: 'Release Sign-off (batch approval / map)',
978+
description: 'Signs off every task in a release one at a time via a map node — demonstrates batch approval (ADR-0037 Track A2).',
979+
type: 'autolaunched',
980+
variables: [
981+
{ name: 'items', type: 'list', isInput: true },
982+
{ name: 'signoffResults', type: 'list', isOutput: true },
983+
],
984+
nodes: [
985+
{ id: 'start', type: 'start', label: 'Start' },
986+
{
987+
id: 'signoffs',
988+
type: 'map',
989+
label: 'Sign off each task',
990+
config: {
991+
collection: '{items}',
992+
iteratorVariable: 'task',
993+
flowName: 'showcase_one_task_signoff',
994+
itemObject: 'showcase_task',
995+
outputVariable: 'signoffResults',
996+
},
997+
},
998+
{
999+
id: 'notify_done',
1000+
type: 'notify',
1001+
label: 'Notify: Release Cleared',
1002+
config: {
1003+
topic: 'release.signoff',
1004+
recipients: ['admin@objectos.ai'],
1005+
channels: ['inbox'],
1006+
severity: 'info',
1007+
title: 'Release sign-off complete',
1008+
message: 'Every task in the release has been signed off.',
1009+
},
1010+
},
1011+
{ id: 'end', type: 'end', label: 'End' },
1012+
],
1013+
edges: [
1014+
{ id: 'e1', source: 'start', target: 'signoffs' },
1015+
{ id: 'e2', source: 'signoffs', target: 'notify_done' },
1016+
{ id: 'e3', source: 'notify_done', target: 'end' },
1017+
],
1018+
});
1019+
9201020
export const allFlows = [
9211021
TaskCompletedFlow,
9221022
ReassignWizardFlow,
9231023
BudgetApprovalFlow,
9241024
InvoiceDualSignoffFlow,
1025+
OneTaskSignoffSubflow,
1026+
ReleaseSignoffFlow,
9251027
TaskCompletedSlackFlow,
9261028
TaskAssignedNotifyFlow,
9271029
ScheduledDigestFlow,

packages/services/service-automation/src/builtin/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { registerConnectorNodes } from './connector-nodes.js';
4141
import { registerNotifyNode } from './notify-node.js';
4242
import { registerWaitNode } from './wait-node.js';
4343
import { registerSubflowNode } from './subflow-node.js';
44+
import { registerMapNode } from './map-node.js';
4445

4546
export { registerLogicNodes } from './logic-nodes.js';
4647
export { registerLoopNode } from './loop-node.js';
@@ -53,6 +54,7 @@ export { registerConnectorNodes } from './connector-nodes.js';
5354
export { registerNotifyNode } from './notify-node.js';
5455
export { registerWaitNode, parseIsoDuration, rearmSuspendedWaitTimers } from './wait-node.js';
5556
export { registerSubflowNode } from './subflow-node.js';
57+
export { registerMapNode } from './map-node.js';
5658

5759
/**
5860
* Seed every built-in node executor into the engine. Called by
@@ -71,6 +73,7 @@ export function installBuiltinNodes(engine: AutomationEngine, ctx: PluginContext
7173
registerNotifyNode(engine, ctx);
7274
registerWaitNode(engine, ctx);
7375
registerSubflowNode(engine, ctx);
76+
registerMapNode(engine, ctx);
7477

7578
const types = engine.getRegisteredNodeTypes();
7679
ctx.logger.info(
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { AutomationEngine } from '../engine.js';
5+
import type { NodeExecutor } from '../engine.js';
6+
import { InMemorySuspendedRunStore } from '../suspended-run-store.js';
7+
import { registerMapNode } from './map-node.js';
8+
9+
function silentLogger() {
10+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
11+
}
12+
function ctx() {
13+
return { logger: silentLogger(), getService() { throw new Error('none'); } } as any;
14+
}
15+
16+
/**
17+
* Wire an engine with the `map` node, a per-item child flow, and a parent flow
18+
* that maps over `{items}`. `childNodes` is the child flow's middle node(s)
19+
* (between start and end) — pass a `pauser` to exercise per-item durable pause.
20+
*/
21+
function setup(childNodes: Array<{ id: string; type: string }>, captured: unknown[]) {
22+
const engine = new AutomationEngine(silentLogger());
23+
registerMapNode(engine, ctx());
24+
25+
// Child marker: copies the mapped item (passed as params.val) to the child's
26+
// output variable `result`.
27+
engine.registerNodeExecutor({
28+
type: 'itemmark',
29+
async execute(_node, variables, context) {
30+
variables.set('result', (context as any)?.params?.val);
31+
return { success: true };
32+
},
33+
} as NodeExecutor);
34+
// Pauses the child (stands in for an approval / screen / wait).
35+
engine.registerNodeExecutor({
36+
type: 'pauser',
37+
async execute() { return { success: true, suspend: true }; },
38+
} as NodeExecutor);
39+
// Fails the child terminally.
40+
engine.registerNodeExecutor({
41+
type: 'failer',
42+
async execute() { return { success: false, error: 'boom' }; },
43+
} as NodeExecutor);
44+
// Parent checker after the map node: captures the collected results array.
45+
engine.registerNodeExecutor({
46+
type: 'mapcheck',
47+
async execute(_node, variables) {
48+
captured.push(variables.get('mapped'));
49+
return { success: true };
50+
},
51+
} as NodeExecutor);
52+
53+
const seq = [{ id: 'cs', type: 'start' }, ...childNodes, { id: 'ce', type: 'end' }];
54+
engine.registerFlow('child_flow', {
55+
name: 'child_flow',
56+
label: 'Child',
57+
type: 'autolaunched',
58+
variables: [{ name: 'result', type: 'text', isOutput: true }],
59+
nodes: seq.map(n => ({ label: n.id, ...n })),
60+
edges: seq.slice(0, -1).map((n, i) => ({ id: `c${i}`, source: n.id, target: seq[i + 1].id })),
61+
} as never);
62+
63+
engine.registerFlow('parent_flow', {
64+
name: 'parent_flow',
65+
label: 'Parent',
66+
type: 'autolaunched',
67+
variables: [{ name: 'items', type: 'list', isInput: true }],
68+
nodes: [
69+
{ id: 'ps', type: 'start', label: 'Start' },
70+
{
71+
id: 'do_map', type: 'map', label: 'For each',
72+
config: { flowName: 'child_flow', collection: '{items}', iteratorVariable: 'item', input: { val: '{item}' }, outputVariable: 'mapped' },
73+
},
74+
{ id: 'chk', type: 'mapcheck', label: 'Check' },
75+
{ id: 'pe', type: 'end', label: 'End' },
76+
],
77+
edges: [
78+
{ id: 'p1', source: 'ps', target: 'do_map' },
79+
{ id: 'p2', source: 'do_map', target: 'chk' },
80+
{ id: 'p3', source: 'chk', target: 'pe' },
81+
],
82+
} as never);
83+
84+
return engine;
85+
}
86+
87+
const childRunId = (engine: AutomationEngine) =>
88+
engine.listSuspendedRuns().find(r => r.flowName === 'child_flow')?.runId;
89+
90+
describe('map node executor (sequential multi-instance)', () => {
91+
let captured: unknown[];
92+
beforeEach(() => { captured = []; });
93+
94+
it('runs a synchronous per-item subflow over the collection, collecting results in order', async () => {
95+
const engine = setup([{ id: 'cm', type: 'itemmark' }], captured);
96+
const result = await engine.execute('parent_flow', { params: { items: ['a', 'b', 'c'] } });
97+
98+
expect(result.success).toBe(true);
99+
expect(result.status).toBeUndefined(); // ran to completion, no pause
100+
expect(captured).toEqual([[{ result: 'a' }, { result: 'b' }, { result: 'c' }]]);
101+
expect(engine.listSuspendedRuns()).toHaveLength(0);
102+
});
103+
104+
it('completes immediately on an empty collection', async () => {
105+
const engine = setup([{ id: 'cm', type: 'itemmark' }], captured);
106+
const result = await engine.execute('parent_flow', { params: { items: [] } });
107+
expect(result.success).toBe(true);
108+
expect(captured).toEqual([[]]);
109+
});
110+
111+
it('drives items ONE AT A TIME when each subflow pauses, re-entering on resume', async () => {
112+
const engine = setup([{ id: 'cp', type: 'pauser' }, { id: 'cm', type: 'itemmark' }], captured);
113+
114+
// Item 0 pauses → the parent suspends at the map node.
115+
const r0 = await engine.execute('parent_flow', { params: { items: ['a', 'b', 'c'] } });
116+
expect(r0.status).toBe('paused');
117+
const parent = engine.listSuspendedRuns().find(x => x.flowName === 'parent_flow')!;
118+
expect(parent.nodeId).toBe('do_map');
119+
expect(parent.correlation).toMatch(/^map:/);
120+
expect(captured).toHaveLength(0); // not done yet
121+
122+
// Resume item 0's child → bubbles → re-enters map → item 1 pauses.
123+
const id0 = childRunId(engine)!;
124+
const after0 = await engine.resume(id0);
125+
expect(after0.success).toBe(true);
126+
expect(engine.listSuspendedRuns().some(x => x.flowName === 'parent_flow')).toBe(true); // still paused
127+
expect(captured).toHaveLength(0);
128+
129+
// Resume item 1 → item 2 pauses.
130+
await engine.resume(childRunId(engine)!);
131+
expect(captured).toHaveLength(0);
132+
133+
// Resume item 2 → all done → parent continues past the map node.
134+
await engine.resume(childRunId(engine)!);
135+
expect(captured).toEqual([[{ result: 'a' }, { result: 'b' }, { result: 'c' }]]);
136+
expect(engine.listSuspendedRuns()).toHaveLength(0);
137+
});
138+
139+
it('fails the map fast when an item subflow fails', async () => {
140+
const engine = setup([{ id: 'cf', type: 'failer' }], captured);
141+
const result = await engine.execute('parent_flow', { params: { items: ['a', 'b'] } });
142+
expect(result.success).toBe(false);
143+
expect(result.error).toMatch(/item 0.*failed/i);
144+
expect(captured).toEqual([]); // downstream never ran
145+
});
146+
147+
it('survives a process restart mid-map: resume on a fresh engine continues the sequence', async () => {
148+
const store = new InMemorySuspendedRunStore();
149+
const engine = setup([{ id: 'cp', type: 'pauser' }, { id: 'cm', type: 'itemmark' }], captured);
150+
engine.setSuspendedRunStore(store);
151+
152+
await engine.execute('parent_flow', { params: { items: ['a', 'b'] } });
153+
const id0 = childRunId(engine)!;
154+
expect((await store.list()).length).toBe(2); // parent (map) + child item 0
155+
156+
// "Restart": a fresh engine sharing only the durable store + the registry.
157+
const capturedB: unknown[] = [];
158+
const engineB = setup([{ id: 'cp', type: 'pauser' }, { id: 'cm', type: 'itemmark' }], capturedB);
159+
engineB.setSuspendedRunStore(store);
160+
161+
await engineB.resume(id0); // item 0 done → item 1 pauses
162+
await engineB.resume(childRunId(engineB)!); // item 1 done → all done
163+
expect(capturedB).toEqual([[{ result: 'a' }, { result: 'b' }]]);
164+
expect(await store.list()).toHaveLength(0);
165+
});
166+
});

0 commit comments

Comments
 (0)