From b9132559a602f9348417f1d7a8c150c21d97373f Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 12:49:42 +0500 Subject: [PATCH] test(automation)+showcase: nested control-flow composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/app-showcase/src/flows/index.ts | 95 +++++++++ .../src/builtin/nested-composition.test.ts | 188 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 packages/services/service-automation/src/builtin/nested-composition.test.ts diff --git a/examples/app-showcase/src/flows/index.ts b/examples/app-showcase/src/flows/index.ts index d435a1619..70632ce2b 100644 --- a/examples/app-showcase/src/flows/index.ts +++ b/examples/app-showcase/src/flows/index.ts @@ -917,6 +917,100 @@ export const InvoiceDualSignoffFlow = defineFlow({ ], }); +/** + * Project Escalation — the worked **composite** example: several constructs + * nested in one realistic flow, where every other showcase flow demos one + * construct in isolation. When a project's health turns red: + * + * decision (critical budget?) + * ├─ critical → parallel { alert owner ∥ alert exec } → try/catch { + * │ push to the incident system, catch → log the failure } + * └─ normal → a single owner notification + * → converge → end + * + * It exercises construct **interactions** (parallel + try/catch under a decision + * branch, converging edges) that single-construct flows don't — and runs + * synchronously (no pause), so it completes in one pass and is fully visible in + * the Runs panel with nested step folding. + */ +export const ProjectEscalationFlow = defineFlow({ + name: 'showcase_project_escalation', + label: 'Project Escalation (composite)', + description: 'On health → red, branches on severity then alerts in parallel and pushes to an incident system with try/catch — demonstrates nested construct composition.', + type: 'autolaunched', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Health Red', + config: { + objectName: 'showcase_project', + triggerType: 'record-after-update', + condition: 'health == "red" && previous.health != "red"', + }, + }, + { id: 'triage', type: 'decision', label: 'Critical budget?' }, + { + id: 'alert', + type: 'parallel', + label: 'Alert in parallel', + config: { + branches: [ + { + name: 'Owner', + nodes: [{ id: 'alert_owner', type: 'script', label: 'Alert Owner', config: { actionType: 'email', inputs: { to: '{record.owner}', subject: '🔴 Critical: {record.name}' } } }], + edges: [], + }, + { + name: 'Exec', + nodes: [{ id: 'alert_exec', type: 'script', label: 'Alert Exec', config: { actionType: 'email', inputs: { to: 'exec@example.com', subject: '🔴 Critical project: {record.name}' } } }], + edges: [], + }, + ], + }, + }, + { + id: 'push_incident', + type: 'try_catch', + label: 'Push to incident system', + config: { + retry: { maxRetries: 2, retryDelayMs: 500, backoffMultiplier: 2 }, + errorVariable: '$error', + try: { + 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' } } }], + edges: [], + }, + catch: { + 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}' } }], + edges: [], + }, + }, + }, + { + id: 'notify_normal', + type: 'notify', + label: 'Notify Owner', + config: { topic: 'project.escalation', recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', title: 'Project needs attention: {record.name}', message: 'Health dropped to red — please review.' }, + }, + { + id: 'converge', + type: 'notify', + label: 'Escalation Handled', + config: { topic: 'project.escalation', recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', title: 'Escalation handled: {record.name}', message: 'The red-health escalation has been processed.' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'triage' }, + { id: 'e2', source: 'triage', target: 'alert', label: 'critical', condition: 'budget > 200000' }, + { id: 'e3', source: 'triage', target: 'notify_normal', label: 'normal', condition: 'budget <= 200000' }, + { id: 'e4', source: 'alert', target: 'push_incident' }, + { id: 'e5', source: 'push_incident', target: 'converge' }, + { id: 'e6', source: 'notify_normal', target: 'converge' }, + { id: 'e7', source: 'converge', target: 'end' }, + ], +}); + /** * One Task Sign-off — a reusable per-item **approval subflow**, invoked once * per task by {@link ReleaseSignoffFlow}'s `map` node. The mapped task is @@ -1036,4 +1130,5 @@ export const allFlows = [ BatchRemindersFlow, FanOutNotifyFlow, ResilientSyncFlow, + ProjectEscalationFlow, ]; diff --git a/packages/services/service-automation/src/builtin/nested-composition.test.ts b/packages/services/service-automation/src/builtin/nested-composition.test.ts new file mode 100644 index 000000000..0e1b34113 --- /dev/null +++ b/packages/services/service-automation/src/builtin/nested-composition.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Nested control-flow composition (ADR-0031) — interactions the single-construct + * tests don't exercise: a `parallel` block inside a `loop` body, a `loop` inside + * a `try_catch` try region. Asserts the three things that only break when + * constructs are nested: + * 1. variable scope flows INTO nested regions (the loop iterator is visible to + * a node inside a nested parallel branch); + * 2. mutations inside a nested region propagate OUT to the enclosing scope; + * 3. step-log folding tags each step with its INNERMOST container + * (parentNodeId / regionKind), and the after-block continuation runs once. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import type { NodeExecutor } from '../engine.js'; +import { registerLoopNode } from './loop-node.js'; +import { registerParallelNode } from './parallel-node.js'; +import { registerTryCatchNode } from './try-catch-node.js'; + +function silentLogger() { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; +} +function ctx() { + return { logger: silentLogger(), getService() { throw new Error('none'); } } as any; +} + +describe('nested control-flow composition (ADR-0031)', () => { + let engine: AutomationEngine; + let collected: Array<{ tag: string; cur: unknown }>; + + beforeEach(() => { + engine = new AutomationEngine(silentLogger()); + collected = []; + registerLoopNode(engine, ctx()); + registerParallelNode(engine, ctx()); + registerTryCatchNode(engine, ctx()); + + // Leaf: records the tag + the loop iterator value it can see (proves scope + // flows into the nested region), and mutates an enclosing-scope variable. + engine.registerNodeExecutor({ + type: 'collect', + async execute(node, variables) { + const tag = String((node.config as any)?.tag ?? 'leaf'); + const cur = variables.get('cur'); + collected.push({ tag, cur }); + variables.set('lastSeen', `${cur}:${tag}`); + return { success: true }; + }, + } as NodeExecutor); + // A node after the outer container, to prove the after-block continuation. + engine.registerNodeExecutor({ + type: 'after', + async execute(_n, variables) { + variables.set('afterRan', true); + return { success: true }; + }, + } as NodeExecutor); + }); + + it('parallel INSIDE loop: iterator visible in branches, mutations propagate, steps fold to innermost', async () => { + engine.registerFlow('nested', { + name: 'nested', label: 'Nested', type: 'autolaunched', + variables: [{ name: 'items', type: 'list', isInput: true }], + nodes: [ + { id: 's', type: 'start', label: 'Start' }, + { + id: 'outer_loop', type: 'loop', label: 'For each', + config: { + collection: '{items}', iteratorVariable: 'cur', + body: { + nodes: [{ + id: 'inner_par', type: 'parallel', label: 'Fan', + config: { + branches: [ + { name: 'A', nodes: [{ id: 'leafA', type: 'collect', label: 'A', config: { tag: 'A' } }], edges: [] }, + { name: 'B', nodes: [{ id: 'leafB', type: 'collect', label: 'B', config: { tag: 'B' } }], edges: [] }, + ], + }, + }], + edges: [], + }, + }, + }, + { id: 'aft', type: 'after', label: 'After' }, + { id: 'e', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 's', target: 'outer_loop' }, + { id: 'e2', source: 'outer_loop', target: 'aft' }, + { id: 'e3', source: 'aft', target: 'e' }, + ], + } as never); + + const result = await engine.execute('nested', { params: { items: ['x', 'y'] } }); + expect(result.success).toBe(true); + + // 1. Scope flows in: every leaf saw the right loop iterator value. + const seen = collected.map(c => `${c.cur}:${c.tag}`).sort(); + expect(seen).toEqual(['x:A', 'x:B', 'y:A', 'y:B']); + + // 3. Step folding: a leaf's INNERMOST container is the parallel block; the + // parallel block's own container is the loop body. + const steps = (await engine.listRuns('nested'))[0].steps; + const leafStep = steps.find(s => s.nodeId === 'leafA'); + expect(leafStep?.parentNodeId).toBe('inner_par'); + expect(leafStep?.regionKind).toBe('parallel-branch'); + const parStep = steps.find(s => s.nodeId === 'inner_par'); + expect(parStep?.parentNodeId).toBe('outer_loop'); + expect(parStep?.regionKind).toBe('loop-body'); + + // after-block continuation ran exactly once. + expect(steps.filter(s => s.nodeId === 'aft')).toHaveLength(1); + }); + + it('loop INSIDE try_catch: deepest step folds to the loop, loop folds to the try region', async () => { + engine.registerFlow('tc_nested', { + name: 'tc_nested', label: 'TC Nested', type: 'autolaunched', + variables: [{ name: 'items', type: 'list', isInput: true }], + nodes: [ + { id: 's', type: 'start', label: 'Start' }, + { + id: 'guard', type: 'try_catch', label: 'Guard', + config: { + try: { + nodes: [{ + id: 'tc_loop', type: 'loop', label: 'Loop', + config: { + collection: '{items}', iteratorVariable: 'cur', + body: { nodes: [{ id: 'leaf', type: 'collect', label: 'L', config: { tag: 'L' } }], edges: [] }, + }, + }], + edges: [], + }, + }, + }, + { id: 'aft', type: 'after', label: 'After' }, + { id: 'e', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 's', target: 'guard' }, + { id: 'e2', source: 'guard', target: 'aft' }, + { id: 'e3', source: 'aft', target: 'e' }, + ], + } as never); + + const result = await engine.execute('tc_nested', { params: { items: ['p', 'q'] } }); + expect(result.success).toBe(true); + expect(collected.map(c => `${c.cur}:${c.tag}`).sort()).toEqual(['p:L', 'q:L']); + + const steps = (await engine.listRuns('tc_nested'))[0].steps; + const leafStep = steps.find(s => s.nodeId === 'leaf'); + expect(leafStep?.parentNodeId).toBe('tc_loop'); // innermost = the loop body + expect(leafStep?.regionKind).toBe('loop-body'); + const loopStep = steps.find(s => s.nodeId === 'tc_loop'); + expect(loopStep?.parentNodeId).toBe('guard'); // loop sits in the try region + expect(loopStep?.regionKind).toBe('try'); + }); + + it('a mutation made deep inside nested regions is visible to the after-block (enclosing scope)', async () => { + engine.registerFlow('scope', { + name: 'scope', label: 'Scope', type: 'autolaunched', + variables: [{ name: 'items', type: 'list', isInput: true }, { name: 'lastSeen', type: 'text', isOutput: true }], + nodes: [ + { id: 's', type: 'start', label: 'Start' }, + { + id: 'outer_loop', type: 'loop', label: 'For each', + config: { + collection: '{items}', iteratorVariable: 'cur', + body: { nodes: [{ id: 'leaf', type: 'collect', label: 'Z', config: { tag: 'Z' } }], edges: [] }, + }, + }, + { id: 'e', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 's', target: 'outer_loop' }, + { id: 'e2', source: 'outer_loop', target: 'e' }, + ], + } as never); + + const result = await engine.execute('scope', { params: { items: ['m', 'n'] } }); + expect(result.success).toBe(true); + // The loop body's mutation of `lastSeen` survived to the flow output — the + // region ran in the enclosing scope, last iteration wins. + expect(result.output?.lastSeen).toBe('n:Z'); + }); +});