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
24 changes: 24 additions & 0 deletions .changeset/structured-try-catch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-automation": minor
---

feat(automation): structured try/catch/retry block (ADR-0031, task 4)

Implement engine execution for the `try_catch` construct — structured error
handling (ADR-0031 §Decision 3). The node runs a protected `try` region; on
failure it retries with exponential backoff (`config.retry`), and if it still
fails the optional `catch` region runs with the caught error bound to
`config.errorVariable` (default `$error`). Both regions execute in the enclosing
variable scope via `AutomationEngine.runRegion`.

- New `builtin/try-catch-node.ts` executor (registered as a built-in).
- `try` success (incl. a successful retry) → node succeeds; `catch` handling a
failure → node succeeds; no `catch` / failing `catch` → node fails to the
flow's fault edge / error handling.
- Well-formedness (single-entry/single-exit `try`/`catch` regions) is already
enforced at `registerFlow()` by `validateControlFlow` (shipped with the loop
container).

Showcase `ResilientSyncFlow` demonstrates the construct. This completes the
native control-flow execution trio (loop / parallel / try-catch); BPMN interop
mapping remains a follow-up (#1479 task 5).
73 changes: 73 additions & 0 deletions examples/app-showcase/src/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,78 @@ export const FanOutNotifyFlow = defineFlow({
],
});

/**
* Resilient Sync — demonstrates the ADR-0031 **try/catch/retry** construct.
*
* The `try_catch` node runs a protected `try` region (an outbound HTTP push);
* on failure it retries with exponential backoff, and if it still fails the
* `catch` region records the failure with the caught error bound to `$error`.
* Both regions are single-entry/single-exit and run in the enclosing scope; the
* node's ordinary out-edge (`→ end`) is the after-block continuation.
*/
export const ResilientSyncFlow = defineFlow({
name: 'showcase_resilient_sync',
label: 'Resilient Sync (Try/Catch/Retry)',
description: 'Pushes a task to an external system, retrying on failure and recording errors via try/catch (ADR-0031).',
type: 'autolaunched',
nodes: [
{
id: 'start',
type: 'start',
label: 'On Task Completed',
config: {
objectName: 'showcase_task',
triggerType: 'record-after-update',
condition: 'status == "done" && previous.status != "done"',
},
},
{
id: 'guarded_push',
type: 'try_catch',
label: 'Push with retry',
config: {
retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2, maxRetryDelayMs: 10000 },
errorVariable: '$error',
try: {
nodes: [
{
id: 'push',
type: 'http_request',
label: 'Push to CRM',
config: {
url: 'https://api.example.com/v1/tasks',
method: 'POST',
body: { id: '{record.id}', title: '{record.title}', status: 'done' },
},
},
],
edges: [],
},
catch: {
nodes: [
{
id: 'record_failure',
type: 'update_record',
label: 'Flag Sync Failure',
config: {
objectName: 'showcase_task',
filter: { id: '{record.id}' },
fields: { sync_status: 'failed', sync_error: '{$error.message}' },
},
},
],
edges: [],
},
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'guarded_push' },
{ id: 'e2', source: 'guarded_push', target: 'end' },
],
});

export const allFlows = [
TaskCompletedFlow,
ReassignWizardFlow,
Expand All @@ -646,4 +718,5 @@ export const allFlows = [
TaskDoneNotifyOwnerFlow,
BatchRemindersFlow,
FanOutNotifyFlow,
ResilientSyncFlow,
];
4 changes: 4 additions & 0 deletions packages/services/service-automation/src/builtin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* - logic — decision / assignment (engine core)
* - logic — loop (structured iteration container, ADR-0031)
* - logic — parallel (structured parallel block, implicit join, ADR-0031)
* - logic — try_catch (structured try/catch/retry, ADR-0031)
* - data — get/create/update/delete_record (platform CRUD baseline)
* - human — screen / script (core flow capability)
* - io — http_request (foundational outbound I/O)
Expand All @@ -32,6 +33,7 @@ import type { AutomationEngine } from '../engine.js';
import { registerLogicNodes } from './logic-nodes.js';
import { registerLoopNode } from './loop-node.js';
import { registerParallelNode } from './parallel-node.js';
import { registerTryCatchNode } from './try-catch-node.js';
import { registerCrudNodes } from './crud-nodes.js';
import { registerScreenNodes } from './screen-nodes.js';
import { registerHttpNodes } from './http-nodes.js';
Expand All @@ -43,6 +45,7 @@ import { registerSubflowNode } from './subflow-node.js';
export { registerLogicNodes } from './logic-nodes.js';
export { registerLoopNode } from './loop-node.js';
export { registerParallelNode } from './parallel-node.js';
export { registerTryCatchNode } from './try-catch-node.js';
export { registerCrudNodes } from './crud-nodes.js';
export { registerScreenNodes } from './screen-nodes.js';
export { registerHttpNodes } from './http-nodes.js';
Expand All @@ -60,6 +63,7 @@ export function installBuiltinNodes(engine: AutomationEngine, ctx: PluginContext
registerLogicNodes(engine, ctx);
registerLoopNode(engine, ctx);
registerParallelNode(engine, ctx);
registerTryCatchNode(engine, ctx);
registerCrudNodes(engine, ctx);
registerScreenNodes(engine, ctx);
registerHttpNodes(engine, ctx);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.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('try/catch/retry executor (ADR-0031)', () => {
let engine: AutomationEngine;
let ran: string[];
let attempts: number;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
ran = [];
attempts = 0;
registerTryCatchNode(engine, ctx());

engine.registerNodeExecutor({
type: 'ok',
async execute(node) { ran.push((node.config as any)?.tag ?? 'ok'); return { success: true }; },
} as NodeExecutor);

// Always fails.
engine.registerNodeExecutor({
type: 'boom',
async execute() { ran.push('boom'); return { success: false, error: 'kaboom' }; },
} as NodeExecutor);

// Fails the first N times (config.failTimes), then succeeds — for retry tests.
engine.registerNodeExecutor({
type: 'flaky',
async execute(node) {
attempts++;
const failTimes = Number((node.config as any)?.failTimes ?? 0);
ran.push(`flaky#${attempts}`);
if (attempts <= failTimes) return { success: false, error: `transient ${attempts}` };
return { success: true };
},
} as NodeExecutor);

// Reads the caught error variable.
engine.registerNodeExecutor({
type: 'handler',
async execute(node, variables) {
const v = (node.config as any)?.errVar ?? '$error';
ran.push(`handler:${JSON.stringify(variables.get(v))}`);
return { success: true };
},
} as NodeExecutor);
});

const tcFlow = (tcConfig: Record<string, unknown>) => ({
name: 'tc_flow',
label: 'TryCatch Flow',
type: 'autolaunched' as const,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'tc', type: 'try_catch', label: 'Guarded', config: tcConfig },
{ id: 'after', type: 'ok', label: 'After', config: { tag: 'after' } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'tc' },
{ id: 'e2', source: 'tc', target: 'after' },
{ id: 'e3', source: 'after', target: 'end' },
],
});

it('runs the try region and continues when it succeeds (no catch invoked)', async () => {
engine.registerFlow('tc_flow', tcFlow({
try: { nodes: [{ id: 't', type: 'ok', label: 'T', config: { tag: 'try' } }], edges: [] },
catch: { nodes: [{ id: 'c', type: 'ok', label: 'C', config: { tag: 'catch' } }], edges: [] },
}));

const result = await engine.execute('tc_flow');
expect(result.success).toBe(true);
expect(ran).toEqual(['try', 'after']); // catch not run, downstream continued
});

it('runs the catch region when the try region fails, binding the error', async () => {
engine.registerFlow('tc_flow', tcFlow({
try: { nodes: [{ id: 't', type: 'boom', label: 'T' }], edges: [] },
catch: { nodes: [{ id: 'c', type: 'handler', label: 'C' }], edges: [] },
}));

const result = await engine.execute('tc_flow');
expect(result.success).toBe(true); // error handled by catch
expect(ran[0]).toBe('boom');
expect(ran.some(r => r.startsWith('handler:') && r.includes('kaboom'))).toBe(true);
expect(ran[ran.length - 1]).toBe('after'); // downstream continued after catch
});

it('retries the try region with backoff and succeeds without running catch', async () => {
engine.registerFlow('tc_flow', tcFlow({
try: { nodes: [{ id: 't', type: 'flaky', label: 'T', config: { failTimes: 2 } }], edges: [] },
catch: { nodes: [{ id: 'c', type: 'boom', label: 'C' }], edges: [] },
retry: { maxRetries: 3, retryDelayMs: 0 },
}));

const result = await engine.execute('tc_flow');
expect(result.success).toBe(true);
expect(attempts).toBe(3); // failed twice, succeeded on the third
expect(ran).not.toContain('boom'); // catch never ran
expect(ran[ran.length - 1]).toBe('after');
});

it('falls through to catch after exhausting retries', async () => {
engine.registerFlow('tc_flow', tcFlow({
try: { nodes: [{ id: 't', type: 'flaky', label: 'T', config: { failTimes: 99 } }], edges: [] },
catch: { nodes: [{ id: 'c', type: 'ok', label: 'C', config: { tag: 'catch' } }], edges: [] },
retry: { maxRetries: 2, retryDelayMs: 0 },
}));

const result = await engine.execute('tc_flow');
expect(result.success).toBe(true);
expect(attempts).toBe(3); // initial + 2 retries
expect(ran).toContain('catch');
});

it('fails the node when the try region fails and there is no catch', async () => {
engine.registerFlow('tc_flow', tcFlow({
try: { nodes: [{ id: 't', type: 'boom', label: 'T' }], edges: [] },
}));

const result = await engine.execute('tc_flow');
expect(result.success).toBe(false);
expect(result.error).toMatch(/try region failed.*kaboom/);
expect(ran).not.toContain('after'); // downstream did not run
});

it('fails the node when the catch region itself fails', async () => {
engine.registerFlow('tc_flow', tcFlow({
try: { nodes: [{ id: 't', type: 'boom', label: 'T' }], edges: [] },
catch: { nodes: [{ id: 'c', type: 'boom', label: 'C' }], edges: [] },
}));

const result = await engine.execute('tc_flow');
expect(result.success).toBe(false);
expect(result.error).toMatch(/catch region failed/);
});

it('rejects a malformed try region at registerFlow', () => {
expect(() =>
engine.registerFlow('bad_tc', tcFlow({
// two entry/exit nodes, no edges → not single-entry/single-exit
try: { nodes: [{ id: 'a', type: 'ok', label: 'A' }, { id: 'b', type: 'ok', label: 'B' }], edges: [] },
})),
).toThrow(/try_catch 'tc' try/);
});

it('runs a multi-node try region in order', async () => {
engine.registerFlow('tc_flow', tcFlow({
try: {
nodes: [
{ id: 't1', type: 'ok', label: 'T1', config: { tag: 't1' } },
{ id: 't2', type: 'ok', label: 'T2', config: { tag: 't2' } },
],
edges: [{ id: 'te', source: 't1', target: 't2' }],
},
}));

const result = await engine.execute('tc_flow');
expect(result.success).toBe(true);
expect(ran).toEqual(['t1', 't2', 'after']);
});
});
Loading