From d11ee57acd74df9de63f22b08d1192db3ec2cd8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 02:38:52 +0000 Subject: [PATCH 1/3] Initial plan From baddb92443916b84c9578adb8c99ce44bf578bd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 02:44:41 +0000 Subject: [PATCH 2/3] feat(automation): add BPMN business semantics - parallel gateway, default flow, boundary events, wait events - Add `parallel_gateway`, `join_gateway`, `boundary_event` node types to FlowNodeAction - Add `isDefault` field and `conditional` edge type to FlowEdgeSchema - Add `waitEventConfig` to FlowNodeSchema for external event wakeup - Add `boundaryConfig` to FlowNodeSchema for BPMN boundary event pattern - Add `parallel_join` and `boundary_event` checkpoint reasons to execution.zod.ts - Add 19 comprehensive tests covering all new BPMN semantics Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../spec/src/automation/execution.test.ts | 2 +- packages/spec/src/automation/execution.zod.ts | 2 +- packages/spec/src/automation/flow.test.ts | 352 ++++++++++++++++++ packages/spec/src/automation/flow.zod.ts | 85 ++++- 4 files changed, 423 insertions(+), 18 deletions(-) diff --git a/packages/spec/src/automation/execution.test.ts b/packages/spec/src/automation/execution.test.ts index ebf3a1c519..f99a266cee 100644 --- a/packages/spec/src/automation/execution.test.ts +++ b/packages/spec/src/automation/execution.test.ts @@ -259,7 +259,7 @@ describe('CheckpointSchema', () => { }); it('should accept all valid checkpoint reasons', () => { - const reasons = ['wait', 'screen_input', 'approval', 'error', 'manual_pause']; + const reasons = ['wait', 'screen_input', 'approval', 'error', 'manual_pause', 'parallel_join', 'boundary_event']; reasons.forEach((r) => { const cp = CheckpointSchema.parse({ id: 'cp_test', diff --git a/packages/spec/src/automation/execution.zod.ts b/packages/spec/src/automation/execution.zod.ts index c432cbb47b..75b09c1007 100644 --- a/packages/spec/src/automation/execution.zod.ts +++ b/packages/spec/src/automation/execution.zod.ts @@ -178,7 +178,7 @@ export const CheckpointSchema = z.object({ expiresAt: z.string().datetime().optional().describe('Checkpoint expiration (auto-cleanup)'), /** Reason */ - reason: z.enum(['wait', 'screen_input', 'approval', 'error', 'manual_pause']) + reason: z.enum(['wait', 'screen_input', 'approval', 'error', 'manual_pause', 'parallel_join', 'boundary_event']) .describe('Why the execution was checkpointed'), }); export type Checkpoint = z.infer; diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index 6b8d5432cf..61f08f2c93 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -18,6 +18,7 @@ describe('FlowNodeAction', () => { 'start', 'end', 'decision', 'assignment', 'loop', 'create_record', 'update_record', 'delete_record', 'get_record', 'http_request', 'script', 'wait', 'subflow', + 'parallel_gateway', 'join_gateway', 'boundary_event', ]; actions.forEach(action => { @@ -120,6 +121,7 @@ describe('FlowNodeSchema', () => { 'start', 'end', 'decision', 'assignment', 'loop', 'create_record', 'update_record', 'delete_record', 'get_record', 'http_request', 'script', 'wait', 'subflow', + 'parallel_gateway', 'join_gateway', 'boundary_event', ] as const; types.forEach(type => { @@ -751,3 +753,353 @@ describe('FlowVersionHistorySchema', () => { expect(result.success).toBe(false); }); }); + +// ============================================================================ +// BPMN Business Semantics Tests +// ============================================================================ + +describe('BPMN — Parallel Gateway & Join Gateway', () => { + it('should accept parallel_gateway node type', () => { + const result = FlowNodeSchema.safeParse({ + id: 'pg_1', + type: 'parallel_gateway', + label: 'Fork — parallel approval', + position: { x: 200, y: 100 }, + }); + expect(result.success).toBe(true); + }); + + it('should accept join_gateway node type', () => { + const result = FlowNodeSchema.safeParse({ + id: 'jg_1', + type: 'join_gateway', + label: 'Join — wait for all branches', + position: { x: 200, y: 400 }, + }); + expect(result.success).toBe(true); + }); + + it('should validate a complete parallel approval flow', () => { + const flow: Flow = { + name: 'parallel_approval', + label: 'Parallel Approval Flow', + description: 'Demonstrates AND-split / AND-join for multi-department approval', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'fork', type: 'parallel_gateway', label: 'Fork — Parallel Approval' }, + { id: 'finance_review', type: 'connector_action', label: 'Finance Review' }, + { id: 'legal_review', type: 'connector_action', label: 'Legal Review' }, + { id: 'join', type: 'join_gateway', label: 'Join — All Approved' }, + { id: 'final_approve', type: 'update_record', label: 'Final Approve' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'fork' }, + { id: 'e2', source: 'fork', target: 'finance_review' }, + { id: 'e3', source: 'fork', target: 'legal_review' }, + { id: 'e4', source: 'finance_review', target: 'join' }, + { id: 'e5', source: 'legal_review', target: 'join' }, + { id: 'e6', source: 'join', target: 'final_approve' }, + { id: 'e7', source: 'final_approve', target: 'end' }, + ], + active: true, + runAs: 'system', + }; + + const result = FlowSchema.safeParse(flow); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.nodes).toHaveLength(7); + const gatewayTypes = result.data.nodes + .filter(n => n.type === 'parallel_gateway' || n.type === 'join_gateway') + .map(n => n.type); + expect(gatewayTypes).toEqual(['parallel_gateway', 'join_gateway']); + } + }); +}); + +describe('BPMN — Default Sequence Flow (isDefault)', () => { + it('should default isDefault to false', () => { + const result = FlowEdgeSchema.parse({ + id: 'e1', + source: 'a', + target: 'b', + }); + expect(result.isDefault).toBe(false); + }); + + it('should accept isDefault: true on an edge', () => { + const result = FlowEdgeSchema.parse({ + id: 'e_default', + source: 'decision_1', + target: 'fallback_node', + isDefault: true, + label: 'Default', + }); + expect(result.isDefault).toBe(true); + }); + + it('should accept conditional edge type', () => { + const result = FlowEdgeSchema.parse({ + id: 'e_cond', + source: 'decision_1', + target: 'branch_a', + type: 'conditional', + condition: '{amount} > 1000', + label: 'High Value', + }); + expect(result.type).toBe('conditional'); + expect(result.isDefault).toBe(false); + }); + + it('should validate a decision with default and conditional branches', () => { + const flow: Flow = { + name: 'default_branch_flow', + label: 'Default Branch Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'check_priority', type: 'decision', label: 'Check Priority' }, + { id: 'high_path', type: 'update_record', label: 'High Priority Handler' }, + { id: 'medium_path', type: 'update_record', label: 'Medium Priority Handler' }, + { id: 'default_path', type: 'update_record', label: 'Default Handler' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check_priority' }, + { id: 'e2', source: 'check_priority', target: 'high_path', type: 'conditional', condition: '{priority} == "high"' }, + { id: 'e3', source: 'check_priority', target: 'medium_path', type: 'conditional', condition: '{priority} == "medium"' }, + { id: 'e4', source: 'check_priority', target: 'default_path', isDefault: true, label: 'Default' }, + { id: 'e5', source: 'high_path', target: 'end' }, + { id: 'e6', source: 'medium_path', target: 'end' }, + { id: 'e7', source: 'default_path', target: 'end' }, + ], + }; + + const result = FlowSchema.safeParse(flow); + expect(result.success).toBe(true); + if (result.success) { + const defaultEdge = result.data.edges.find(e => e.isDefault); + expect(defaultEdge).toBeDefined(); + expect(defaultEdge!.target).toBe('default_path'); + } + }); +}); + +describe('BPMN — Wait Event Configuration', () => { + it('should accept wait node with timer event config', () => { + const result = FlowNodeSchema.safeParse({ + id: 'wait_timer', + type: 'wait', + label: 'Wait 1 Hour', + waitEventConfig: { + eventType: 'timer', + timerDuration: 'PT1H', + timeoutMs: 7200000, + onTimeout: 'fail', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.waitEventConfig?.eventType).toBe('timer'); + expect(result.data.waitEventConfig?.timerDuration).toBe('PT1H'); + } + }); + + it('should accept wait node with webhook event config', () => { + const result = FlowNodeSchema.safeParse({ + id: 'wait_webhook', + type: 'wait', + label: 'Wait for External Webhook', + waitEventConfig: { + eventType: 'webhook', + signalName: 'payment_received', + timeoutMs: 86400000, + onTimeout: 'continue', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.waitEventConfig?.eventType).toBe('webhook'); + expect(result.data.waitEventConfig?.signalName).toBe('payment_received'); + expect(result.data.waitEventConfig?.onTimeout).toBe('continue'); + } + }); + + it('should accept wait node with signal event config', () => { + const result = FlowNodeSchema.safeParse({ + id: 'wait_signal', + type: 'wait', + label: 'Wait for Approval Signal', + waitEventConfig: { + eventType: 'signal', + signalName: 'manager_approved', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.waitEventConfig?.onTimeout).toBe('fail'); // default + } + }); + + it('should accept wait node with manual resume', () => { + const result = FlowNodeSchema.safeParse({ + id: 'wait_manual', + type: 'wait', + label: 'Wait for Manual Resume', + waitEventConfig: { eventType: 'manual' }, + }); + expect(result.success).toBe(true); + }); + + it('should accept wait node without waitEventConfig (backward compatible)', () => { + const result = FlowNodeSchema.safeParse({ + id: 'wait_simple', + type: 'wait', + label: 'Simple Wait', + }); + expect(result.success).toBe(true); + expect(result.data?.waitEventConfig).toBeUndefined(); + }); +}); + +describe('BPMN — Boundary Event', () => { + it('should accept boundary_event node with error event config', () => { + const result = FlowNodeSchema.safeParse({ + id: 'be_error', + type: 'boundary_event', + label: 'Catch API Error', + boundaryConfig: { + attachedToNodeId: 'http_call_1', + eventType: 'error', + interrupting: true, + errorCode: 'HTTP_TIMEOUT', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.boundaryConfig?.attachedToNodeId).toBe('http_call_1'); + expect(result.data.boundaryConfig?.eventType).toBe('error'); + expect(result.data.boundaryConfig?.interrupting).toBe(true); + } + }); + + it('should accept boundary_event with timer (non-interrupting)', () => { + const result = FlowNodeSchema.safeParse({ + id: 'be_timer', + type: 'boundary_event', + label: 'Escalation Timer', + boundaryConfig: { + attachedToNodeId: 'approval_node', + eventType: 'timer', + interrupting: false, + timerDuration: 'P3D', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.boundaryConfig?.interrupting).toBe(false); + expect(result.data.boundaryConfig?.timerDuration).toBe('P3D'); + } + }); + + it('should accept boundary_event with signal', () => { + const result = FlowNodeSchema.safeParse({ + id: 'be_signal', + type: 'boundary_event', + label: 'Catch Cancel Signal', + boundaryConfig: { + attachedToNodeId: 'long_task', + eventType: 'signal', + signalName: 'user_cancelled', + }, + }); + expect(result.success).toBe(true); + }); + + it('should default interrupting to true', () => { + const result = FlowNodeSchema.safeParse({ + id: 'be_default', + type: 'boundary_event', + label: 'Default Boundary', + boundaryConfig: { + attachedToNodeId: 'some_node', + eventType: 'cancel', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.boundaryConfig?.interrupting).toBe(true); + } + }); + + it('should validate a flow with boundary error handling', () => { + const flow: Flow = { + name: 'boundary_error_flow', + label: 'Flow with Boundary Error Handling', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'api_call', type: 'http_request', label: 'Call External API', timeoutMs: 5000 }, + { + id: 'api_error_boundary', + type: 'boundary_event', + label: 'API Timeout Handler', + boundaryConfig: { + attachedToNodeId: 'api_call', + eventType: 'error', + interrupting: true, + errorCode: 'TIMEOUT', + }, + }, + { id: 'handle_error', type: 'update_record', label: 'Log Error' }, + { id: 'end_success', type: 'end', label: 'End Success' }, + { id: 'end_error', type: 'end', label: 'End Error' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'api_call' }, + { id: 'e2', source: 'api_call', target: 'end_success' }, + { id: 'e3', source: 'api_error_boundary', target: 'handle_error', type: 'fault' }, + { id: 'e4', source: 'handle_error', target: 'end_error' }, + ], + }; + + const result = FlowSchema.safeParse(flow); + expect(result.success).toBe(true); + if (result.success) { + const boundaryNode = result.data.nodes.find(n => n.type === 'boundary_event'); + expect(boundaryNode).toBeDefined(); + expect(boundaryNode!.boundaryConfig?.attachedToNodeId).toBe('api_call'); + const faultEdge = result.data.edges.find(e => e.type === 'fault'); + expect(faultEdge).toBeDefined(); + expect(faultEdge!.source).toBe('api_error_boundary'); + } + }); +}); + +describe('BPMN — Fault Edge Enhancement', () => { + it('should accept fault edge type', () => { + const result = FlowEdgeSchema.parse({ + id: 'fault_1', + source: 'node_a', + target: 'error_handler', + type: 'fault', + label: 'On Error', + }); + expect(result.type).toBe('fault'); + }); + + it('should accept all edge types: default, fault, conditional', () => { + const types = ['default', 'fault', 'conditional'] as const; + types.forEach(type => { + const result = FlowEdgeSchema.safeParse({ + id: `e_${type}`, + source: 'a', + target: 'b', + type, + }); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 8ee9910e63..f6a19fb771 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -6,21 +6,24 @@ import { z } from 'zod'; * Flow Node Types */ export const FlowNodeAction = z.enum([ - 'start', // Trigger - 'end', // Return/Stop - 'decision', // If/Else logic - 'assignment', // Set Variable - 'loop', // For Each - 'create_record', // CRUD: Create - 'update_record', // CRUD: Update - 'delete_record', // CRUD: Delete - 'get_record', // CRUD: Get/Query - 'http_request', // Webhook/API Call - 'script', // Custom Script (JS/TS) - 'screen', // Screen / User-Input Element - 'wait', // Delay/Sleep - 'subflow', // Call another flow - 'connector_action', // Zapier-style integration action + 'start', // Trigger + 'end', // Return/Stop + 'decision', // If/Else logic + 'assignment', // Set Variable + 'loop', // For Each + 'create_record', // CRUD: Create + 'update_record', // CRUD: Update + 'delete_record', // CRUD: Delete + 'get_record', // CRUD: Get/Query + 'http_request', // Webhook/API Call + 'script', // Custom Script (JS/TS) + 'screen', // Screen / User-Input Element + 'wait', // Delay/Sleep + 'subflow', // Call another flow + 'connector_action', // Zapier-style integration action + 'parallel_gateway', // BPMN Parallel Gateway — AND-split (all outgoing branches execute concurrently) + 'join_gateway', // BPMN Join Gateway — AND-join (waits for all incoming branches to complete) + 'boundary_event', // BPMN Boundary Event — attached to a host node for timer/error/signal interrupts ]); /** @@ -88,6 +91,47 @@ export const FlowNodeSchema = z.object({ type: z.enum(['string', 'number', 'boolean', 'object', 'array']).describe('Output type'), description: z.string().optional().describe('Output description'), })).optional().describe('Output schema declaration for this node'), + + /** + * Wait Event Configuration (for 'wait' nodes) + * Defines what external event or condition should resume the paused execution. + * Industry alignment: BPMN Intermediate Catch Events, Temporal Signals. + */ + waitEventConfig: z.object({ + /** Type of event to wait for */ + eventType: z.enum(['timer', 'signal', 'webhook', 'manual', 'condition']) + .describe('What kind of event resumes the execution'), + /** Duration to wait (ISO 8601 duration or milliseconds) — for timer events */ + timerDuration: z.string().optional().describe('ISO 8601 duration (e.g., "PT1H") or wait time for timer events'), + /** Signal name to listen for — for signal/webhook events */ + signalName: z.string().optional().describe('Named signal or webhook event to wait for'), + /** Timeout before auto-failing or continuing — optional guard */ + timeoutMs: z.number().int().min(0).optional().describe('Maximum wait time before timeout (ms)'), + /** Action to take on timeout */ + onTimeout: z.enum(['fail', 'continue']).default('fail').describe('Behavior when the wait times out'), + }).optional().describe('Configuration for wait node event resumption'), + + /** + * Boundary Event Configuration (for 'boundary_event' nodes) + * Attaches an event handler to a host activity node (BPMN Boundary Event pattern). + * Industry alignment: BPMN Boundary Error/Timer/Signal Events. + */ + boundaryConfig: z.object({ + /** ID of the host node this boundary event is attached to */ + attachedToNodeId: z.string().describe('Host node ID this boundary event monitors'), + /** Type of boundary event */ + eventType: z.enum(['error', 'timer', 'signal', 'cancel']) + .describe('Boundary event trigger type'), + /** Whether the boundary event interrupts the host activity */ + interrupting: z.boolean().default(true) + .describe('If true, the host activity is cancelled when this event fires'), + /** Error code filter — only for error boundary events */ + errorCode: z.string().optional().describe('Specific error code to catch (empty = catch all errors)'), + /** Timer duration — only for timer boundary events */ + timerDuration: z.string().optional().describe('ISO 8601 duration for timer boundary events'), + /** Signal name — only for signal boundary events */ + signalName: z.string().optional().describe('Named signal to catch'), + }).optional().describe('Configuration for boundary events attached to host nodes'), }); /** @@ -102,8 +146,17 @@ export const FlowEdgeSchema = z.object({ /** Condition for this path (only for decision/branch nodes) */ condition: z.string().optional().describe('Expression returning boolean used for branching'), - type: z.enum(['default', 'fault']).default('default').describe('Connection type: Standard (Success) or Fault (Error) path'), + type: z.enum(['default', 'fault', 'conditional']).default('default') + .describe('Connection type: default (normal flow), fault (error path), or conditional (expression-guarded)'), label: z.string().optional().describe('Label on the connector'), + + /** + * Default Sequence Flow marker (BPMN Default Flow semantics). + * When true, this edge is taken when no sibling conditional edges match. + * Only meaningful on outgoing edges of decision/gateway nodes. + */ + isDefault: z.boolean().default(false) + .describe('Marks this edge as the default path when no other conditions match'), }); /** From 0a1300325775cd226f44e0246b7d8d9945076980 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 02:48:23 +0000 Subject: [PATCH 3/3] fix(tests): include all 18 node types in FlowNodeAction test lists Add missing `connector_action` and `screen` to both FlowNodeAction and FlowNodeSchema 'accept all node types' tests for completeness. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 7 ++++++- packages/spec/src/automation/flow.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 32571177f1..2074aeb98a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,7 +141,7 @@ Multi-stage triggers, action pipelines, execution logs, and cron scheduling stan | Item | Status | Location | |:---|:---:|:---| -| Flow orchestration (14 node types) | ✅ | `automation/flow.zod.ts` | +| Flow orchestration (18 node types) | ✅ | `automation/flow.zod.ts` | | Trigger registry (record, field, webhook) | ✅ | `automation/trigger-registry.zod.ts` | | Cron scheduling expression | ✅ | `automation/etl.zod.ts`, `automation/webhook.zod.ts` | | Action pipeline (webhook, email, CRUD, notification) | ✅ | `automation/flow.zod.ts` (HTTP, CRUD, script nodes) | @@ -166,6 +166,11 @@ Multi-stage triggers, action pipelines, execution logs, and cron scheduling stan | Safe expression evaluation (no `new Function`) | ✅ | `@objectstack/service-automation` → operator-based parser, no code execution | | Node input/output schema validation | ✅ | `automation/flow.zod.ts` → `inputSchema`/`outputSchema` per node, runtime validation | | Flow version history & rollback | ✅ | `automation/flow.zod.ts` → `FlowVersionHistorySchema`, engine version management | +| BPMN parallel gateway & join gateway | ✅ | `automation/flow.zod.ts` → `parallel_gateway` (AND-split), `join_gateway` (AND-join) node types | +| BPMN default sequence flow | ✅ | `automation/flow.zod.ts` → `isDefault` field + `conditional` edge type on `FlowEdgeSchema` | +| BPMN boundary events (error/timer/signal) | ✅ | `automation/flow.zod.ts` → `boundary_event` node type + `boundaryConfig` (interrupting/non-interrupting) | +| BPMN wait event configuration | ✅ | `automation/flow.zod.ts` → `waitEventConfig` (timer/signal/webhook/manual/condition event types) | +| BPMN checkpoint reasons (parallel join, boundary) | ✅ | `automation/execution.zod.ts` → `parallel_join`, `boundary_event` in `CheckpointSchema.reason` | ### 3. File Direct Upload & Resumable Upload Protocol diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index 61f08f2c93..0e8be374d3 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -17,7 +17,7 @@ describe('FlowNodeAction', () => { const actions = [ 'start', 'end', 'decision', 'assignment', 'loop', 'create_record', 'update_record', 'delete_record', 'get_record', - 'http_request', 'script', 'wait', 'subflow', + 'http_request', 'script', 'screen', 'wait', 'subflow', 'connector_action', 'parallel_gateway', 'join_gateway', 'boundary_event', ]; @@ -120,7 +120,7 @@ describe('FlowNodeSchema', () => { const types = [ 'start', 'end', 'decision', 'assignment', 'loop', 'create_record', 'update_record', 'delete_record', 'get_record', - 'http_request', 'script', 'wait', 'subflow', + 'http_request', 'script', 'screen', 'wait', 'subflow', 'connector_action', 'parallel_gateway', 'join_gateway', 'boundary_event', ] as const;