/** * @vitest-environment node */ import { sleep } from '@sim/utils/helpers' import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' const { mockCancellationSubscribers } = vi.hoisted(() => ({ mockCancellationSubscribers: new Set<(event: { executionId: string }) => void>(), })) vi.mock('@/lib/execution/cancellation', () => ({ isExecutionCancelled: vi.fn(), isRedisCancellationEnabled: vi.fn(), getCancellationChannel: () => ({ publish: (event: { executionId: string }) => { for (const handler of mockCancellationSubscribers) handler(event) }, subscribe: (handler: (event: { executionId: string }) => void) => { mockCancellationSubscribers.add(handler) return () => { mockCancellationSubscribers.delete(handler) } }, dispose: () => { mockCancellationSubscribers.clear() }, }), })) import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { EDGE } from '@/executor/constants' import type { DAG, DAGNode } from '@/executor/dag/builder' import type { EdgeManager } from '@/executor/execution/edge-manager' import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' import { ExecutionEngine } from './engine' function createMockBlock(id: string): SerializedBlock { return { id, metadata: { id: 'test', name: 'Test Block' }, position: { x: 0, y: 0 }, config: { tool: '', params: {} }, inputs: {}, outputs: {}, enabled: true, } } function createMockNode(id: string, blockType = 'test'): DAGNode { return { id, block: { ...createMockBlock(id), metadata: { id: blockType, name: `Block ${id}` }, }, outgoingEdges: new Map(), incomingEdges: new Set(), metadata: {}, } } function createMockContext(overrides: Partial = {}): ExecutionContext { return { workflowId: 'test-workflow', workspaceId: 'test-workspace', executionId: 'test-execution', userId: 'test-user', blockStates: new Map(), executedBlocks: new Set(), blockLogs: [], loopExecutions: new Map(), parallelExecutions: new Map(), completedLoops: new Set(), activeExecutionPath: new Set(), metadata: { executionId: 'test-execution', startTime: new Date().toISOString(), pendingBlocks: [], }, envVars: {}, ...overrides, } } function createMockDAG(nodes: DAGNode[]): DAG { const nodeMap = new Map() nodes.forEach((node) => nodeMap.set(node.id, node)) return { nodes: nodeMap, loopConfigs: new Map(), parallelConfigs: new Map(), } } interface MockEdgeManager extends EdgeManager { processOutgoingEdges: ReturnType } function createMockEdgeManager( processOutgoingEdgesImpl?: (node: DAGNode) => string[] ): MockEdgeManager { const mockFn = vi.fn().mockImplementation(processOutgoingEdgesImpl || (() => [])) return { processOutgoingEdges: mockFn, isNodeReady: vi.fn().mockReturnValue(true), deactivateEdgeAndDescendants: vi.fn(), restoreIncomingEdge: vi.fn(), clearDeactivatedEdges: vi.fn(), clearDeactivatedEdgesForNodes: vi.fn(), getDeactivatedEdges: vi.fn(() => []), getNodesWithActivatedEdge: vi.fn(() => []), markNodeWithActivatedEdge: vi.fn(), deactivateResumedEdge: vi.fn(), hasActivatedEdge: vi.fn(() => false), } as unknown as MockEdgeManager } interface MockNodeOrchestrator extends NodeExecutionOrchestrator { executionCount: number } function createMockNodeOrchestrator(executeDelay = 0): MockNodeOrchestrator { const mock = { executionCount: 0, executeNode: vi.fn().mockImplementation(async () => { mock.executionCount++ if (executeDelay > 0) { await sleep(executeDelay) } return { nodeId: 'test', output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } return mock as unknown as MockNodeOrchestrator } describe('ExecutionEngine', () => { beforeEach(() => { vi.clearAllMocks() mockCancellationSubscribers.clear() ;(isExecutionCancelled as Mock).mockResolvedValue(false) ;(isRedisCancellationEnabled as Mock).mockReturnValue(false) }) afterEach(() => { vi.useRealTimers() }) describe('Normal execution', () => { it('should execute a simple linear workflow', async () => { const startNode = createMockNode('start', 'starter') const endNode = createMockNode('end', 'function') startNode.outgoingEdges.set('edge1', { target: 'end' }) endNode.incomingEdges.add('start') const dag = createMockDAG([startNode, endNode]) const context = createMockContext({ decisions: { router: new Map(), condition: new Map() }, }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['end'] return [] }) const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(nodeOrchestrator.executionCount).toBe(2) }) it('should mark execution as successful when completed without cancellation', async () => { const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext({ decisions: { router: new Map(), condition: new Map() }, }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(result.status).toBeUndefined() }) it('should not fall back to starter blocks for terminal resume snapshots', async () => { const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext({ metadata: { executionId: 'test-execution', startTime: new Date().toISOString(), pendingBlocks: [], resumeFromSnapshot: true, }, }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('hitl') expect(result.success).toBe(true) expect(nodeOrchestrator.executionCount).toBe(0) }) it('marks resumed pause edge targets as activated before readiness checks', async () => { const targetNode = createMockNode('join', 'function') targetNode.incomingEdges.add('pause-block') targetNode.incomingEdges.add('condition-block') const dag = createMockDAG([targetNode]) const context = createMockContext({ metadata: { executionId: 'test-execution', startTime: new Date().toISOString(), pendingBlocks: [], remainingEdges: [{ source: 'pause-block', target: 'join' }], } as any, }) const edgeManager = createMockEdgeManager(() => []) vi.mocked(edgeManager.isNodeReady).mockReturnValue(false) const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await engine.run() expect(edgeManager.markNodeWithActivatedEdge).toHaveBeenCalledWith('join') expect(nodeOrchestrator.executeNode).not.toHaveBeenCalled() }) it('deactivates a resumed pause block error edge instead of firing it', async () => { // Pause block has two outgoing edges: a `source` continuation edge and an // `error` edge to an error-notifier. On a successful resume the error edge // must be deactivated (never marked/queued); only the continuation fires. const pauseNode = createMockNode('pause-block', 'human_in_the_loop') pauseNode.outgoingEdges.set('pause-block→next-source', { target: 'next', sourceHandle: EDGE.SOURCE, }) pauseNode.outgoingEdges.set('pause-block→notify-error', { target: 'error-notify', sourceHandle: EDGE.ERROR, }) const nextNode = createMockNode('next', 'function') nextNode.incomingEdges.add('pause-block') const errorNotifyNode = createMockNode('error-notify', 'gmail') errorNotifyNode.incomingEdges.add('pause-block') const dag = createMockDAG([pauseNode, nextNode, errorNotifyNode]) const context = createMockContext({ metadata: { executionId: 'test-execution', startTime: new Date().toISOString(), pendingBlocks: [], // remainingEdges omit sourceHandle (as persisted snapshots do), forcing // the engine to resolve the handle from the live DAG. remainingEdges: [ { source: 'pause-block', target: 'next' }, { source: 'pause-block', target: 'error-notify' }, ], } as any, }) const edgeManager = createMockEdgeManager(() => []) const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await engine.run() // Continuation edge fires; error edge is deactivated, not activated/queued. expect(edgeManager.markNodeWithActivatedEdge).toHaveBeenCalledWith('next') expect(edgeManager.markNodeWithActivatedEdge).not.toHaveBeenCalledWith('error-notify') expect(edgeManager.deactivateResumedEdge).toHaveBeenCalledWith( 'pause-block', 'error-notify', EDGE.ERROR ) // A pure error-handler target (never activated) must not be executed. expect(nodeOrchestrator.executeNode).not.toHaveBeenCalledWith(context, 'error-notify') }) it('re-queues a convergence target when a resumed pause error edge is pruned', async () => { // The join is fed by a succeeding block's `source` edge (activated in // phase 1) AND the pause block's `error` edge. On resume the error edge is // pruned, but the join must still run because it already had a genuine // activation — otherwise it would be silently stranded. const pauseNode = createMockNode('pause-block', 'human_in_the_loop') pauseNode.outgoingEdges.set('pause-block→join-error', { target: 'join', sourceHandle: EDGE.ERROR, }) const joinNode = createMockNode('join', 'function') joinNode.incomingEdges.add('pause-block') const dag = createMockDAG([pauseNode, joinNode]) const context = createMockContext({ metadata: { executionId: 'test-execution', startTime: new Date().toISOString(), pendingBlocks: [], remainingEdges: [{ source: 'pause-block', target: 'join' }], } as any, }) const edgeManager = createMockEdgeManager(() => []) // Join already received a genuine activation in phase 1 and is now ready. vi.mocked(edgeManager.hasActivatedEdge).mockReturnValue(true) vi.mocked(edgeManager.isNodeReady).mockReturnValue(true) const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await engine.run() expect(edgeManager.deactivateResumedEdge).toHaveBeenCalledWith( 'pause-block', 'join', EDGE.ERROR ) // Pruning is via deactivation, never force-activation... expect(edgeManager.markNodeWithActivatedEdge).not.toHaveBeenCalledWith('join') // ...but the already-activated convergence node still runs. expect(nodeOrchestrator.executeNode).toHaveBeenCalledWith(context, 'join') }) it('prefers the continuation handle when a pause block also errors into the same target', async () => { // Pause block wires BOTH source and error into the same target. The error // edge is registered first, but on a successful resume the continuation // (source) handle must win, so the target is activated, not pruned. const pauseNode = createMockNode('pause-block', 'human_in_the_loop') pauseNode.outgoingEdges.set('pause-block→both-error', { target: 'both', sourceHandle: EDGE.ERROR, }) pauseNode.outgoingEdges.set('pause-block→both-source', { target: 'both', sourceHandle: EDGE.SOURCE, }) const bothNode = createMockNode('both', 'function') bothNode.incomingEdges.add('pause-block') const dag = createMockDAG([pauseNode, bothNode]) const context = createMockContext({ metadata: { executionId: 'test-execution', startTime: new Date().toISOString(), pendingBlocks: [], remainingEdges: [{ source: 'pause-block', target: 'both' }], } as any, }) const edgeManager = createMockEdgeManager(() => []) const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await engine.run() expect(edgeManager.markNodeWithActivatedEdge).toHaveBeenCalledWith('both') expect(edgeManager.deactivateResumedEdge).not.toHaveBeenCalled() expect(nodeOrchestrator.executeNode).toHaveBeenCalledWith(context, 'both') }) it('should execute all nodes in a multi-node workflow', async () => { const nodes = [ createMockNode('start', 'starter'), createMockNode('middle1', 'function'), createMockNode('middle2', 'function'), createMockNode('end', 'function'), ] nodes[0].outgoingEdges.set('e1', { target: 'middle1' }) nodes[1].outgoingEdges.set('e2', { target: 'middle2' }) nodes[2].outgoingEdges.set('e3', { target: 'end' }) const dag = createMockDAG(nodes) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['middle1'] if (node.id === 'middle1') return ['middle2'] if (node.id === 'middle2') return ['end'] return [] }) const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(nodeOrchestrator.executionCount).toBe(4) }) it('records paused block completion before returning paused result', async () => { const node = createMockNode('hitl', 'function') const dag = createMockDAG([node]) const context = createMockContext({ decisions: { router: new Map(), condition: new Map() }, }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const pauseOutput = { response: { status: 'paused' }, _pauseMetadata: { contextId: 'pause-1', blockId: 'hitl', response: { status: 'paused' }, timestamp: new Date().toISOString(), pauseKind: 'hitl', }, } vi.mocked(nodeOrchestrator.executeNode).mockResolvedValue({ nodeId: 'hitl', output: pauseOutput, isFinalOutput: false, }) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('hitl') expect(result.status).toBe('paused') expect(nodeOrchestrator.handleNodeCompletion).toHaveBeenCalledWith( context, 'hitl', pauseOutput ) }) it('does not stop run-until execution on parallel batch continuation', async () => { const parallelEnd = createMockNode('parallel-end', 'parallel') const nextNode = createMockNode('next', 'function') parallelEnd.outgoingEdges.set('continue', { target: 'next', sourceHandle: EDGE.PARALLEL_CONTINUE, }) nextNode.incomingEdges.add('parallel-end') const dag = createMockDAG([parallelEnd, nextNode]) const context = createMockContext({ stopAfterBlockId: 'parallel-end', decisions: { router: new Map(), condition: new Map() }, }) const edgeManager = createMockEdgeManager((node) => node.id === 'parallel-end' ? ['next'] : [] ) const nodeOrchestrator = createMockNodeOrchestrator() vi.mocked(nodeOrchestrator.executeNode).mockImplementation(async (_ctx, nodeId) => ({ nodeId, output: nodeId === 'parallel-end' ? { selectedRoute: EDGE.PARALLEL_CONTINUE } : { result: 'done' }, isFinalOutput: nodeId === 'next', })) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await engine.run('parallel-end') expect(nodeOrchestrator.executeNode).toHaveBeenCalledWith(context, 'next') }) }) describe('Cancellation via AbortSignal', () => { it('should stop execution immediately when aborted before start', async () => { const abortController = new AbortController() abortController.abort() const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.status).toBe('cancelled') expect(nodeOrchestrator.executionCount).toBe(0) }) it('should stop execution when aborted mid-workflow', async () => { const abortController = new AbortController() const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function')) for (let i = 0; i < nodes.length - 1; i++) { nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` }) } const dag = createMockDAG(nodes) const context = createMockContext({ abortSignal: abortController.signal }) let callCount = 0 const edgeManager = createMockEdgeManager((node) => { callCount++ if (callCount === 2) abortController.abort() const idx = Number.parseInt(node.id.replace('node', '')) if (idx < 4) return [`node${idx + 1}`] return [] }) const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('node0') expect(result.success).toBe(false) expect(result.status).toBe('cancelled') expect(nodeOrchestrator.executionCount).toBeLessThan(5) }) it('should not wait for slow executions when cancelled', async () => { const abortController = new AbortController() const startNode = createMockNode('start', 'starter') const slowNode = createMockNode('slow', 'function') startNode.outgoingEdges.set('edge1', { target: 'slow' }) const dag = createMockDAG([startNode, slowNode]) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['slow'] return [] }) const nodeOrchestrator = createMockNodeOrchestrator(1) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const executionPromise = engine.run('start') setTimeout(() => abortController.abort(), 1) const startTime = Date.now() const result = await executionPromise const duration = Date.now() - startTime expect(result.status).toBe('cancelled') expect(duration).toBeLessThan(200) }) it('should return cancelled status even if error thrown during cancellation', async () => { const abortController = new AbortController() abortController.abort() const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.status).toBe('cancelled') expect(result.success).toBe(false) }) }) describe('Cancellation via Redis', () => { it('should check Redis for cancellation when enabled', async () => { ;(isRedisCancellationEnabled as Mock).mockReturnValue(true) ;(isExecutionCancelled as Mock).mockResolvedValue(false) const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext() const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await engine.run('start') expect(isExecutionCancelled as Mock).toHaveBeenCalled() }) it('should stop execution when Redis reports cancellation', async () => { ;(isRedisCancellationEnabled as Mock).mockReturnValue(true) ;(isExecutionCancelled as Mock).mockResolvedValue(true) const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function')) for (let i = 0; i < nodes.length - 1; i++) { nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` }) } const dag = createMockDAG(nodes) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { const idx = Number.parseInt(node.id.replace('node', '')) if (idx < 4) return [`node${idx + 1}`] return [] }) const nodeOrchestrator = createMockNodeOrchestrator(1) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('node0') expect(result.success).toBe(false) expect(result.status).toBe('cancelled') }) it('wakes from a slow in-flight node when a pub/sub cancellation arrives', async () => { ;(isRedisCancellationEnabled as Mock).mockReturnValue(true) ;(isExecutionCancelled as Mock).mockResolvedValue(false) const startNode = createMockNode('start', 'starter') const slowNode = createMockNode('slow', 'function') startNode.outgoingEdges.set('edge1', { target: 'slow' }) const dag = createMockDAG([startNode, slowNode]) const context = createMockContext({ executionId: 'pubsub-execution' }) const edgeManager = createMockEdgeManager((node) => (node.id === 'start' ? ['slow'] : [])) const nodeOrchestrator = createMockNodeOrchestrator(500) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const executionPromise = engine.run('start') setTimeout(() => { for (const handler of mockCancellationSubscribers) { handler({ executionId: 'pubsub-execution' }) } }, 5) const startTime = Date.now() const result = await executionPromise const duration = Date.now() - startTime expect(result.status).toBe('cancelled') expect(duration).toBeLessThan(100) }) it('ignores pub/sub events targeting other executions', async () => { ;(isRedisCancellationEnabled as Mock).mockReturnValue(true) ;(isExecutionCancelled as Mock).mockResolvedValue(false) const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext({ executionId: 'execution-a' }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) for (const handler of mockCancellationSubscribers) { handler({ executionId: 'execution-b' }) } const result = await engine.run('start') expect(result.status).toBeUndefined() expect(result.success).toBe(true) }) it('unsubscribes from the cancellation channel after run completes', async () => { ;(isRedisCancellationEnabled as Mock).mockReturnValue(true) ;(isExecutionCancelled as Mock).mockResolvedValue(false) const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext({ executionId: 'cleanup-execution' }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) expect(mockCancellationSubscribers.size).toBe(1) await engine.run('start') expect(mockCancellationSubscribers.size).toBe(0) }) it('honours the durable backstop when cancelled before subscribing', async () => { ;(isRedisCancellationEnabled as Mock).mockReturnValue(true) ;(isExecutionCancelled as Mock).mockResolvedValue(true) const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext() const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.status).toBe('cancelled') expect(nodeOrchestrator.executionCount).toBe(0) }) it('calls isExecutionCancelled once as the startup backstop check', async () => { ;(isRedisCancellationEnabled as Mock).mockReturnValue(true) ;(isExecutionCancelled as Mock).mockResolvedValue(false) const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext() const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await engine.run('start') expect((isExecutionCancelled as Mock).mock.calls.length).toBe(1) }) }) describe('Loop execution with cancellation', () => { it('should break out of loop when cancelled mid-iteration', async () => { const abortController = new AbortController() const loopStartNode = createMockNode('loop-start', 'loop_sentinel') loopStartNode.metadata = { isSentinel: true, sentinelType: 'start', subflowId: 'loop1', subflowType: 'loop', } const loopBodyNode = createMockNode('loop-body', 'function') loopBodyNode.metadata = { isLoopNode: true, subflowId: 'loop1', subflowType: 'loop' } const loopEndNode = createMockNode('loop-end', 'loop_sentinel') loopEndNode.metadata = { isSentinel: true, sentinelType: 'end', subflowId: 'loop1', subflowType: 'loop', } loopStartNode.outgoingEdges.set('edge1', { target: 'loop-body' }) loopBodyNode.outgoingEdges.set('edge2', { target: 'loop-end' }) loopEndNode.outgoingEdges.set('loop_continue', { target: 'loop-start', sourceHandle: 'loop_continue', }) const dag = createMockDAG([loopStartNode, loopBodyNode, loopEndNode]) const context = createMockContext({ abortSignal: abortController.signal }) let iterationCount = 0 const edgeManager = createMockEdgeManager((node) => { if (node.id === 'loop-start') return ['loop-body'] if (node.id === 'loop-body') return ['loop-end'] if (node.id === 'loop-end') { iterationCount++ if (iterationCount === 3) abortController.abort() return ['loop-start'] } return [] }) const nodeOrchestrator = createMockNodeOrchestrator(1) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('loop-start') expect(result.status).toBe('cancelled') expect(iterationCount).toBeLessThan(100) }) }) describe('Parallel execution with cancellation', () => { it('should stop queueing parallel branches when cancelled', async () => { const abortController = new AbortController() const startNode = createMockNode('start', 'starter') const parallelNodes = Array.from({ length: 10 }, (_, i) => createMockNode(`parallel${i}`, 'function') ) parallelNodes.forEach((_, i) => { startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` }) }) const dag = createMockDAG([startNode, ...parallelNodes]) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') { return parallelNodes.map((_, i) => `parallel${i}`) } return [] }) const nodeOrchestrator = createMockNodeOrchestrator(1) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const executionPromise = engine.run('start') setTimeout(() => abortController.abort(), 1) const result = await executionPromise expect(result.status).toBe('cancelled') expect(nodeOrchestrator.executionCount).toBeLessThan(11) }) it('should not wait for all parallel branches when cancelled', async () => { const abortController = new AbortController() const startNode = createMockNode('start', 'starter') const slowNodes = Array.from({ length: 5 }, (_, i) => createMockNode(`slow${i}`, 'function')) slowNodes.forEach((_, i) => { startNode.outgoingEdges.set(`edge${i}`, { target: `slow${i}` }) }) const dag = createMockDAG([startNode, ...slowNodes]) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return slowNodes.map((_, i) => `slow${i}`) return [] }) const nodeOrchestrator = createMockNodeOrchestrator(2) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const executionPromise = engine.run('start') setTimeout(() => abortController.abort(), 1) const startTime = Date.now() const result = await executionPromise const duration = Date.now() - startTime expect(result.status).toBe('cancelled') expect(duration).toBeLessThan(100) }) }) describe('Edge cases', () => { it('should handle empty DAG gracefully', async () => { const dag = createMockDAG([]) const context = createMockContext() const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run() expect(result.success).toBe(true) expect(nodeOrchestrator.executionCount).toBe(0) }) it('should preserve partial output when cancelled', async () => { const abortController = new AbortController() const startNode = createMockNode('start', 'starter') const endNode = createMockNode('end', 'function') endNode.outgoingEdges = new Map() startNode.outgoingEdges.set('edge1', { target: 'end' }) const dag = createMockDAG([startNode, endNode]) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['end'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'start') { return { nodeId: 'start', output: { startData: 'value' }, isFinalOutput: false } } abortController.abort() return { nodeId: 'end', output: { endData: 'value' }, isFinalOutput: true } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.status).toBe('cancelled') expect(result.output).toBeDefined() }) it('should populate metadata on cancellation', async () => { const abortController = new AbortController() abortController.abort() const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.metadata).toBeDefined() expect(result.metadata.endTime).toBeDefined() expect(result.metadata.duration).toBeDefined() }) it('should return logs even when cancelled', async () => { const abortController = new AbortController() const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) const context = createMockContext({ abortSignal: abortController.signal }) context.blockLogs.push({ blockId: 'test', blockName: 'Test', blockType: 'test', startedAt: '', endedAt: '', durationMs: 0, success: true, }) const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() abortController.abort() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.logs).toBeDefined() expect(result.logs.length).toBeGreaterThan(0) }) }) describe('Error handling in execution', () => { it('should fail execution when a single node throws an error', async () => { const startNode = createMockNode('start', 'starter') const errorNode = createMockNode('error-node', 'function') startNode.outgoingEdges.set('edge1', { target: 'error-node' }) const dag = createMockDAG([startNode, errorNode]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['error-node'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'error-node') { throw new Error('Block execution failed') } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await expect(engine.run('start')).rejects.toThrow('Block execution failed') }) it('should stop parallel branches when one branch throws an error', async () => { const startNode = createMockNode('start', 'starter') const parallelNodes = Array.from({ length: 5 }, (_, i) => createMockNode(`parallel${i}`, 'function') ) parallelNodes.forEach((_, i) => { startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` }) }) const dag = createMockDAG([startNode, ...parallelNodes]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return parallelNodes.map((_, i) => `parallel${i}`) return [] }) const executedNodes: string[] = [] const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { executedNodes.push(nodeId) if (nodeId === 'parallel0') { await sleep(1) throw new Error('Parallel branch failed') } await sleep(2) return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await expect(engine.run('start')).rejects.toThrow('Parallel branch failed') }) it('should capture only the first error when multiple parallel branches fail', async () => { const startNode = createMockNode('start', 'starter') const parallelNodes = Array.from({ length: 3 }, (_, i) => createMockNode(`parallel${i}`, 'function') ) parallelNodes.forEach((_, i) => { startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` }) }) const dag = createMockDAG([startNode, ...parallelNodes]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return parallelNodes.map((_, i) => `parallel${i}`) return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'parallel0') { await sleep(1) throw new Error('First error') } if (nodeId === 'parallel1') { await sleep(1) throw new Error('Second error') } if (nodeId === 'parallel2') { await sleep(1) throw new Error('Third error') } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await expect(engine.run('start')).rejects.toThrow('First error') }) it('should wait for ongoing executions to complete before throwing error', async () => { const startNode = createMockNode('start', 'starter') const fastErrorNode = createMockNode('fast-error', 'function') const slowNode = createMockNode('slow', 'function') startNode.outgoingEdges.set('edge1', { target: 'fast-error' }) startNode.outgoingEdges.set('edge2', { target: 'slow' }) const dag = createMockDAG([startNode, fastErrorNode, slowNode]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['fast-error', 'slow'] return [] }) let slowNodeCompleted = false const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'fast-error') { await sleep(1) throw new Error('Fast error') } if (nodeId === 'slow') { await sleep(1) slowNodeCompleted = true return { nodeId, output: {}, isFinalOutput: false } } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await expect(engine.run('start')).rejects.toThrow('Fast error') expect(slowNodeCompleted).toBe(true) }) it('should not queue new nodes after an error occurs', async () => { const startNode = createMockNode('start', 'starter') const errorNode = createMockNode('error-node', 'function') const afterErrorNode = createMockNode('after-error', 'function') startNode.outgoingEdges.set('edge1', { target: 'error-node' }) errorNode.outgoingEdges.set('edge2', { target: 'after-error' }) const dag = createMockDAG([startNode, errorNode, afterErrorNode]) const context = createMockContext() const queuedNodes: string[] = [] const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') { queuedNodes.push('error-node') return ['error-node'] } if (node.id === 'error-node') { queuedNodes.push('after-error') return ['after-error'] } return [] }) const executedNodes: string[] = [] const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { executedNodes.push(nodeId) if (nodeId === 'error-node') { throw new Error('Node error') } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await expect(engine.run('start')).rejects.toThrow('Node error') expect(executedNodes).not.toContain('after-error') }) it('should populate error result with metadata when execution fails', async () => { const startNode = createMockNode('start', 'starter') const errorNode = createMockNode('error-node', 'function') startNode.outgoingEdges.set('edge1', { target: 'error-node' }) const dag = createMockDAG([startNode, errorNode]) const context = createMockContext() context.blockLogs.push({ blockId: 'start', blockName: 'Start', blockType: 'starter', startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), durationMs: 10, success: true, }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['error-node'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'error-node') { const error = new Error('Execution failed') as any error.executionResult = { success: false, output: { partial: 'data' }, logs: context.blockLogs, metadata: context.metadata, } throw error } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) try { await engine.run('start') expect.fail('Should have thrown') } catch (error: any) { expect(error.executionResult).toBeDefined() expect(error.executionResult.metadata.endTime).toBeDefined() expect(error.executionResult.metadata.duration).toBeDefined() } }) it('should prefer cancellation status over error when both occur', async () => { const abortController = new AbortController() const startNode = createMockNode('start', 'starter') const errorNode = createMockNode('error-node', 'function') startNode.outgoingEdges.set('edge1', { target: 'error-node' }) const dag = createMockDAG([startNode, errorNode]) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['error-node'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'error-node') { abortController.abort() throw new Error('Node error') } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.status).toBe('cancelled') expect(result.success).toBe(false) }) it('should stop loop iteration when error occurs in loop body', async () => { const loopStartNode = createMockNode('loop-start', 'loop_sentinel') loopStartNode.metadata = { isSentinel: true, sentinelType: 'start', subflowId: 'loop1', subflowType: 'loop', } const loopBodyNode = createMockNode('loop-body', 'function') loopBodyNode.metadata = { isLoopNode: true, subflowId: 'loop1', subflowType: 'loop' } const loopEndNode = createMockNode('loop-end', 'loop_sentinel') loopEndNode.metadata = { isSentinel: true, sentinelType: 'end', subflowId: 'loop1', subflowType: 'loop', } const afterLoopNode = createMockNode('after-loop', 'function') loopStartNode.outgoingEdges.set('edge1', { target: 'loop-body' }) loopBodyNode.outgoingEdges.set('edge2', { target: 'loop-end' }) loopEndNode.outgoingEdges.set('loop_continue', { target: 'loop-start', sourceHandle: 'loop_continue', }) loopEndNode.outgoingEdges.set('loop_complete', { target: 'after-loop', sourceHandle: 'loop_complete', }) const dag = createMockDAG([loopStartNode, loopBodyNode, loopEndNode, afterLoopNode]) const context = createMockContext() let iterationCount = 0 const edgeManager = createMockEdgeManager((node) => { if (node.id === 'loop-start') return ['loop-body'] if (node.id === 'loop-body') return ['loop-end'] if (node.id === 'loop-end') { iterationCount++ if (iterationCount < 5) return ['loop-start'] return ['after-loop'] } return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'loop-body' && iterationCount >= 2) { throw new Error('Loop body error on iteration 3') } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await expect(engine.run('loop-start')).rejects.toThrow('Loop body error on iteration 3') expect(iterationCount).toBeLessThanOrEqual(3) }) it('should handle error that is not an Error instance', async () => { const startNode = createMockNode('start', 'starter') const errorNode = createMockNode('error-node', 'function') startNode.outgoingEdges.set('edge1', { target: 'error-node' }) const dag = createMockDAG([startNode, errorNode]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['error-node'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'error-node') { throw 'String error message' } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await expect(engine.run('start')).rejects.toThrow('String error message') }) it('should preserve partial output when error occurs after some blocks complete', async () => { const startNode = createMockNode('start', 'starter') const successNode = createMockNode('success', 'function') const errorNode = createMockNode('error-node', 'function') startNode.outgoingEdges.set('edge1', { target: 'success' }) successNode.outgoingEdges.set('edge2', { target: 'error-node' }) const dag = createMockDAG([startNode, successNode, errorNode]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['success'] if (node.id === 'success') return ['error-node'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { if (nodeId === 'success') { return { nodeId, output: { successData: 'preserved' }, isFinalOutput: false } } if (nodeId === 'error-node') { throw new Error('Late error') } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) try { await engine.run('start') expect.fail('Should have thrown') } catch (error: any) { // Verify the error was thrown expect(error.message).toBe('Late error') // The partial output should be available in executionResult if attached if (error.executionResult) { expect(error.executionResult.output).toBeDefined() } } }) }) describe('Response block exit-point behavior', () => { it('should lock finalOutput and stop execution when a terminal Response block fires', async () => { const startNode = createMockNode('start', 'starter') const responseNode = createMockNode('response', 'response') startNode.outgoingEdges.set('edge1', { target: 'response' }) const dag = createMockDAG([startNode, responseNode]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['response'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { nodeOrchestrator.executionCount++ if (nodeId === 'response') { return { nodeId, output: { data: { message: 'ok' }, status: 200, headers: {} }, isFinalOutput: true, } } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(result.output).toEqual({ data: { message: 'ok' }, status: 200, headers: {} }) expect(nodeOrchestrator.executionCount).toBe(2) }) it('should stop execution after Response block on a branch (Router)', async () => { const startNode = createMockNode('start', 'starter') const routerNode = createMockNode('router', 'router') const successResponse = createMockNode('success-response', 'response') const errorResponse = createMockNode('error-response', 'response') startNode.outgoingEdges.set('edge1', { target: 'router' }) routerNode.outgoingEdges.set('success', { target: 'success-response' }) routerNode.outgoingEdges.set('error', { target: 'error-response' }) const dag = createMockDAG([startNode, routerNode, successResponse, errorResponse]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['router'] if (node.id === 'router') return ['success-response'] return [] }) const executedNodes: string[] = [] const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { executedNodes.push(nodeId) nodeOrchestrator.executionCount++ if (nodeId === 'success-response') { return { nodeId, output: { data: { result: 'success' }, status: 200, headers: {} }, isFinalOutput: true, } } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(result.output).toEqual({ data: { result: 'success' }, status: 200, headers: {} }) expect(executedNodes).not.toContain('error-response') }) it('should stop all branches when a parallel Response block fires first', async () => { const startNode = createMockNode('start', 'starter') const responseNode = createMockNode('fast-response', 'response') const slowNode = createMockNode('slow-work', 'function') const afterSlowNode = createMockNode('after-slow', 'function') startNode.outgoingEdges.set('edge1', { target: 'fast-response' }) startNode.outgoingEdges.set('edge2', { target: 'slow-work' }) slowNode.outgoingEdges.set('edge3', { target: 'after-slow' }) const dag = createMockDAG([startNode, responseNode, slowNode, afterSlowNode]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['fast-response', 'slow-work'] if (node.id === 'slow-work') return ['after-slow'] return [] }) const executedNodes: string[] = [] const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { executedNodes.push(nodeId) nodeOrchestrator.executionCount++ if (nodeId === 'fast-response') { return { nodeId, output: { data: { fast: true }, status: 200, headers: {} }, isFinalOutput: true, } } if (nodeId === 'slow-work') { await sleep(1) return { nodeId, output: { slow: true }, isFinalOutput: false } } return { nodeId, output: {}, isFinalOutput: true } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(result.output).toEqual({ data: { fast: true }, status: 200, headers: {} }) expect(executedNodes).not.toContain('after-slow') }) it('should use standard finalOutput logic when no Response block exists', async () => { const startNode = createMockNode('start', 'starter') const endNode = createMockNode('end', 'function') startNode.outgoingEdges.set('edge1', { target: 'end' }) const dag = createMockDAG([startNode, endNode]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['end'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { nodeOrchestrator.executionCount++ if (nodeId === 'end') { return { nodeId, output: { result: 'done' }, isFinalOutput: true } } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(result.output).toEqual({ result: 'done' }) }) it('should not let a second Response block overwrite the first', async () => { const startNode = createMockNode('start', 'starter') const response1 = createMockNode('response1', 'response') const response2 = createMockNode('response2', 'response') startNode.outgoingEdges.set('edge1', { target: 'response1' }) startNode.outgoingEdges.set('edge2', { target: 'response2' }) const dag = createMockDAG([startNode, response1, response2]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['response1', 'response2'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { nodeOrchestrator.executionCount++ if (nodeId === 'response1') { return { nodeId, output: { data: { first: true }, status: 200, headers: {} }, isFinalOutput: true, } } if (nodeId === 'response2') { return { nodeId, output: { data: { second: true }, status: 201, headers: {} }, isFinalOutput: true, } } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(result.output).toEqual({ data: { first: true }, status: 200, headers: {} }) }) it('should not let non-Response terminals overwrite a Response block output', async () => { const startNode = createMockNode('start', 'starter') const responseNode = createMockNode('response', 'response') const otherTerminal = createMockNode('other', 'function') startNode.outgoingEdges.set('edge1', { target: 'response' }) startNode.outgoingEdges.set('edge2', { target: 'other' }) const dag = createMockDAG([startNode, responseNode, otherTerminal]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['response', 'other'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { nodeOrchestrator.executionCount++ if (nodeId === 'response') { return { nodeId, output: { data: { response: true }, status: 200, headers: {} }, isFinalOutput: true, } } if (nodeId === 'other') { await sleep(1) return { nodeId, output: { other: true }, isFinalOutput: true } } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(result.output).toEqual({ data: { response: true }, status: 200, headers: {} }) }) it('should honor locked Response output even when a parallel node throws an error', async () => { const startNode = createMockNode('start', 'starter') const responseNode = createMockNode('response', 'response') const errorNode = createMockNode('error-node', 'function') startNode.outgoingEdges.set('edge1', { target: 'response' }) startNode.outgoingEdges.set('edge2', { target: 'error-node' }) const dag = createMockDAG([startNode, responseNode, errorNode]) const context = createMockContext() const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['response', 'error-node'] return [] }) const nodeOrchestrator = { executionCount: 0, executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => { nodeOrchestrator.executionCount++ if (nodeId === 'response') { return { nodeId, output: { data: { ok: true }, status: 200, headers: {} }, isFinalOutput: true, } } if (nodeId === 'error-node') { await sleep(1) throw new Error('Parallel branch failed') } return { nodeId, output: {}, isFinalOutput: false } }), handleNodeCompletion: vi.fn(), } as unknown as MockNodeOrchestrator const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('start') expect(result.success).toBe(true) expect(result.output).toEqual({ data: { ok: true }, status: 200, headers: {} }) }) }) describe('Cancellation flag behavior', () => { it('should set cancelledFlag when abort signal fires', async () => { const abortController = new AbortController() const nodes = Array.from({ length: 3 }, (_, i) => createMockNode(`node${i}`, 'function')) for (let i = 0; i < nodes.length - 1; i++) { nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` }) } const dag = createMockDAG(nodes) const context = createMockContext({ abortSignal: abortController.signal }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'node0') { abortController.abort() return ['node1'] } return node.id === 'node1' ? ['node2'] : [] }) const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) const result = await engine.run('node0') expect(result.status).toBe('cancelled') }) it('should cache Redis cancellation result', async () => { ;(isRedisCancellationEnabled as Mock).mockReturnValue(true) ;(isExecutionCancelled as Mock).mockResolvedValue(true) const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function')) const dag = createMockDAG(nodes) const context = createMockContext() const edgeManager = createMockEdgeManager() const nodeOrchestrator = createMockNodeOrchestrator() const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) await engine.run('node0') expect((isExecutionCancelled as Mock).mock.calls.length).toBeLessThanOrEqual(3) }) }) })