diff --git a/apps/sim/executor/dag/construction/edges.test.ts b/apps/sim/executor/dag/construction/edges.test.ts new file mode 100644 index 0000000000..3859ca086e --- /dev/null +++ b/apps/sim/executor/dag/construction/edges.test.ts @@ -0,0 +1,567 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DAG, DAGNode } from '@/executor/dag/builder' +import type { SerializedBlock, SerializedLoop, SerializedWorkflow } from '@/serializer/types' +import { EdgeConstructor } from './edges' + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })), +})) + +function createMockBlock(id: string, type = 'function', config: any = {}): SerializedBlock { + return { + id, + metadata: { id: type, name: `Block ${id}` }, + position: { x: 0, y: 0 }, + config: { tool: type, params: config }, + inputs: {}, + outputs: {}, + enabled: true, + } +} + +function createMockNode(id: string): DAGNode { + return { + id, + block: createMockBlock(id), + outgoingEdges: new Map(), + incomingEdges: new Set(), + metadata: {}, + } +} + +function createMockDAG(nodeIds: string[]): DAG { + const nodes = new Map() + for (const id of nodeIds) { + nodes.set(id, createMockNode(id)) + } + return { + nodes, + loopConfigs: new Map(), + parallelConfigs: new Map(), + } +} + +function createMockWorkflow( + blocks: SerializedBlock[], + connections: Array<{ + source: string + target: string + sourceHandle?: string + targetHandle?: string + }>, + loops: Record = {}, + parallels: Record = {} +): SerializedWorkflow { + return { + version: '1', + blocks, + connections, + loops, + parallels, + } +} + +describe('EdgeConstructor', () => { + let edgeConstructor: EdgeConstructor + + beforeEach(() => { + edgeConstructor = new EdgeConstructor() + }) + + describe('Edge ID generation (bug fix verification)', () => { + it('should generate unique edge IDs for multiple edges to same target with different handles', () => { + const conditionId = 'condition-1' + const targetId = 'target-1' + + const conditionBlock = createMockBlock(conditionId, 'condition', { + conditions: JSON.stringify([ + { id: 'if-id', label: 'if', condition: 'true' }, + { id: 'else-id', label: 'else', condition: '' }, + ]), + }) + + const workflow = createMockWorkflow( + [conditionBlock, createMockBlock(targetId)], + [ + { source: conditionId, target: targetId, sourceHandle: 'condition-if-id' }, + { source: conditionId, target: targetId, sourceHandle: 'condition-else-id' }, + ] + ) + + const dag = createMockDAG([conditionId, targetId]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([conditionId, targetId]), + new Map() + ) + + const conditionNode = dag.nodes.get(conditionId)! + + // Should have 2 edges, not 1 (the bug was that they would overwrite each other) + expect(conditionNode.outgoingEdges.size).toBe(2) + + // Verify edge IDs are unique and include the sourceHandle + const edgeIds = Array.from(conditionNode.outgoingEdges.keys()) + expect(edgeIds).toContain(`${conditionId}→${targetId}-condition-if-id`) + expect(edgeIds).toContain(`${conditionId}→${targetId}-condition-else-id`) + }) + + it('should generate edge ID without handle suffix when no sourceHandle', () => { + const sourceId = 'source-1' + const targetId = 'target-1' + + const workflow = createMockWorkflow( + [createMockBlock(sourceId), createMockBlock(targetId)], + [{ source: sourceId, target: targetId }] + ) + + const dag = createMockDAG([sourceId, targetId]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([sourceId, targetId]), + new Map() + ) + + const sourceNode = dag.nodes.get(sourceId)! + const edgeIds = Array.from(sourceNode.outgoingEdges.keys()) + + expect(edgeIds).toContain(`${sourceId}→${targetId}`) + }) + }) + + describe('Condition block edge wiring', () => { + it('should wire condition block edges with proper condition prefixes', () => { + const conditionId = 'condition-1' + const target1Id = 'target-1' + const target2Id = 'target-2' + + const conditionBlock = createMockBlock(conditionId, 'condition', { + conditions: JSON.stringify([ + { id: 'cond-if', label: 'if', condition: 'x > 5' }, + { id: 'cond-else', label: 'else', condition: '' }, + ]), + }) + + const workflow = createMockWorkflow( + [conditionBlock, createMockBlock(target1Id), createMockBlock(target2Id)], + [ + { source: conditionId, target: target1Id, sourceHandle: 'condition-cond-if' }, + { source: conditionId, target: target2Id, sourceHandle: 'condition-cond-else' }, + ] + ) + + const dag = createMockDAG([conditionId, target1Id, target2Id]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([conditionId, target1Id, target2Id]), + new Map() + ) + + const conditionNode = dag.nodes.get(conditionId)! + + expect(conditionNode.outgoingEdges.size).toBe(2) + + // Verify edges have correct targets and handles + const edges = Array.from(conditionNode.outgoingEdges.values()) + const ifEdge = edges.find((e) => e.sourceHandle === 'condition-cond-if') + const elseEdge = edges.find((e) => e.sourceHandle === 'condition-cond-else') + + expect(ifEdge?.target).toBe(target1Id) + expect(elseEdge?.target).toBe(target2Id) + }) + + it('should handle condition block with if→A, elseif→B, else→A pattern', () => { + const conditionId = 'condition-1' + const targetAId = 'target-a' + const targetBId = 'target-b' + + const conditionBlock = createMockBlock(conditionId, 'condition', { + conditions: JSON.stringify([ + { id: 'if-id', label: 'if', condition: 'x == 1' }, + { id: 'elseif-id', label: 'else if', condition: 'x == 2' }, + { id: 'else-id', label: 'else', condition: '' }, + ]), + }) + + const workflow = createMockWorkflow( + [conditionBlock, createMockBlock(targetAId), createMockBlock(targetBId)], + [ + { source: conditionId, target: targetAId, sourceHandle: 'condition-if-id' }, + { source: conditionId, target: targetBId, sourceHandle: 'condition-elseif-id' }, + { source: conditionId, target: targetAId, sourceHandle: 'condition-else-id' }, + ] + ) + + const dag = createMockDAG([conditionId, targetAId, targetBId]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([conditionId, targetAId, targetBId]), + new Map() + ) + + const conditionNode = dag.nodes.get(conditionId)! + + // Should have 3 edges (if→A, elseif→B, else→A) + expect(conditionNode.outgoingEdges.size).toBe(3) + + // Target A should have 2 incoming edges (from if and else) + const targetANode = dag.nodes.get(targetAId)! + expect(targetANode.incomingEdges.has(conditionId)).toBe(true) + + // Target B should have 1 incoming edge (from elseif) + const targetBNode = dag.nodes.get(targetBId)! + expect(targetBNode.incomingEdges.has(conditionId)).toBe(true) + }) + }) + + describe('Router block edge wiring', () => { + it('should wire router block edges with router prefix', () => { + const routerId = 'router-1' + const target1Id = 'target-1' + const target2Id = 'target-2' + + const routerBlock = createMockBlock(routerId, 'router') + + const workflow = createMockWorkflow( + [routerBlock, createMockBlock(target1Id), createMockBlock(target2Id)], + [ + { source: routerId, target: target1Id }, + { source: routerId, target: target2Id }, + ] + ) + + const dag = createMockDAG([routerId, target1Id, target2Id]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([routerId, target1Id, target2Id]), + new Map() + ) + + const routerNode = dag.nodes.get(routerId)! + const edges = Array.from(routerNode.outgoingEdges.values()) + + // Router edges should have router- prefix with target ID + expect(edges[0].sourceHandle).toBe(`router-${target1Id}`) + expect(edges[1].sourceHandle).toBe(`router-${target2Id}`) + }) + }) + + describe('Simple linear workflow', () => { + it('should wire linear workflow correctly', () => { + const block1Id = 'block-1' + const block2Id = 'block-2' + const block3Id = 'block-3' + + const workflow = createMockWorkflow( + [createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(block3Id)], + [ + { source: block1Id, target: block2Id }, + { source: block2Id, target: block3Id }, + ] + ) + + const dag = createMockDAG([block1Id, block2Id, block3Id]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([block1Id, block2Id, block3Id]), + new Map() + ) + + // Block 1 → Block 2 + const block1Node = dag.nodes.get(block1Id)! + expect(block1Node.outgoingEdges.size).toBe(1) + expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id) + + // Block 2 → Block 3 + const block2Node = dag.nodes.get(block2Id)! + expect(block2Node.outgoingEdges.size).toBe(1) + expect(Array.from(block2Node.outgoingEdges.values())[0].target).toBe(block3Id) + expect(block2Node.incomingEdges.has(block1Id)).toBe(true) + + // Block 3 has incoming from Block 2 + const block3Node = dag.nodes.get(block3Id)! + expect(block3Node.incomingEdges.has(block2Id)).toBe(true) + }) + }) + + describe('Edge reachability', () => { + it('should not wire edges to blocks not in DAG nodes', () => { + const block1Id = 'block-1' + const block2Id = 'block-2' + const unreachableId = 'unreachable' + + const workflow = createMockWorkflow( + [createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(unreachableId)], + [ + { source: block1Id, target: block2Id }, + { source: block1Id, target: unreachableId }, + ] + ) + + // Only create DAG nodes for block1 and block2 (not unreachable) + const dag = createMockDAG([block1Id, block2Id]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([block1Id, block2Id]), + new Map() + ) + + const block1Node = dag.nodes.get(block1Id)! + + // Should only have edge to block2, not unreachable (not in DAG) + expect(block1Node.outgoingEdges.size).toBe(1) + expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id) + }) + + it('should check both reachableBlocks and dag.nodes for edge validity', () => { + const block1Id = 'block-1' + const block2Id = 'block-2' + + const workflow = createMockWorkflow( + [createMockBlock(block1Id), createMockBlock(block2Id)], + [{ source: block1Id, target: block2Id }] + ) + + const dag = createMockDAG([block1Id, block2Id]) + + // Block2 exists in DAG but not in reachableBlocks - edge should still be wired + // because isEdgeReachable checks: reachableBlocks.has(target) || dag.nodes.has(target) + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([block1Id]), // Only block1 is "reachable" but block2 exists in DAG + new Map() + ) + + const block1Node = dag.nodes.get(block1Id)! + expect(block1Node.outgoingEdges.size).toBe(1) + }) + }) + + describe('Error edge handling', () => { + it('should preserve error sourceHandle', () => { + const sourceId = 'source-1' + const successTargetId = 'success-target' + const errorTargetId = 'error-target' + + const workflow = createMockWorkflow( + [ + createMockBlock(sourceId), + createMockBlock(successTargetId), + createMockBlock(errorTargetId), + ], + [ + { source: sourceId, target: successTargetId, sourceHandle: 'source' }, + { source: sourceId, target: errorTargetId, sourceHandle: 'error' }, + ] + ) + + const dag = createMockDAG([sourceId, successTargetId, errorTargetId]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([sourceId, successTargetId, errorTargetId]), + new Map() + ) + + const sourceNode = dag.nodes.get(sourceId)! + const edges = Array.from(sourceNode.outgoingEdges.values()) + + const successEdge = edges.find((e) => e.target === successTargetId) + const errorEdge = edges.find((e) => e.target === errorTargetId) + + expect(successEdge?.sourceHandle).toBe('source') + expect(errorEdge?.sourceHandle).toBe('error') + }) + }) + + describe('Loop sentinel wiring', () => { + it('should wire loop sentinels to nodes with no incoming edges from within loop', () => { + const loopId = 'loop-1' + const nodeInLoopId = 'node-in-loop' + const sentinelStartId = `loop-${loopId}-sentinel-start` + const sentinelEndId = `loop-${loopId}-sentinel-end` + + // Create DAG with sentinels - nodeInLoop has no incoming edges from loop nodes + // so it will be identified as a start node + const dag = createMockDAG([nodeInLoopId, sentinelStartId, sentinelEndId]) + dag.loopConfigs.set(loopId, { + id: loopId, + nodes: [nodeInLoopId], + iterations: 5, + loopType: 'for', + } as SerializedLoop) + + const workflow = createMockWorkflow([createMockBlock(nodeInLoopId)], [], { + [loopId]: { + id: loopId, + nodes: [nodeInLoopId], + iterations: 5, + loopType: 'for', + } as SerializedLoop, + }) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([nodeInLoopId]), + new Set([nodeInLoopId, sentinelStartId, sentinelEndId]), + new Map() + ) + + // Sentinel start should have edge to node in loop (it's a start node - no incoming from loop) + const sentinelStartNode = dag.nodes.get(sentinelStartId)! + expect(sentinelStartNode.outgoingEdges.size).toBe(1) + const startEdge = Array.from(sentinelStartNode.outgoingEdges.values())[0] + expect(startEdge.target).toBe(nodeInLoopId) + + // Node in loop should have edge to sentinel end (it's a terminal node - no outgoing to loop) + const nodeInLoopNode = dag.nodes.get(nodeInLoopId)! + const hasEdgeToEnd = Array.from(nodeInLoopNode.outgoingEdges.values()).some( + (e) => e.target === sentinelEndId + ) + expect(hasEdgeToEnd).toBe(true) + + // Sentinel end should have loop_continue edge back to start + const sentinelEndNode = dag.nodes.get(sentinelEndId)! + const continueEdge = Array.from(sentinelEndNode.outgoingEdges.values()).find( + (e) => e.sourceHandle === 'loop_continue' + ) + expect(continueEdge?.target).toBe(sentinelStartId) + }) + + it('should identify multiple start and terminal nodes in loop', () => { + const loopId = 'loop-1' + const node1Id = 'node-1' + const node2Id = 'node-2' + const sentinelStartId = `loop-${loopId}-sentinel-start` + const sentinelEndId = `loop-${loopId}-sentinel-end` + + // Create DAG with two nodes in loop - both are start and terminal (no edges between them) + const dag = createMockDAG([node1Id, node2Id, sentinelStartId, sentinelEndId]) + dag.loopConfigs.set(loopId, { + id: loopId, + nodes: [node1Id, node2Id], + iterations: 3, + loopType: 'for', + } as SerializedLoop) + + const workflow = createMockWorkflow( + [createMockBlock(node1Id), createMockBlock(node2Id)], + [], + { + [loopId]: { + id: loopId, + nodes: [node1Id, node2Id], + iterations: 3, + loopType: 'for', + } as SerializedLoop, + } + ) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([node1Id, node2Id]), + new Set([node1Id, node2Id, sentinelStartId, sentinelEndId]), + new Map() + ) + + // Sentinel start should have edges to both nodes (both are start nodes) + const sentinelStartNode = dag.nodes.get(sentinelStartId)! + expect(sentinelStartNode.outgoingEdges.size).toBe(2) + + // Both nodes should have edges to sentinel end (both are terminal nodes) + const node1 = dag.nodes.get(node1Id)! + const node2 = dag.nodes.get(node2Id)! + expect(Array.from(node1.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe( + true + ) + expect(Array.from(node2.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe( + true + ) + }) + }) + + describe('Cross-loop boundary detection', () => { + it('should not wire edges that cross loop boundaries', () => { + const outsideId = 'outside' + const insideId = 'inside' + const loopId = 'loop-1' + + const workflow = createMockWorkflow( + [createMockBlock(outsideId), createMockBlock(insideId)], + [{ source: outsideId, target: insideId }], + { + [loopId]: { + id: loopId, + nodes: [insideId], + iterations: 5, + loopType: 'for', + } as SerializedLoop, + } + ) + + const dag = createMockDAG([outsideId, insideId]) + dag.loopConfigs.set(loopId, { + id: loopId, + nodes: [insideId], + iterations: 5, + loopType: 'for', + } as SerializedLoop) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([insideId]), + new Set([outsideId, insideId]), + new Map() + ) + + // Edge should not be wired because it crosses loop boundary + const outsideNode = dag.nodes.get(outsideId)! + expect(outsideNode.outgoingEdges.size).toBe(0) + }) + }) +}) diff --git a/apps/sim/executor/dag/construction/edges.ts b/apps/sim/executor/dag/construction/edges.ts index 821fc29523..2b652a5dba 100644 --- a/apps/sim/executor/dag/construction/edges.ts +++ b/apps/sim/executor/dag/construction/edges.ts @@ -578,7 +578,7 @@ export class EdgeConstructor { return } - const edgeId = `${sourceId}→${targetId}` + const edgeId = `${sourceId}→${targetId}${sourceHandle ? `-${sourceHandle}` : ''}` sourceNode.outgoingEdges.set(edgeId, { target: targetId, diff --git a/apps/sim/executor/execution/edge-manager.test.ts b/apps/sim/executor/execution/edge-manager.test.ts new file mode 100644 index 0000000000..3470c2d67e --- /dev/null +++ b/apps/sim/executor/execution/edge-manager.test.ts @@ -0,0 +1,1052 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DAG, DAGNode } from '@/executor/dag/builder' +import type { DAGEdge } from '@/executor/dag/types' +import type { SerializedBlock } from '@/serializer/types' +import { EdgeManager } from './edge-manager' + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })), +})) + +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, + outgoingEdges: DAGEdge[] = [], + incomingEdges: string[] = [] +): DAGNode { + const outEdgesMap = new Map() + outgoingEdges.forEach((edge, i) => { + outEdgesMap.set(`edge-${i}`, edge) + }) + + return { + id, + block: createMockBlock(id), + outgoingEdges: outEdgesMap, + incomingEdges: new Set(incomingEdges), + metadata: {}, + } +} + +function createMockDAG(nodes: Map): DAG { + return { + nodes, + loopConfigs: new Map(), + parallelConfigs: new Map(), + } +} + +describe('EdgeManager', () => { + describe('Happy path - basic workflows', () => { + it('should handle simple linear flow (A → B → C)', () => { + const blockAId = 'block-a' + const blockBId = 'block-b' + const blockCId = 'block-c' + + const blockANode = createMockNode(blockAId, [{ target: blockBId }]) + const blockBNode = createMockNode(blockBId, [{ target: blockCId }], [blockAId]) + const blockCNode = createMockNode(blockCId, [], [blockBId]) + + const nodes = new Map([ + [blockAId, blockANode], + [blockBId, blockBNode], + [blockCId, blockCNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // A completes → B becomes ready + const readyAfterA = edgeManager.processOutgoingEdges(blockANode, { result: 'done' }) + expect(readyAfterA).toContain(blockBId) + expect(readyAfterA).not.toContain(blockCId) + + // B completes → C becomes ready + const readyAfterB = edgeManager.processOutgoingEdges(blockBNode, { result: 'done' }) + expect(readyAfterB).toContain(blockCId) + }) + + it('should handle branching and each branch executing independently', () => { + const startId = 'start' + const branch1Id = 'branch-1' + const branch2Id = 'branch-2' + + const startNode = createMockNode(startId, [ + { target: branch1Id, sourceHandle: 'condition-opt1' }, + { target: branch2Id, sourceHandle: 'condition-opt2' }, + ]) + + const branch1Node = createMockNode(branch1Id, [], [startId]) + const branch2Node = createMockNode(branch2Id, [], [startId]) + + const nodes = new Map([ + [startId, startNode], + [branch1Id, branch1Node], + [branch2Id, branch2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Select option 1 + const readyNodes = edgeManager.processOutgoingEdges(startNode, { selectedOption: 'opt1' }) + expect(readyNodes).toContain(branch1Id) + expect(readyNodes).not.toContain(branch2Id) + }) + + it('should process standard block output with result', () => { + const sourceId = 'source' + const targetId = 'target' + + const sourceNode = createMockNode(sourceId, [{ target: targetId }]) + const targetNode = createMockNode(targetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [targetId, targetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Normal block output + const output = { + result: { data: 'test' }, + content: 'Hello world', + tokens: { prompt: 10, completion: 20, total: 30 }, + } + + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + expect(readyNodes).toContain(targetId) + }) + + it('should handle multiple sequential blocks completing in order', () => { + const block1Id = 'block-1' + const block2Id = 'block-2' + const block3Id = 'block-3' + const block4Id = 'block-4' + + const block1Node = createMockNode(block1Id, [{ target: block2Id }]) + const block2Node = createMockNode(block2Id, [{ target: block3Id }], [block1Id]) + const block3Node = createMockNode(block3Id, [{ target: block4Id }], [block2Id]) + const block4Node = createMockNode(block4Id, [], [block3Id]) + + const nodes = new Map([ + [block1Id, block1Node], + [block2Id, block2Node], + [block3Id, block3Node], + [block4Id, block4Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process through the chain + let ready = edgeManager.processOutgoingEdges(block1Node, {}) + expect(ready).toEqual([block2Id]) + + ready = edgeManager.processOutgoingEdges(block2Node, {}) + expect(ready).toEqual([block3Id]) + + ready = edgeManager.processOutgoingEdges(block3Node, {}) + expect(ready).toEqual([block4Id]) + + ready = edgeManager.processOutgoingEdges(block4Node, {}) + expect(ready).toEqual([]) + }) + }) + + describe('Multiple condition edges to same target', () => { + it('should not cascade-deactivate when multiple edges from same source go to same target', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + { target: function1Id, sourceHandle: 'condition-else' }, + ]) + + const function1Node = createMockNode(function1Id, [{ target: function2Id }], [conditionId]) + + const function2Node = createMockNode(function2Id, [], [function1Id]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'if' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + expect(function1Node.incomingEdges.size).toBe(0) + }) + + it('should handle "else if" selected when "if" points to same target', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if-id' }, + { target: function1Id, sourceHandle: 'condition-elseif-id' }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'elseif-id' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + }) + + it('should handle condition with if→A, elseif→B, else→A pattern', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + { target: function2Id, sourceHandle: 'condition-elseif' }, + { target: function1Id, sourceHandle: 'condition-else' }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + const function2Node = createMockNode(function2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'if' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + }) + + it('should activate correct target when elseif is selected (iteration 2)', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + { target: function2Id, sourceHandle: 'condition-elseif' }, + { target: function1Id, sourceHandle: 'condition-else' }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + const function2Node = createMockNode(function2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'elseif' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function2Id) + expect(readyNodes).not.toContain(function1Id) + }) + + it('should activate Function1 when else is selected (iteration 3+)', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + { target: function2Id, sourceHandle: 'condition-elseif' }, + { target: function1Id, sourceHandle: 'condition-else' }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + const function2Node = createMockNode(function2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'else' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + }) + }) + + describe('Cascade deactivation', () => { + it('should cascade-deactivate descendants when ALL edges to target are deactivated', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + ]) + + const function1Node = createMockNode(function1Id, [{ target: function2Id }], [conditionId]) + + const function2Node = createMockNode(function2Id, [], [function1Id]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'else' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).not.toContain(function1Id) + }) + }) + + describe('Exact workflow reproduction: modern-atoll', () => { + const conditionId = '63353190-ed15-427b-af6b-c0967ba06010' + const function1Id = '576cc8a3-c3f3-40f5-a515-8320462b8162' + const function2Id = 'b96067c5-0c5c-4a91-92bd-299e8c4ab42d' + + const ifConditionId = '63353190-ed15-427b-af6b-c0967ba06010-if' + const elseIfConditionId = '63353190-ed15-427b-af6b-c0967ba06010-else-if-1766204485970' + const elseConditionId = '63353190-ed15-427b-af6b-c0967ba06010-else' + + function setupWorkflow() { + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: `condition-${ifConditionId}` }, + { target: function2Id, sourceHandle: `condition-${elseIfConditionId}` }, + { target: function1Id, sourceHandle: `condition-${elseConditionId}` }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + const function2Node = createMockNode(function2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + return createMockDAG(nodes) + } + + it('iteration 1: if selected (loop.index == 1) should activate Function 1', () => { + const dag = setupWorkflow() + const edgeManager = new EdgeManager(dag) + const conditionNode = dag.nodes.get(conditionId)! + + const output = { selectedOption: ifConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + }) + + it('iteration 2: else if selected (loop.index == 2) should activate Function 2', () => { + const dag = setupWorkflow() + const edgeManager = new EdgeManager(dag) + const conditionNode = dag.nodes.get(conditionId)! + + const output = { selectedOption: elseIfConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function2Id) + expect(readyNodes).not.toContain(function1Id) + }) + + it('iteration 3+: else selected (loop.index > 2) should activate Function 1', () => { + const dag = setupWorkflow() + const edgeManager = new EdgeManager(dag) + const conditionNode = dag.nodes.get(conditionId)! + + const output = { selectedOption: elseConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + }) + + it('should handle multiple iterations correctly (simulating loop)', () => { + const dag = setupWorkflow() + const edgeManager = new EdgeManager(dag) + const conditionNode = dag.nodes.get(conditionId)! + + // Iteration 1: if selected + { + dag.nodes.get(function1Id)!.incomingEdges = new Set([conditionId]) + dag.nodes.get(function2Id)!.incomingEdges = new Set([conditionId]) + edgeManager.clearDeactivatedEdges() + + const output = { selectedOption: ifConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + } + + // Iteration 2: else if selected + { + dag.nodes.get(function1Id)!.incomingEdges = new Set([conditionId]) + dag.nodes.get(function2Id)!.incomingEdges = new Set([conditionId]) + edgeManager.clearDeactivatedEdges() + + const output = { selectedOption: elseIfConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + expect(readyNodes).toContain(function2Id) + expect(readyNodes).not.toContain(function1Id) + } + + // Iteration 3: else selected + { + dag.nodes.get(function1Id)!.incomingEdges = new Set([conditionId]) + dag.nodes.get(function2Id)!.incomingEdges = new Set([conditionId]) + edgeManager.clearDeactivatedEdges() + + const output = { selectedOption: elseConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + } + }) + }) + + describe('Error/Success edge handling', () => { + it('should activate error edge when output has error', () => { + const sourceId = 'source-1' + const successTargetId = 'success-target' + const errorTargetId = 'error-target' + + const sourceNode = createMockNode(sourceId, [ + { target: successTargetId, sourceHandle: 'source' }, + { target: errorTargetId, sourceHandle: 'error' }, + ]) + + const successNode = createMockNode(successTargetId, [], [sourceId]) + const errorNode = createMockNode(errorTargetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [successTargetId, successNode], + [errorTargetId, errorNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { error: 'Something went wrong' } + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + + expect(readyNodes).toContain(errorTargetId) + expect(readyNodes).not.toContain(successTargetId) + }) + + it('should activate source edge when no error', () => { + const sourceId = 'source-1' + const successTargetId = 'success-target' + const errorTargetId = 'error-target' + + const sourceNode = createMockNode(sourceId, [ + { target: successTargetId, sourceHandle: 'source' }, + { target: errorTargetId, sourceHandle: 'error' }, + ]) + + const successNode = createMockNode(successTargetId, [], [sourceId]) + const errorNode = createMockNode(errorTargetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [successTargetId, successNode], + [errorTargetId, errorNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { result: 'success' } + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + + expect(readyNodes).toContain(successTargetId) + expect(readyNodes).not.toContain(errorTargetId) + }) + }) + + describe('Router edge handling', () => { + it('should activate only the selected route', () => { + const routerId = 'router-1' + const route1Id = 'route-1' + const route2Id = 'route-2' + const route3Id = 'route-3' + + const routerNode = createMockNode(routerId, [ + { target: route1Id, sourceHandle: 'router-route1' }, + { target: route2Id, sourceHandle: 'router-route2' }, + { target: route3Id, sourceHandle: 'router-route3' }, + ]) + + const route1Node = createMockNode(route1Id, [], [routerId]) + const route2Node = createMockNode(route2Id, [], [routerId]) + const route3Node = createMockNode(route3Id, [], [routerId]) + + const nodes = new Map([ + [routerId, routerNode], + [route1Id, route1Node], + [route2Id, route2Node], + [route3Id, route3Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedRoute: 'route2' } + const readyNodes = edgeManager.processOutgoingEdges(routerNode, output) + + expect(readyNodes).toContain(route2Id) + expect(readyNodes).not.toContain(route1Id) + expect(readyNodes).not.toContain(route3Id) + }) + }) + + describe('Node with multiple incoming sources', () => { + it('should wait for all incoming edges before becoming ready', () => { + const source1Id = 'source-1' + const source2Id = 'source-2' + const targetId = 'target' + + const source1Node = createMockNode(source1Id, [{ target: targetId }]) + const source2Node = createMockNode(source2Id, [{ target: targetId }]) + const targetNode = createMockNode(targetId, [], [source1Id, source2Id]) + + const nodes = new Map([ + [source1Id, source1Node], + [source2Id, source2Node], + [targetId, targetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process first source + const readyAfterFirst = edgeManager.processOutgoingEdges(source1Node, {}) + expect(readyAfterFirst).not.toContain(targetId) + + // Process second source + const readyAfterSecond = edgeManager.processOutgoingEdges(source2Node, {}) + expect(readyAfterSecond).toContain(targetId) + }) + }) + + describe('clearDeactivatedEdgesForNodes', () => { + it('should clear deactivated edges for specified nodes', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + ]) + const function1Node = createMockNode(function1Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Deactivate edge by selecting non-existent option + edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'nonexistent' }) + + // Clear deactivated edges for condition node + edgeManager.clearDeactivatedEdgesForNodes(new Set([conditionId])) + + // Restore incoming edge and try again + function1Node.incomingEdges.add(conditionId) + + // Now select "if" - should work since edge is no longer deactivated + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'if' }) + expect(readyNodes).toContain(function1Id) + }) + }) + + describe('restoreIncomingEdge', () => { + it('should restore an incoming edge to a target node', () => { + const sourceId = 'source-1' + const targetId = 'target-1' + + const sourceNode = createMockNode(sourceId, [{ target: targetId }]) + const targetNode = createMockNode(targetId, [], []) + + const nodes = new Map([ + [sourceId, sourceNode], + [targetId, targetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + expect(targetNode.incomingEdges.has(sourceId)).toBe(false) + + edgeManager.restoreIncomingEdge(targetId, sourceId) + + expect(targetNode.incomingEdges.has(sourceId)).toBe(true) + }) + }) + + describe('Diamond pattern (convergent paths)', () => { + it('should handle diamond: condition splits then converges at merge point', () => { + const conditionId = 'condition-1' + const branchAId = 'branch-a' + const branchBId = 'branch-b' + const mergeId = 'merge-point' + + const conditionNode = createMockNode(conditionId, [ + { target: branchAId, sourceHandle: 'condition-if' }, + { target: branchBId, sourceHandle: 'condition-else' }, + ]) + + const branchANode = createMockNode(branchAId, [{ target: mergeId }], [conditionId]) + const branchBNode = createMockNode(branchBId, [{ target: mergeId }], [conditionId]) + const mergeNode = createMockNode(mergeId, [], [branchAId, branchBId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [branchAId, branchANode], + [branchBId, branchBNode], + [mergeId, mergeNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Select "if" branch + const output = { selectedOption: 'if' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + // Branch A should be ready + expect(readyNodes).toContain(branchAId) + expect(readyNodes).not.toContain(branchBId) + + // Process branch A completing + const mergeReady = edgeManager.processOutgoingEdges(branchANode, {}) + + // Merge point should be ready since branch B was deactivated + expect(mergeReady).toContain(mergeId) + }) + + it('should wait for both branches when both are active (parallel merge)', () => { + const source1Id = 'source-1' + const source2Id = 'source-2' + const mergeId = 'merge-point' + + const source1Node = createMockNode(source1Id, [{ target: mergeId }]) + const source2Node = createMockNode(source2Id, [{ target: mergeId }]) + const mergeNode = createMockNode(mergeId, [], [source1Id, source2Id]) + + const nodes = new Map([ + [source1Id, source1Node], + [source2Id, source2Node], + [mergeId, mergeNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process first source + const readyAfterFirst = edgeManager.processOutgoingEdges(source1Node, {}) + expect(readyAfterFirst).not.toContain(mergeId) + + // Process second source + const readyAfterSecond = edgeManager.processOutgoingEdges(source2Node, {}) + expect(readyAfterSecond).toContain(mergeId) + }) + }) + + describe('Error edge cascading', () => { + it('should cascade-deactivate success path when error occurs', () => { + const sourceId = 'source' + const successId = 'success-handler' + const errorId = 'error-handler' + const afterSuccessId = 'after-success' + + const sourceNode = createMockNode(sourceId, [ + { target: successId, sourceHandle: 'source' }, + { target: errorId, sourceHandle: 'error' }, + ]) + + const successNode = createMockNode(successId, [{ target: afterSuccessId }], [sourceId]) + const errorNode = createMockNode(errorId, [], [sourceId]) + const afterSuccessNode = createMockNode(afterSuccessId, [], [successId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [successId, successNode], + [errorId, errorNode], + [afterSuccessId, afterSuccessNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Source produces an error + const output = { error: 'Something failed' } + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + + // Error handler should be ready, success handler should not + expect(readyNodes).toContain(errorId) + expect(readyNodes).not.toContain(successId) + }) + + it('should cascade-deactivate error path when success occurs', () => { + const sourceId = 'source' + const successId = 'success-handler' + const errorId = 'error-handler' + const afterErrorId = 'after-error' + + const sourceNode = createMockNode(sourceId, [ + { target: successId, sourceHandle: 'source' }, + { target: errorId, sourceHandle: 'error' }, + ]) + + const successNode = createMockNode(successId, [], [sourceId]) + const errorNode = createMockNode(errorId, [{ target: afterErrorId }], [sourceId]) + const afterErrorNode = createMockNode(afterErrorId, [], [errorId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [successId, successNode], + [errorId, errorNode], + [afterErrorId, afterErrorNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Source succeeds + const output = { result: 'success' } + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + + // Success handler should be ready, error handler should not + expect(readyNodes).toContain(successId) + expect(readyNodes).not.toContain(errorId) + }) + + it('should handle error edge to same target as success edge', () => { + const sourceId = 'source' + const handlerId = 'handler' + + const sourceNode = createMockNode(sourceId, [ + { target: handlerId, sourceHandle: 'source' }, + { target: handlerId, sourceHandle: 'error' }, + ]) + + const handlerNode = createMockNode(handlerId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [handlerId, handlerNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // When error occurs, handler should still be ready via error edge + const errorOutput = { error: 'Failed' } + const readyWithError = edgeManager.processOutgoingEdges(sourceNode, errorOutput) + expect(readyWithError).toContain(handlerId) + }) + }) + + describe('Chained conditions', () => { + it('should handle sequential conditions (condition1 → condition2)', () => { + const condition1Id = 'condition-1' + const condition2Id = 'condition-2' + const target1Id = 'target-1' + const target2Id = 'target-2' + + const condition1Node = createMockNode(condition1Id, [ + { target: condition2Id, sourceHandle: 'condition-if' }, + { target: target1Id, sourceHandle: 'condition-else' }, + ]) + + const condition2Node = createMockNode( + condition2Id, + [ + { target: target2Id, sourceHandle: 'condition-if' }, + { target: target1Id, sourceHandle: 'condition-else' }, + ], + [condition1Id] + ) + + const target1Node = createMockNode(target1Id, [], [condition1Id, condition2Id]) + const target2Node = createMockNode(target2Id, [], [condition2Id]) + + const nodes = new Map([ + [condition1Id, condition1Node], + [condition2Id, condition2Node], + [target1Id, target1Node], + [target2Id, target2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // First condition: select "if" → goes to condition2 + const ready1 = edgeManager.processOutgoingEdges(condition1Node, { selectedOption: 'if' }) + expect(ready1).toContain(condition2Id) + expect(ready1).not.toContain(target1Id) + + // Second condition: select "else" → goes to target1 + const ready2 = edgeManager.processOutgoingEdges(condition2Node, { selectedOption: 'else' }) + expect(ready2).toContain(target1Id) + expect(ready2).not.toContain(target2Id) + }) + }) + + describe('Loop edge handling', () => { + it('should skip backwards edge when skipBackwardsEdge is true', () => { + const loopStartId = 'loop-start' + const loopBodyId = 'loop-body' + + const loopStartNode = createMockNode(loopStartId, [ + { target: loopBodyId, sourceHandle: 'loop-start-source' }, + ]) + + // Use correct constant: loop_continue (with underscore) + const loopBodyNode = createMockNode( + loopBodyId, + [{ target: loopStartId, sourceHandle: 'loop_continue' }], + [loopStartId] + ) + + const nodes = new Map([ + [loopStartId, loopStartNode], + [loopBodyId, loopBodyNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process with skipBackwardsEdge = true + const readyNodes = edgeManager.processOutgoingEdges(loopBodyNode, {}, true) + + // Loop start should NOT be activated because we're skipping backwards edges + expect(readyNodes).not.toContain(loopStartId) + }) + + it('should include backwards edge when skipBackwardsEdge is false', () => { + const loopStartId = 'loop-start' + const loopBodyId = 'loop-body' + + // Use correct constant: loop_continue (with underscore) + const loopBodyNode = createMockNode(loopBodyId, [ + { target: loopStartId, sourceHandle: 'loop_continue' }, + ]) + + const loopStartNode = createMockNode(loopStartId, [], [loopBodyId]) + + const nodes = new Map([ + [loopStartId, loopStartNode], + [loopBodyId, loopBodyNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process without skipping backwards edges + const readyNodes = edgeManager.processOutgoingEdges(loopBodyNode, {}, false) + + // Loop start should be activated + expect(readyNodes).toContain(loopStartId) + }) + + it('should handle loop-exit vs loop-continue based on selectedRoute', () => { + const loopCheckId = 'loop-check' + const loopBodyId = 'loop-body' + const afterLoopId = 'after-loop' + + // Use correct constants: loop_continue, loop_exit (with underscores) + const loopCheckNode = createMockNode(loopCheckId, [ + { target: loopBodyId, sourceHandle: 'loop_continue' }, + { target: afterLoopId, sourceHandle: 'loop_exit' }, + ]) + + const loopBodyNode = createMockNode(loopBodyId, [], [loopCheckId]) + const afterLoopNode = createMockNode(afterLoopId, [], [loopCheckId]) + + const nodes = new Map([ + [loopCheckId, loopCheckNode], + [loopBodyId, loopBodyNode], + [afterLoopId, afterLoopNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Test loop-exit selection using the correct constant value + const exitOutput = { selectedRoute: 'loop_exit' } + const exitReady = edgeManager.processOutgoingEdges(loopCheckNode, exitOutput) + expect(exitReady).toContain(afterLoopId) + expect(exitReady).not.toContain(loopBodyId) + }) + }) + + describe('Complex routing patterns', () => { + it('should handle 3+ conditions pointing to same target', () => { + const conditionId = 'condition-1' + const targetId = 'target' + const altTargetId = 'alt-target' + + const conditionNode = createMockNode(conditionId, [ + { target: targetId, sourceHandle: 'condition-cond1' }, + { target: targetId, sourceHandle: 'condition-cond2' }, + { target: targetId, sourceHandle: 'condition-cond3' }, + { target: altTargetId, sourceHandle: 'condition-else' }, + ]) + + const targetNode = createMockNode(targetId, [], [conditionId]) + const altTargetNode = createMockNode(altTargetId, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [targetId, targetNode], + [altTargetId, altTargetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Select middle condition + const output = { selectedOption: 'cond2' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(targetId) + expect(readyNodes).not.toContain(altTargetId) + }) + + it('should handle no matching condition (all edges deactivated)', () => { + const conditionId = 'condition-1' + const target1Id = 'target-1' + const target2Id = 'target-2' + + const conditionNode = createMockNode(conditionId, [ + { target: target1Id, sourceHandle: 'condition-cond1' }, + { target: target2Id, sourceHandle: 'condition-cond2' }, + ]) + + const target1Node = createMockNode(target1Id, [], [conditionId]) + const target2Node = createMockNode(target2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [target1Id, target1Node], + [target2Id, target2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Select non-existent condition + const output = { selectedOption: 'nonexistent' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + // No nodes should be ready + expect(readyNodes).not.toContain(target1Id) + expect(readyNodes).not.toContain(target2Id) + expect(readyNodes).toHaveLength(0) + }) + }) + + describe('Edge with no sourceHandle (default edge)', () => { + it('should activate edge without sourceHandle by default', () => { + const sourceId = 'source' + const targetId = 'target' + + const sourceNode = createMockNode(sourceId, [{ target: targetId }]) + const targetNode = createMockNode(targetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [targetId, targetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, {}) + + expect(readyNodes).toContain(targetId) + }) + + it('should not activate default edge when error occurs', () => { + const sourceId = 'source' + const targetId = 'target' + const errorTargetId = 'error-target' + + const sourceNode = createMockNode(sourceId, [ + { target: targetId }, + { target: errorTargetId, sourceHandle: 'error' }, + ]) + + const targetNode = createMockNode(targetId, [], [sourceId]) + const errorTargetNode = createMockNode(errorTargetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [targetId, targetNode], + [errorTargetId, errorTargetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // When no explicit error, default edge should be activated + const successReady = edgeManager.processOutgoingEdges(sourceNode, { result: 'ok' }) + expect(successReady).toContain(targetId) + }) + }) +}) diff --git a/apps/sim/executor/execution/edge-manager.ts b/apps/sim/executor/execution/edge-manager.ts index ec69512e70..dc04a4cec7 100644 --- a/apps/sim/executor/execution/edge-manager.ts +++ b/apps/sim/executor/execution/edge-manager.ts @@ -18,7 +18,10 @@ export class EdgeManager { ): string[] { const readyNodes: string[] = [] const activatedTargets: string[] = [] + const edgesToDeactivate: Array<{ target: string; handle?: string }> = [] + // First pass: categorize edges as activating or deactivating + // Don't modify incomingEdges yet - we need the original state for deactivation checks for (const [edgeId, edge] of node.outgoingEdges) { if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) { continue @@ -32,23 +35,31 @@ export class EdgeManager { edge.sourceHandle === EDGE.LOOP_EXIT if (!isLoopEdge) { - this.deactivateEdgeAndDescendants(node.id, edge.target, edge.sourceHandle) + edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle }) } - continue } - const targetNode = this.dag.nodes.get(edge.target) + activatedTargets.push(edge.target) + } + + // Second pass: process deactivations while incomingEdges is still intact + // This ensures hasActiveIncomingEdges can find all potential sources + for (const { target, handle } of edgesToDeactivate) { + this.deactivateEdgeAndDescendants(node.id, target, handle) + } + + // Third pass: update incomingEdges for activated targets + for (const targetId of activatedTargets) { + const targetNode = this.dag.nodes.get(targetId) if (!targetNode) { - logger.warn('Target node not found', { target: edge.target }) + logger.warn('Target node not found', { target: targetId }) continue } - targetNode.incomingEdges.delete(node.id) - activatedTargets.push(edge.target) } - // Check readiness after all edges processed to ensure cascade deactivations are complete + // Fourth pass: check readiness after all edge processing is complete for (const targetId of activatedTargets) { const targetNode = this.dag.nodes.get(targetId) if (targetNode && this.isNodeReady(targetNode)) { @@ -162,7 +173,10 @@ export class EdgeManager { const targetNode = this.dag.nodes.get(targetId) if (!targetNode) return - const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, sourceId) + // Check if target has other active incoming edges + // Pass the specific edge key being deactivated, not just source ID, + // to handle multiple edges from same source to same target (e.g., condition branches) + const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, edgeKey) if (!hasOtherActiveIncoming) { for (const [_, outgoingEdge] of targetNode.outgoingEdges) { this.deactivateEdgeAndDescendants(targetId, outgoingEdge.target, outgoingEdge.sourceHandle) @@ -170,10 +184,13 @@ export class EdgeManager { } } - private hasActiveIncomingEdges(node: DAGNode, excludeSourceId: string): boolean { + /** + * Checks if a node has any active incoming edges besides the one being excluded. + * This properly handles the case where multiple edges from the same source go to + * the same target (e.g., multiple condition branches pointing to one block). + */ + private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean { for (const incomingSourceId of node.incomingEdges) { - if (incomingSourceId === excludeSourceId) continue - const incomingNode = this.dag.nodes.get(incomingSourceId) if (!incomingNode) continue @@ -184,6 +201,8 @@ export class EdgeManager { node.id, incomingEdge.sourceHandle ) + // Skip the specific edge being excluded, but check other edges from same source + if (incomingEdgeKey === excludeEdgeKey) continue if (!this.deactivatedEdges.has(incomingEdgeKey)) { return true } diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index 9f81d20fee..abc4159482 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -43,8 +43,6 @@ function simulateConditionExecution(code: string): { error?: string } { try { - // The code is in format: "const context = {...};\nreturn Boolean(...)" - // We need to execute it and return the result const fn = new Function(code) const result = fn() return { success: true, output: { result } } @@ -350,4 +348,283 @@ describe('ConditionBlockHandler', () => { /Evaluation error in condition "if".*Execution timeout/ ) }) + + describe('Multiple branches to same target', () => { + it('should handle if and else pointing to same target', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value > 5' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + // Both branches point to the same target + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).conditionResult).toBe(true) + expect((result as any).selectedOption).toBe('cond1') + expect((result as any).selectedPath).toEqual({ + blockId: mockTargetBlock1.id, + blockType: 'target', + blockTitle: 'Target Block 1', + }) + }) + + it('should select else branch to same target when if fails', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value < 0' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + // Both branches point to the same target + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).conditionResult).toBe(true) + expect((result as any).selectedOption).toBe('else1') + expect((result as any).selectedPath).toEqual({ + blockId: mockTargetBlock1.id, + blockType: 'target', + blockTitle: 'Target Block 1', + }) + }) + + it('should handle if→A, elseif→B, else→A pattern', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value === 1' }, + { id: 'cond2', title: 'else if', value: 'context.value === 2' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' }, + ] + + // value is 10, so else should be selected (pointing to target 1) + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).conditionResult).toBe(true) + expect((result as any).selectedOption).toBe('else1') + expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock1.id) + }) + }) + + describe('Condition evaluation with different data types', () => { + it('should evaluate string comparison conditions', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { name: 'test', status: 'active' }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.status === "active"' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + + it('should evaluate boolean conditions', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { isEnabled: true, count: 5 }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.isEnabled' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + + it('should evaluate array length conditions', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { items: [1, 2, 3, 4, 5] }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.items.length > 3' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + + it('should evaluate null/undefined check conditions', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { data: null }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.data === null' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + }) + + describe('Multiple else-if conditions', () => { + it('should evaluate multiple else-if conditions in order', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { score: 75 }, + executed: true, + executionTime: 100, + }) + + const mockTargetBlock3: SerializedBlock = { + id: 'target-block-3', + metadata: { id: 'target', name: 'Target Block 3' }, + position: { x: 100, y: 200 }, + config: { tool: 'target_tool_3', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + + mockContext.workflow!.blocks!.push(mockTargetBlock3) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.score >= 90' }, + { id: 'cond2', title: 'else if', value: 'context.score >= 70' }, + { id: 'cond3', title: 'else if', value: 'context.score >= 50' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' }, + { source: mockBlock.id, target: mockTargetBlock3.id, sourceHandle: 'condition-cond3' }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + // Score is 75, so second condition (>=70) should match + expect((result as any).selectedOption).toBe('cond2') + expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id) + }) + + it('should skip to else when all else-if fail', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { score: 30 }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.score >= 90' }, + { id: 'cond2', title: 'else if', value: 'context.score >= 70' }, + { id: 'cond3', title: 'else if', value: 'context.score >= 50' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('else1') + }) + }) + + describe('Condition with no outgoing edge', () => { + it('should return null path when condition matches but has no edge', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'true' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + // No connection for cond1 + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + // Condition matches but no edge for it + expect((result as any).conditionResult).toBe(false) + expect((result as any).selectedPath).toBeNull() + }) + }) + + describe('Empty conditions handling', () => { + it('should handle empty conditions array', async () => { + const conditions: unknown[] = [] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).conditionResult).toBe(false) + expect((result as any).selectedPath).toBeNull() + expect((result as any).selectedOption).toBeNull() + }) + + it('should handle conditions passed as array directly', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'true' }, + { id: 'else1', title: 'else', value: '' }, + ] + // Pass as array instead of JSON string + const inputs = { conditions } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + }) + + describe('Virtual block ID handling', () => { + it('should use currentVirtualBlockId for decision key when available', async () => { + mockContext.currentVirtualBlockId = 'virtual-block-123' + + const conditions = [ + { id: 'cond1', title: 'if', value: 'true' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + await handler.execute(mockContext, mockBlock, inputs) + + // Decision should be stored under virtual block ID, not actual block ID + expect(mockContext.decisions.condition.get('virtual-block-123')).toBe('cond1') + expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false) + }) + }) }) diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts index cb5bf458b7..f6a71565ba 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.ts @@ -92,8 +92,7 @@ export class ConditionBlockHandler implements BlockHandler { conditions, outgoingConnections || [], evalContext, - ctx, - block + ctx ) if (!selectedConnection || !selectedCondition) { @@ -158,8 +157,7 @@ export class ConditionBlockHandler implements BlockHandler { conditions: Array<{ id: string; title: string; value: string }>, outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>, evalContext: Record, - ctx: ExecutionContext, - block: SerializedBlock + ctx: ExecutionContext ): Promise<{ selectedConnection: { target: string; sourceHandle?: string } | null selectedCondition: { id: string; title: string; value: string } | null @@ -187,13 +185,6 @@ export class ConditionBlockHandler implements BlockHandler { return { selectedConnection: connection, selectedCondition: condition } } // Condition is true but has no outgoing edge - branch ends gracefully - logger.info( - `Condition "${condition.title}" is true but has no outgoing edge - branch ending`, - { - blockId: block.id, - conditionId: condition.id, - } - ) return { selectedConnection: null, selectedCondition: null } } } catch (error: any) { @@ -204,18 +195,13 @@ export class ConditionBlockHandler implements BlockHandler { const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE) if (elseCondition) { - logger.warn(`No condition met, selecting 'else' path`, { blockId: block.id }) const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id) if (elseConnection) { return { selectedConnection: elseConnection, selectedCondition: elseCondition } } - logger.info(`No condition matched and else has no connection - branch ending`, { - blockId: block.id, - }) return { selectedConnection: null, selectedCondition: null } } - logger.info(`No condition matched and no else block - branch ending`, { blockId: block.id }) return { selectedConnection: null, selectedCondition: null } }