diff --git a/.changeset/structured-try-catch.md b/.changeset/structured-try-catch.md new file mode 100644 index 000000000..9ea9f12d8 --- /dev/null +++ b/.changeset/structured-try-catch.md @@ -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). diff --git a/examples/app-showcase/src/flows/index.ts b/examples/app-showcase/src/flows/index.ts index a7e32d1bc..18d9db2e5 100644 --- a/examples/app-showcase/src/flows/index.ts +++ b/examples/app-showcase/src/flows/index.ts @@ -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, @@ -646,4 +718,5 @@ export const allFlows = [ TaskDoneNotifyOwnerFlow, BatchRemindersFlow, FanOutNotifyFlow, + ResilientSyncFlow, ]; diff --git a/packages/services/service-automation/src/builtin/index.ts b/packages/services/service-automation/src/builtin/index.ts index c44ab3935..64e42e80a 100644 --- a/packages/services/service-automation/src/builtin/index.ts +++ b/packages/services/service-automation/src/builtin/index.ts @@ -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) @@ -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'; @@ -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'; @@ -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); diff --git a/packages/services/service-automation/src/builtin/try-catch-node.test.ts b/packages/services/service-automation/src/builtin/try-catch-node.test.ts new file mode 100644 index 000000000..6a5838666 --- /dev/null +++ b/packages/services/service-automation/src/builtin/try-catch-node.test.ts @@ -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) => ({ + 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']); + }); +}); diff --git a/packages/services/service-automation/src/builtin/try-catch-node.ts b/packages/services/service-automation/src/builtin/try-catch-node.ts new file mode 100644 index 000000000..5ae7591eb --- /dev/null +++ b/packages/services/service-automation/src/builtin/try-catch-node.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { PluginContext } from '@objectstack/core'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import type { FlowRegionParsed } from '@objectstack/spec/automation'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import type { AutomationEngine } from '../engine.js'; + +interface RetryPolicy { + maxRetries?: number; + retryDelayMs?: number; + backoffMultiplier?: number; + maxRetryDelayMs?: number; + jitter?: boolean; +} + +/** + * `try_catch` built-in node — **structured try/catch/retry** (ADR-0031 §Decision 3). + * + * Runs the protected `try` region; if it throws (a node fails), an optional + * `retry` policy re-runs the `try` region with exponential backoff. If the + * region still fails after retries, the optional `catch` region runs with the + * caught error bound to `errorVariable` (default `$error`). Both regions are + * self-contained single-entry/single-exit sub-graphs validated at + * `registerFlow()`, executed in the **enclosing variable scope** via + * {@link AutomationEngine.runRegion}. + * + * Outcome: + * - `try` (or a retry) succeeds → the node succeeds, downstream continues. + * - `try` exhausts retries, a `catch` is present and succeeds → the node + * succeeds (the error was handled). + * - `try` exhausts retries and there is **no** `catch` (or `catch` itself + * fails) → the node fails, surfacing to the flow's fault edge / error handling. + * + * This is the low-code-native error model — the same `fault` + exponential- + * backoff retry the engine already implements, surfaced as a construct rather + * than BPMN boundary events. + */ +export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContext): void { + engine.registerNodeExecutor({ + type: 'try_catch', + descriptor: defineActionDescriptor({ + type: 'try_catch', + version: '1.0.0', + name: 'Try / Catch', + description: 'Run a protected region with optional retry and a catch handler (structured error handling).', + icon: 'shield-alert', + category: 'logic', + source: 'builtin', + supportsRetry: true, + configSchema: { + type: 'object', + properties: { + try: { + type: 'object', + description: 'Protected region (single-entry/single-exit sub-graph)', + properties: { nodes: { type: 'array' }, edges: { type: 'array' } }, + }, + catch: { + type: 'object', + description: 'Handler region run when the try region fails', + properties: { nodes: { type: 'array' }, edges: { type: 'array' } }, + }, + errorVariable: { type: 'string', description: 'Variable holding the caught error in the catch region' }, + retry: { + type: 'object', + properties: { + maxRetries: { type: 'integer', minimum: 0, maximum: 10 }, + retryDelayMs: { type: 'integer', minimum: 0 }, + backoffMultiplier: { type: 'number', minimum: 1 }, + maxRetryDelayMs: { type: 'integer', minimum: 0 }, + jitter: { type: 'boolean' }, + }, + }, + }, + required: ['try'], + }, + }), + async execute(node, variables, context) { + const cfg = (node.config ?? {}) as Record; + const tryRegion = cfg.try as FlowRegionParsed | undefined; + const catchRegion = cfg.catch as FlowRegionParsed | undefined; + const errorVariable = + typeof cfg.errorVariable === 'string' && cfg.errorVariable ? cfg.errorVariable : '$error'; + const retry = (cfg.retry ?? {}) as RetryPolicy; + + if (tryRegion == null) { + return { success: false, error: `try_catch '${node.id}': config.try region is required` }; + } + + const ctxOrEmpty = context ?? ({} as AutomationContext); + const maxRetries = typeof retry.maxRetries === 'number' ? retry.maxRetries : 0; + const baseDelay = typeof retry.retryDelayMs === 'number' ? retry.retryDelayMs : 0; + const multiplier = typeof retry.backoffMultiplier === 'number' ? retry.backoffMultiplier : 1; + const maxDelay = typeof retry.maxRetryDelayMs === 'number' ? retry.maxRetryDelayMs : 30000; + const useJitter = retry.jitter === true; + + // Run the try region, retrying with exponential backoff up to maxRetries. + let lastError = 'unknown error'; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + let delay = Math.min(baseDelay * Math.pow(multiplier, attempt - 1), maxDelay); + if (useJitter) delay = delay * (0.5 + Math.random() * 0.5); + if (delay > 0) await new Promise(r => setTimeout(r, delay)); + } + try { + await engine.runRegion(tryRegion, variables, ctxOrEmpty); + return { success: true, output: { attempts: attempt + 1, caught: false } }; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + } + + // The try region (and any retries) failed. Run the catch handler if present. + if (catchRegion != null) { + variables.set(errorVariable, { nodeId: node.id, message: lastError }); + try { + await engine.runRegion(catchRegion, variables, ctxOrEmpty); + return { success: true, output: { attempts: maxRetries + 1, caught: true, error: lastError } }; + } catch (catchErr) { + const catchMsg = catchErr instanceof Error ? catchErr.message : String(catchErr); + return { success: false, error: `try_catch '${node.id}': catch region failed — ${catchMsg}` }; + } + } + + // No catch handler — surface the failure to the flow's fault edge / error handling. + return { success: false, error: `try_catch '${node.id}': try region failed — ${lastError}` }; + }, + }); + + ctx.logger.info('[TryCatch Node] 1 built-in node executor registered'); +}