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
95 changes: 95 additions & 0 deletions examples/app-showcase/src/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1036,4 +1130,5 @@ export const allFlows = [
BatchRemindersFlow,
FanOutNotifyFlow,
ResilientSyncFlow,
ProjectEscalationFlow,
];
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading