Skip to content

Commit b913255

Browse files
os-zhuangclaude
andcommitted
test(automation)+showcase: nested control-flow composition
Single-construct tests cover loop/parallel/try_catch in isolation; nothing exercised their INTERACTIONS when nested. Adds both the deterministic proof and a worked composite example. Engine tests (nested-composition.test.ts, +3): - parallel INSIDE loop: the loop iterator is visible to a node in a nested parallel branch (scope flows in); step folding tags each step with its INNERMOST container (leaf → parallel-branch/inner_par; the parallel node → loop-body/outer_loop); the after-block continuation runs once. - loop INSIDE try_catch: deepest step folds to the loop, the loop folds to the try region. - a mutation made deep inside nested regions is visible to the after-block (regions run in the enclosing scope; last iteration wins). Showcase (showcase_project_escalation): on health → red, decision branches on severity → critical path runs a parallel alert block then a try/catch incident push (catch logs the failure); normal path sends one notification; both converge. Combines decision + parallel + try_catch with converging edges in one realistic flow. Full automation suite 180 green. Browser-verified: critical path ran start→triage→alert(parallel ×2)→push_incident(catch on http failure)→converge; normal path took the single-notify branch; the designer renders the nested structure with correct construct icons + conditional edges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a91ec93 commit b913255

2 files changed

Lines changed: 283 additions & 0 deletions

File tree

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

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

920+
/**
921+
* Project Escalation — the worked **composite** example: several constructs
922+
* nested in one realistic flow, where every other showcase flow demos one
923+
* construct in isolation. When a project's health turns red:
924+
*
925+
* decision (critical budget?)
926+
* ├─ critical → parallel { alert owner ∥ alert exec } → try/catch {
927+
* │ push to the incident system, catch → log the failure }
928+
* └─ normal → a single owner notification
929+
* → converge → end
930+
*
931+
* It exercises construct **interactions** (parallel + try/catch under a decision
932+
* branch, converging edges) that single-construct flows don't — and runs
933+
* synchronously (no pause), so it completes in one pass and is fully visible in
934+
* the Runs panel with nested step folding.
935+
*/
936+
export const ProjectEscalationFlow = defineFlow({
937+
name: 'showcase_project_escalation',
938+
label: 'Project Escalation (composite)',
939+
description: 'On health → red, branches on severity then alerts in parallel and pushes to an incident system with try/catch — demonstrates nested construct composition.',
940+
type: 'autolaunched',
941+
nodes: [
942+
{
943+
id: 'start',
944+
type: 'start',
945+
label: 'On Health Red',
946+
config: {
947+
objectName: 'showcase_project',
948+
triggerType: 'record-after-update',
949+
condition: 'health == "red" && previous.health != "red"',
950+
},
951+
},
952+
{ id: 'triage', type: 'decision', label: 'Critical budget?' },
953+
{
954+
id: 'alert',
955+
type: 'parallel',
956+
label: 'Alert in parallel',
957+
config: {
958+
branches: [
959+
{
960+
name: 'Owner',
961+
nodes: [{ id: 'alert_owner', type: 'script', label: 'Alert Owner', config: { actionType: 'email', inputs: { to: '{record.owner}', subject: '🔴 Critical: {record.name}' } } }],
962+
edges: [],
963+
},
964+
{
965+
name: 'Exec',
966+
nodes: [{ id: 'alert_exec', type: 'script', label: 'Alert Exec', config: { actionType: 'email', inputs: { to: 'exec@example.com', subject: '🔴 Critical project: {record.name}' } } }],
967+
edges: [],
968+
},
969+
],
970+
},
971+
},
972+
{
973+
id: 'push_incident',
974+
type: 'try_catch',
975+
label: 'Push to incident system',
976+
config: {
977+
retry: { maxRetries: 2, retryDelayMs: 500, backoffMultiplier: 2 },
978+
errorVariable: '$error',
979+
try: {
980+
nodes: [{ id: 'push', type: 'http_request', label: 'POST incident', config: { url: 'https://api.example.com/v1/incidents', method: 'POST', body: { project: '{record.id}', severity: 'critical' } } }],
981+
edges: [],
982+
},
983+
catch: {
984+
nodes: [{ id: 'log_fail', type: 'notify', label: 'Log push failure', config: { topic: 'project.escalation', recipients: ['admin@objectos.ai'], channels: ['inbox'], severity: 'warning', title: 'Incident push failed: {record.name}', message: 'Could not reach the incident system: {$error.message}' } }],
985+
edges: [],
986+
},
987+
},
988+
},
989+
{
990+
id: 'notify_normal',
991+
type: 'notify',
992+
label: 'Notify Owner',
993+
config: { topic: 'project.escalation', recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', title: 'Project needs attention: {record.name}', message: 'Health dropped to red — please review.' },
994+
},
995+
{
996+
id: 'converge',
997+
type: 'notify',
998+
label: 'Escalation Handled',
999+
config: { topic: 'project.escalation', recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', title: 'Escalation handled: {record.name}', message: 'The red-health escalation has been processed.' },
1000+
},
1001+
{ id: 'end', type: 'end', label: 'End' },
1002+
],
1003+
edges: [
1004+
{ id: 'e1', source: 'start', target: 'triage' },
1005+
{ id: 'e2', source: 'triage', target: 'alert', label: 'critical', condition: 'budget > 200000' },
1006+
{ id: 'e3', source: 'triage', target: 'notify_normal', label: 'normal', condition: 'budget <= 200000' },
1007+
{ id: 'e4', source: 'alert', target: 'push_incident' },
1008+
{ id: 'e5', source: 'push_incident', target: 'converge' },
1009+
{ id: 'e6', source: 'notify_normal', target: 'converge' },
1010+
{ id: 'e7', source: 'converge', target: 'end' },
1011+
],
1012+
});
1013+
9201014
/**
9211015
* One Task Sign-off — a reusable per-item **approval subflow**, invoked once
9221016
* per task by {@link ReleaseSignoffFlow}'s `map` node. The mapped task is
@@ -1036,4 +1130,5 @@ export const allFlows = [
10361130
BatchRemindersFlow,
10371131
FanOutNotifyFlow,
10381132
ResilientSyncFlow,
1133+
ProjectEscalationFlow,
10391134
];
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Nested control-flow composition (ADR-0031) — interactions the single-construct
5+
* tests don't exercise: a `parallel` block inside a `loop` body, a `loop` inside
6+
* a `try_catch` try region. Asserts the three things that only break when
7+
* constructs are nested:
8+
* 1. variable scope flows INTO nested regions (the loop iterator is visible to
9+
* a node inside a nested parallel branch);
10+
* 2. mutations inside a nested region propagate OUT to the enclosing scope;
11+
* 3. step-log folding tags each step with its INNERMOST container
12+
* (parentNodeId / regionKind), and the after-block continuation runs once.
13+
*/
14+
15+
import { describe, it, expect, beforeEach } from 'vitest';
16+
import { AutomationEngine } from '../engine.js';
17+
import type { NodeExecutor } from '../engine.js';
18+
import { registerLoopNode } from './loop-node.js';
19+
import { registerParallelNode } from './parallel-node.js';
20+
import { registerTryCatchNode } from './try-catch-node.js';
21+
22+
function silentLogger() {
23+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
24+
}
25+
function ctx() {
26+
return { logger: silentLogger(), getService() { throw new Error('none'); } } as any;
27+
}
28+
29+
describe('nested control-flow composition (ADR-0031)', () => {
30+
let engine: AutomationEngine;
31+
let collected: Array<{ tag: string; cur: unknown }>;
32+
33+
beforeEach(() => {
34+
engine = new AutomationEngine(silentLogger());
35+
collected = [];
36+
registerLoopNode(engine, ctx());
37+
registerParallelNode(engine, ctx());
38+
registerTryCatchNode(engine, ctx());
39+
40+
// Leaf: records the tag + the loop iterator value it can see (proves scope
41+
// flows into the nested region), and mutates an enclosing-scope variable.
42+
engine.registerNodeExecutor({
43+
type: 'collect',
44+
async execute(node, variables) {
45+
const tag = String((node.config as any)?.tag ?? 'leaf');
46+
const cur = variables.get('cur');
47+
collected.push({ tag, cur });
48+
variables.set('lastSeen', `${cur}:${tag}`);
49+
return { success: true };
50+
},
51+
} as NodeExecutor);
52+
// A node after the outer container, to prove the after-block continuation.
53+
engine.registerNodeExecutor({
54+
type: 'after',
55+
async execute(_n, variables) {
56+
variables.set('afterRan', true);
57+
return { success: true };
58+
},
59+
} as NodeExecutor);
60+
});
61+
62+
it('parallel INSIDE loop: iterator visible in branches, mutations propagate, steps fold to innermost', async () => {
63+
engine.registerFlow('nested', {
64+
name: 'nested', label: 'Nested', type: 'autolaunched',
65+
variables: [{ name: 'items', type: 'list', isInput: true }],
66+
nodes: [
67+
{ id: 's', type: 'start', label: 'Start' },
68+
{
69+
id: 'outer_loop', type: 'loop', label: 'For each',
70+
config: {
71+
collection: '{items}', iteratorVariable: 'cur',
72+
body: {
73+
nodes: [{
74+
id: 'inner_par', type: 'parallel', label: 'Fan',
75+
config: {
76+
branches: [
77+
{ name: 'A', nodes: [{ id: 'leafA', type: 'collect', label: 'A', config: { tag: 'A' } }], edges: [] },
78+
{ name: 'B', nodes: [{ id: 'leafB', type: 'collect', label: 'B', config: { tag: 'B' } }], edges: [] },
79+
],
80+
},
81+
}],
82+
edges: [],
83+
},
84+
},
85+
},
86+
{ id: 'aft', type: 'after', label: 'After' },
87+
{ id: 'e', type: 'end', label: 'End' },
88+
],
89+
edges: [
90+
{ id: 'e1', source: 's', target: 'outer_loop' },
91+
{ id: 'e2', source: 'outer_loop', target: 'aft' },
92+
{ id: 'e3', source: 'aft', target: 'e' },
93+
],
94+
} as never);
95+
96+
const result = await engine.execute('nested', { params: { items: ['x', 'y'] } });
97+
expect(result.success).toBe(true);
98+
99+
// 1. Scope flows in: every leaf saw the right loop iterator value.
100+
const seen = collected.map(c => `${c.cur}:${c.tag}`).sort();
101+
expect(seen).toEqual(['x:A', 'x:B', 'y:A', 'y:B']);
102+
103+
// 3. Step folding: a leaf's INNERMOST container is the parallel block; the
104+
// parallel block's own container is the loop body.
105+
const steps = (await engine.listRuns('nested'))[0].steps;
106+
const leafStep = steps.find(s => s.nodeId === 'leafA');
107+
expect(leafStep?.parentNodeId).toBe('inner_par');
108+
expect(leafStep?.regionKind).toBe('parallel-branch');
109+
const parStep = steps.find(s => s.nodeId === 'inner_par');
110+
expect(parStep?.parentNodeId).toBe('outer_loop');
111+
expect(parStep?.regionKind).toBe('loop-body');
112+
113+
// after-block continuation ran exactly once.
114+
expect(steps.filter(s => s.nodeId === 'aft')).toHaveLength(1);
115+
});
116+
117+
it('loop INSIDE try_catch: deepest step folds to the loop, loop folds to the try region', async () => {
118+
engine.registerFlow('tc_nested', {
119+
name: 'tc_nested', label: 'TC Nested', type: 'autolaunched',
120+
variables: [{ name: 'items', type: 'list', isInput: true }],
121+
nodes: [
122+
{ id: 's', type: 'start', label: 'Start' },
123+
{
124+
id: 'guard', type: 'try_catch', label: 'Guard',
125+
config: {
126+
try: {
127+
nodes: [{
128+
id: 'tc_loop', type: 'loop', label: 'Loop',
129+
config: {
130+
collection: '{items}', iteratorVariable: 'cur',
131+
body: { nodes: [{ id: 'leaf', type: 'collect', label: 'L', config: { tag: 'L' } }], edges: [] },
132+
},
133+
}],
134+
edges: [],
135+
},
136+
},
137+
},
138+
{ id: 'aft', type: 'after', label: 'After' },
139+
{ id: 'e', type: 'end', label: 'End' },
140+
],
141+
edges: [
142+
{ id: 'e1', source: 's', target: 'guard' },
143+
{ id: 'e2', source: 'guard', target: 'aft' },
144+
{ id: 'e3', source: 'aft', target: 'e' },
145+
],
146+
} as never);
147+
148+
const result = await engine.execute('tc_nested', { params: { items: ['p', 'q'] } });
149+
expect(result.success).toBe(true);
150+
expect(collected.map(c => `${c.cur}:${c.tag}`).sort()).toEqual(['p:L', 'q:L']);
151+
152+
const steps = (await engine.listRuns('tc_nested'))[0].steps;
153+
const leafStep = steps.find(s => s.nodeId === 'leaf');
154+
expect(leafStep?.parentNodeId).toBe('tc_loop'); // innermost = the loop body
155+
expect(leafStep?.regionKind).toBe('loop-body');
156+
const loopStep = steps.find(s => s.nodeId === 'tc_loop');
157+
expect(loopStep?.parentNodeId).toBe('guard'); // loop sits in the try region
158+
expect(loopStep?.regionKind).toBe('try');
159+
});
160+
161+
it('a mutation made deep inside nested regions is visible to the after-block (enclosing scope)', async () => {
162+
engine.registerFlow('scope', {
163+
name: 'scope', label: 'Scope', type: 'autolaunched',
164+
variables: [{ name: 'items', type: 'list', isInput: true }, { name: 'lastSeen', type: 'text', isOutput: true }],
165+
nodes: [
166+
{ id: 's', type: 'start', label: 'Start' },
167+
{
168+
id: 'outer_loop', type: 'loop', label: 'For each',
169+
config: {
170+
collection: '{items}', iteratorVariable: 'cur',
171+
body: { nodes: [{ id: 'leaf', type: 'collect', label: 'Z', config: { tag: 'Z' } }], edges: [] },
172+
},
173+
},
174+
{ id: 'e', type: 'end', label: 'End' },
175+
],
176+
edges: [
177+
{ id: 'e1', source: 's', target: 'outer_loop' },
178+
{ id: 'e2', source: 'outer_loop', target: 'e' },
179+
],
180+
} as never);
181+
182+
const result = await engine.execute('scope', { params: { items: ['m', 'n'] } });
183+
expect(result.success).toBe(true);
184+
// The loop body's mutation of `lastSeen` survived to the flow output — the
185+
// region ran in the enclosing scope, last iteration wins.
186+
expect(result.output?.lastSeen).toBe('n:Z');
187+
});
188+
});

0 commit comments

Comments
 (0)