Files
simstudioai--sim/apps/sim/executor/dag/construction/edges.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1837 lines
59 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import { buildBranchNodeId } from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedLoop, SerializedWorkflow } from '@/serializer/types'
import { EdgeConstructor } from './edges'
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<string, DAGNode>()
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<string, SerializedLoop> = {},
parallels: Record<string, any> = {}
): 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('nested subflow skip-at-start bypasses', () => {
it('wires a nested loop start exit to the next sibling inside a parallel branch', () => {
const parallelId = 'parallel-1'
const loopId = 'loop-1'
const afterId = 'after'
const loopStartId = `loop-${loopId}-sentinel-start`
const loopEndId = `loop-${loopId}-sentinel-end`
const afterTemplateId = buildBranchNodeId(afterId, 0)
const dag = createMockDAG([loopStartId, loopEndId, afterTemplateId])
dag.nodes.get(loopStartId)!.metadata = {
isSentinel: true,
sentinelType: 'start',
subflowId: loopId,
subflowType: 'loop',
}
dag.nodes.get(loopEndId)!.metadata = {
isSentinel: true,
sentinelType: 'end',
subflowId: loopId,
subflowType: 'loop',
}
dag.nodes.get(afterTemplateId)!.metadata = {
isParallelBranch: true,
subflowId: parallelId,
subflowType: 'parallel',
branchIndex: 0,
}
dag.loopConfigs.set(loopId, { id: loopId, nodes: [], iterations: 1 })
dag.parallelConfigs.set(parallelId, {
id: parallelId,
nodes: [loopId, afterId],
count: 1,
})
const workflow = createMockWorkflow(
[createMockBlock(loopId, 'loop'), createMockBlock(afterId)],
[{ source: loopId, target: afterId }]
)
edgeConstructor.execute(
workflow,
dag,
new Set([loopId, afterId]),
new Set(),
new Set([loopId, afterId]),
new Map()
)
const loopStartTargets = Array.from(dag.nodes.get(loopStartId)!.outgoingEdges.values())
expect(loopStartTargets).toContainEqual({
target: loopEndId,
sourceHandle: 'loop_exit',
targetHandle: undefined,
})
expect(Array.from(dag.nodes.get(loopEndId)!.outgoingEdges.values())).toContainEqual({
target: afterTemplateId,
sourceHandle: 'loop_exit',
targetHandle: undefined,
})
expect(dag.nodes.get(loopEndId)!.incomingEdges).not.toContain(loopStartId)
})
it('wires a parallel start exit bypass to a downstream parallel sentinel start', () => {
const sourceParallelId = 'parallel-a'
const targetParallelId = 'parallel-b'
const sourceStartId = `parallel-${sourceParallelId}-sentinel-start`
const sourceEndId = `parallel-${sourceParallelId}-sentinel-end`
const targetStartId = `parallel-${targetParallelId}-sentinel-start`
const targetEndId = `parallel-${targetParallelId}-sentinel-end`
const dag = createMockDAG([sourceStartId, sourceEndId, targetStartId, targetEndId])
dag.parallelConfigs.set(sourceParallelId, { id: sourceParallelId, nodes: [], count: 1 })
dag.parallelConfigs.set(targetParallelId, { id: targetParallelId, nodes: [], count: 1 })
const workflow = createMockWorkflow(
[
createMockBlock(sourceParallelId, 'parallel'),
createMockBlock(targetParallelId, 'parallel'),
],
[{ source: sourceParallelId, target: targetParallelId }]
)
edgeConstructor.execute(workflow, dag, new Set(), new Set(), new Set(), new Map())
const sourceStartTargets = Array.from(dag.nodes.get(sourceStartId)!.outgoingEdges.values())
expect(sourceStartTargets).toContainEqual({
target: sourceEndId,
sourceHandle: 'parallel_exit',
targetHandle: undefined,
})
expect(dag.nodes.get(sourceEndId)!.incomingEdges).not.toContain(sourceStartId)
})
it('wires terminal top-level loop start exit through its own sentinel end', () => {
const loopId = 'loop-1'
const taskId = 'task-1'
const loopStartId = `loop-${loopId}-sentinel-start`
const loopEndId = `loop-${loopId}-sentinel-end`
const dag = createMockDAG([loopStartId, loopEndId, taskId])
dag.loopConfigs.set(loopId, { id: loopId, nodes: [taskId], iterations: 1 })
const workflow = createMockWorkflow(
[createMockBlock(loopId, 'loop'), createMockBlock(taskId)],
[]
)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([taskId]),
new Set([taskId]),
new Map()
)
const loopStartTargets = Array.from(dag.nodes.get(loopStartId)!.outgoingEdges.values())
expect(loopStartTargets).toContainEqual({
target: loopEndId,
sourceHandle: 'loop_exit',
targetHandle: undefined,
})
expect(dag.nodes.get(loopEndId)!.incomingEdges).not.toContain(loopStartId)
})
})
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)!
const startEdge = Array.from(sentinelStartNode.outgoingEdges.values()).find(
(edge) => edge.target === nodeInLoopId
)
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)!
const bodyStartEdges = Array.from(sentinelStartNode.outgoingEdges.values()).filter(
(edge) => edge.target === node1Id || edge.target === node2Id
)
expect(bodyStartEdges).toHaveLength(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)
})
})
describe('Subflow-to-subflow edge wiring', () => {
describe('Parallel → Parallel', () => {
it('should wire parallel sentinel end to next parallel sentinel start', () => {
const parallel1Id = 'parallel-1'
const parallel2Id = 'parallel-2'
const task1Id = 'task-1'
const task2Id = 'task-2'
const task1TemplateId = `${task1Id}__branch-0`
const task2TemplateId = `${task2Id}__branch-0`
const parallel1SentinelStart = `parallel-${parallel1Id}-sentinel-start`
const parallel1SentinelEnd = `parallel-${parallel1Id}-sentinel-end`
const parallel2SentinelStart = `parallel-${parallel2Id}-sentinel-start`
const parallel2SentinelEnd = `parallel-${parallel2Id}-sentinel-end`
const workflow = createMockWorkflow(
[
createMockBlock(parallel1Id, 'parallel'),
createMockBlock(parallel2Id, 'parallel'),
createMockBlock(task1Id),
createMockBlock(task2Id),
],
[{ source: parallel1Id, target: parallel2Id }],
{},
{
[parallel1Id]: { id: parallel1Id, nodes: [task1Id], count: 2 },
[parallel2Id]: { id: parallel2Id, nodes: [task2Id], count: 2 },
}
)
const dag = createMockDAG([
task1TemplateId,
task2TemplateId,
parallel1SentinelStart,
parallel1SentinelEnd,
parallel2SentinelStart,
parallel2SentinelEnd,
])
dag.parallelConfigs.set(parallel1Id, { id: parallel1Id, nodes: [task1Id], count: 2 })
dag.parallelConfigs.set(parallel2Id, { id: parallel2Id, nodes: [task2Id], count: 2 })
edgeConstructor.execute(
workflow,
dag,
new Set([task1Id, task2Id]),
new Set(),
new Set([
task1TemplateId,
task2TemplateId,
parallel1SentinelStart,
parallel1SentinelEnd,
parallel2SentinelStart,
parallel2SentinelEnd,
]),
new Map()
)
const parallel1EndNode = dag.nodes.get(parallel1SentinelEnd)!
const edgesToParallel2 = Array.from(parallel1EndNode.outgoingEdges.values()).filter(
(e) => e.target === parallel2SentinelStart
)
expect(edgesToParallel2.length).toBeGreaterThan(0)
expect(edgesToParallel2[0].sourceHandle).toBe('parallel_exit')
})
})
describe('Loop → Loop', () => {
it('should wire loop sentinel end to next loop sentinel start', () => {
const loop1Id = 'loop-1'
const loop2Id = 'loop-2'
const task1Id = 'task-1'
const task2Id = 'task-2'
const loop1SentinelStart = `loop-${loop1Id}-sentinel-start`
const loop1SentinelEnd = `loop-${loop1Id}-sentinel-end`
const loop2SentinelStart = `loop-${loop2Id}-sentinel-start`
const loop2SentinelEnd = `loop-${loop2Id}-sentinel-end`
const workflow = createMockWorkflow(
[
createMockBlock(loop1Id, 'loop'),
createMockBlock(loop2Id, 'loop'),
createMockBlock(task1Id),
createMockBlock(task2Id),
],
[{ source: loop1Id, target: loop2Id }],
{
[loop1Id]: {
id: loop1Id,
nodes: [task1Id],
iterations: 3,
loopType: 'for',
} as SerializedLoop,
[loop2Id]: {
id: loop2Id,
nodes: [task2Id],
iterations: 3,
loopType: 'for',
} as SerializedLoop,
}
)
const dag = createMockDAG([
task1Id,
task2Id,
loop1SentinelStart,
loop1SentinelEnd,
loop2SentinelStart,
loop2SentinelEnd,
])
dag.loopConfigs.set(loop1Id, {
id: loop1Id,
nodes: [task1Id],
iterations: 3,
loopType: 'for',
} as SerializedLoop)
dag.loopConfigs.set(loop2Id, {
id: loop2Id,
nodes: [task2Id],
iterations: 3,
loopType: 'for',
} as SerializedLoop)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([task1Id, task2Id]),
new Set([
task1Id,
task2Id,
loop1SentinelStart,
loop1SentinelEnd,
loop2SentinelStart,
loop2SentinelEnd,
]),
new Map()
)
const loop1EndNode = dag.nodes.get(loop1SentinelEnd)!
const edgesToLoop2 = Array.from(loop1EndNode.outgoingEdges.values()).filter(
(e) => e.target === loop2SentinelStart
)
expect(edgesToLoop2.length).toBeGreaterThan(0)
expect(edgesToLoop2[0].sourceHandle).toBe('loop_exit')
const loop1StartNode = dag.nodes.get(loop1SentinelStart)!
const earlyExitEdges = Array.from(loop1StartNode.outgoingEdges.values()).filter(
(e) => e.target === loop1SentinelEnd && e.sourceHandle === 'loop_exit'
)
expect(earlyExitEdges.length).toBeGreaterThan(0)
})
})
describe('Parallel → Loop', () => {
it('should wire parallel sentinel end to loop sentinel start', () => {
const parallelId = 'parallel-1'
const loopId = 'loop-1'
const taskInParallelId = 'parallel-task'
const taskInParallelTemplateId = `${taskInParallelId}__branch-0`
const taskInLoopId = 'loop-task'
const parallelSentinelStart = `parallel-${parallelId}-sentinel-start`
const parallelSentinelEnd = `parallel-${parallelId}-sentinel-end`
const loopSentinelStart = `loop-${loopId}-sentinel-start`
const loopSentinelEnd = `loop-${loopId}-sentinel-end`
const workflow = createMockWorkflow(
[
createMockBlock(parallelId, 'parallel'),
createMockBlock(loopId, 'loop'),
createMockBlock(taskInParallelId),
createMockBlock(taskInLoopId),
],
[{ source: parallelId, target: loopId }],
{
[loopId]: {
id: loopId,
nodes: [taskInLoopId],
iterations: 3,
loopType: 'for',
} as SerializedLoop,
},
{
[parallelId]: { id: parallelId, nodes: [taskInParallelId], count: 2 },
}
)
const dag = createMockDAG([
taskInParallelTemplateId,
taskInLoopId,
parallelSentinelStart,
parallelSentinelEnd,
loopSentinelStart,
loopSentinelEnd,
])
dag.parallelConfigs.set(parallelId, {
id: parallelId,
nodes: [taskInParallelId],
count: 2,
})
dag.loopConfigs.set(loopId, {
id: loopId,
nodes: [taskInLoopId],
iterations: 3,
loopType: 'for',
} as SerializedLoop)
edgeConstructor.execute(
workflow,
dag,
new Set([taskInParallelId]),
new Set([taskInLoopId]),
new Set([
taskInParallelTemplateId,
taskInLoopId,
parallelSentinelStart,
parallelSentinelEnd,
loopSentinelStart,
loopSentinelEnd,
]),
new Map()
)
const parallelEndNode = dag.nodes.get(parallelSentinelEnd)!
const edgesToLoop = Array.from(parallelEndNode.outgoingEdges.values()).filter(
(e) => e.target === loopSentinelStart
)
expect(edgesToLoop.length).toBeGreaterThan(0)
expect(edgesToLoop[0].sourceHandle).toBe('parallel_exit')
})
})
describe('Loop → Parallel', () => {
it('should wire loop sentinel end to parallel sentinel start', () => {
const loopId = 'loop-1'
const parallelId = 'parallel-1'
const taskInLoopId = 'loop-task'
const taskInParallelId = 'parallel-task'
const taskInParallelTemplateId = `${taskInParallelId}__branch-0`
const loopSentinelStart = `loop-${loopId}-sentinel-start`
const loopSentinelEnd = `loop-${loopId}-sentinel-end`
const parallelSentinelStart = `parallel-${parallelId}-sentinel-start`
const parallelSentinelEnd = `parallel-${parallelId}-sentinel-end`
const workflow = createMockWorkflow(
[
createMockBlock(loopId, 'loop'),
createMockBlock(parallelId, 'parallel'),
createMockBlock(taskInLoopId),
createMockBlock(taskInParallelId),
],
[{ source: loopId, target: parallelId }],
{
[loopId]: {
id: loopId,
nodes: [taskInLoopId],
iterations: 3,
loopType: 'for',
} as SerializedLoop,
},
{
[parallelId]: { id: parallelId, nodes: [taskInParallelId], count: 2 },
}
)
const dag = createMockDAG([
taskInLoopId,
taskInParallelTemplateId,
loopSentinelStart,
loopSentinelEnd,
parallelSentinelStart,
parallelSentinelEnd,
])
dag.loopConfigs.set(loopId, {
id: loopId,
nodes: [taskInLoopId],
iterations: 3,
loopType: 'for',
} as SerializedLoop)
dag.parallelConfigs.set(parallelId, {
id: parallelId,
nodes: [taskInParallelId],
count: 2,
})
edgeConstructor.execute(
workflow,
dag,
new Set([taskInParallelId]),
new Set([taskInLoopId]),
new Set([
taskInLoopId,
taskInParallelTemplateId,
loopSentinelStart,
loopSentinelEnd,
parallelSentinelStart,
parallelSentinelEnd,
]),
new Map()
)
const loopEndNode = dag.nodes.get(loopSentinelEnd)!
const edgesToParallel = Array.from(loopEndNode.outgoingEdges.values()).filter(
(e) => e.target === parallelSentinelStart
)
expect(edgesToParallel.length).toBeGreaterThan(0)
expect(edgesToParallel[0].sourceHandle).toBe('loop_exit')
const loopStartNode = dag.nodes.get(loopSentinelStart)!
const earlyExitEdges = Array.from(loopStartNode.outgoingEdges.values()).filter(
(e) => e.target === loopSentinelEnd && e.sourceHandle === 'loop_exit'
)
expect(earlyExitEdges.length).toBeGreaterThan(0)
})
})
describe('Subflow → Regular block', () => {
it('should wire parallel sentinel end to regular block', () => {
const parallelId = 'parallel-1'
const taskInParallelId = 'parallel-task'
const taskInParallelTemplateId = `${taskInParallelId}__branch-0`
const regularBlockId = 'regular-block'
const parallelSentinelStart = `parallel-${parallelId}-sentinel-start`
const parallelSentinelEnd = `parallel-${parallelId}-sentinel-end`
const workflow = createMockWorkflow(
[
createMockBlock(parallelId, 'parallel'),
createMockBlock(taskInParallelId),
createMockBlock(regularBlockId),
],
[{ source: parallelId, target: regularBlockId }],
{},
{
[parallelId]: { id: parallelId, nodes: [taskInParallelId], count: 2 },
}
)
const dag = createMockDAG([
taskInParallelTemplateId,
regularBlockId,
parallelSentinelStart,
parallelSentinelEnd,
])
dag.parallelConfigs.set(parallelId, {
id: parallelId,
nodes: [taskInParallelId],
count: 2,
})
edgeConstructor.execute(
workflow,
dag,
new Set([taskInParallelId]),
new Set(),
new Set([
taskInParallelTemplateId,
regularBlockId,
parallelSentinelStart,
parallelSentinelEnd,
]),
new Map()
)
const parallelEndNode = dag.nodes.get(parallelSentinelEnd)!
const edgesToRegular = Array.from(parallelEndNode.outgoingEdges.values()).filter(
(e) => e.target === regularBlockId
)
expect(edgesToRegular.length).toBe(1)
expect(edgesToRegular[0].sourceHandle).toBe('parallel_exit')
const edgesToParallelStart = Array.from(parallelEndNode.outgoingEdges.values()).filter(
(e) => e.target === parallelSentinelStart
)
expect(edgesToParallelStart.length).toBe(1)
expect(edgesToParallelStart[0].sourceHandle).toBe('parallel_continue')
const parallelStartNode = dag.nodes.get(parallelSentinelStart)!
expect(parallelStartNode.incomingEdges.has(parallelSentinelEnd)).toBe(false)
const regularBlockNode = dag.nodes.get(regularBlockId)!
expect(regularBlockNode.incomingEdges.has(parallelSentinelEnd)).toBe(true)
})
it('should wire loop sentinel end to regular block', () => {
const loopId = 'loop-1'
const taskInLoopId = 'loop-task'
const regularBlockId = 'regular-block'
const loopSentinelStart = `loop-${loopId}-sentinel-start`
const loopSentinelEnd = `loop-${loopId}-sentinel-end`
const workflow = createMockWorkflow(
[
createMockBlock(loopId, 'loop'),
createMockBlock(taskInLoopId),
createMockBlock(regularBlockId),
],
[{ source: loopId, target: regularBlockId }],
{
[loopId]: {
id: loopId,
nodes: [taskInLoopId],
iterations: 3,
loopType: 'for',
} as SerializedLoop,
}
)
const dag = createMockDAG([
taskInLoopId,
regularBlockId,
loopSentinelStart,
loopSentinelEnd,
])
dag.loopConfigs.set(loopId, {
id: loopId,
nodes: [taskInLoopId],
iterations: 3,
loopType: 'for',
} as SerializedLoop)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([taskInLoopId]),
new Set([taskInLoopId, regularBlockId, loopSentinelStart, loopSentinelEnd]),
new Map()
)
const loopEndNode = dag.nodes.get(loopSentinelEnd)!
const edgesToRegular = Array.from(loopEndNode.outgoingEdges.values()).filter(
(e) => e.target === regularBlockId
)
expect(edgesToRegular.length).toBe(1)
expect(edgesToRegular[0].sourceHandle).toBe('loop_exit')
})
})
describe('Regular block → Subflow', () => {
it('should wire regular block to parallel sentinel start', () => {
const regularBlockId = 'regular-block'
const parallelId = 'parallel-1'
const taskInParallelId = 'parallel-task'
const taskInParallelTemplateId = `${taskInParallelId}__branch-0`
const parallelSentinelStart = `parallel-${parallelId}-sentinel-start`
const parallelSentinelEnd = `parallel-${parallelId}-sentinel-end`
const workflow = createMockWorkflow(
[
createMockBlock(regularBlockId),
createMockBlock(parallelId, 'parallel'),
createMockBlock(taskInParallelId),
],
[{ source: regularBlockId, target: parallelId }],
{},
{
[parallelId]: { id: parallelId, nodes: [taskInParallelId], count: 2 },
}
)
const dag = createMockDAG([
regularBlockId,
taskInParallelTemplateId,
parallelSentinelStart,
parallelSentinelEnd,
])
dag.parallelConfigs.set(parallelId, {
id: parallelId,
nodes: [taskInParallelId],
count: 2,
})
edgeConstructor.execute(
workflow,
dag,
new Set([taskInParallelId]),
new Set(),
new Set([
regularBlockId,
taskInParallelTemplateId,
parallelSentinelStart,
parallelSentinelEnd,
]),
new Map()
)
const regularBlockNode = dag.nodes.get(regularBlockId)!
const edgesToParallel = Array.from(regularBlockNode.outgoingEdges.values()).filter(
(e) => e.target === parallelSentinelStart
)
expect(edgesToParallel.length).toBe(1)
const parallelStartNode = dag.nodes.get(parallelSentinelStart)!
expect(parallelStartNode.incomingEdges.has(regularBlockId)).toBe(true)
})
it('should wire regular block to loop sentinel start', () => {
const regularBlockId = 'regular-block'
const loopId = 'loop-1'
const taskInLoopId = 'loop-task'
const loopSentinelStart = `loop-${loopId}-sentinel-start`
const loopSentinelEnd = `loop-${loopId}-sentinel-end`
const workflow = createMockWorkflow(
[
createMockBlock(regularBlockId),
createMockBlock(loopId, 'loop'),
createMockBlock(taskInLoopId),
],
[{ source: regularBlockId, target: loopId }],
{
[loopId]: {
id: loopId,
nodes: [taskInLoopId],
iterations: 3,
loopType: 'for',
} as SerializedLoop,
}
)
const dag = createMockDAG([
regularBlockId,
taskInLoopId,
loopSentinelStart,
loopSentinelEnd,
])
dag.loopConfigs.set(loopId, {
id: loopId,
nodes: [taskInLoopId],
iterations: 3,
loopType: 'for',
} as SerializedLoop)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([taskInLoopId]),
new Set([regularBlockId, taskInLoopId, loopSentinelStart, loopSentinelEnd]),
new Map()
)
const regularBlockNode = dag.nodes.get(regularBlockId)!
const edgesToLoop = Array.from(regularBlockNode.outgoingEdges.values()).filter(
(e) => e.target === loopSentinelStart
)
expect(edgesToLoop.length).toBe(1)
const loopStartNode = dag.nodes.get(loopSentinelStart)!
expect(loopStartNode.incomingEdges.has(regularBlockId)).toBe(true)
})
})
})
describe('Nested loop wiring', () => {
it('should wire inner loop sentinels into outer loop sentinel chain', () => {
const outerLoopId = 'outer-loop'
const innerLoopId = 'inner-loop'
const functionId = 'func-1'
const innerFunctionId = 'func-2'
const outerSentinelStart = `loop-${outerLoopId}-sentinel-start`
const outerSentinelEnd = `loop-${outerLoopId}-sentinel-end`
const innerSentinelStart = `loop-${innerLoopId}-sentinel-start`
const innerSentinelEnd = `loop-${innerLoopId}-sentinel-end`
const outerLoop: SerializedLoop = {
id: outerLoopId,
nodes: [functionId, innerLoopId],
iterations: 5,
loopType: 'for',
}
const innerLoop: SerializedLoop = {
id: innerLoopId,
nodes: [innerFunctionId],
iterations: 3,
loopType: 'for',
}
const dag = createMockDAG([
functionId,
innerFunctionId,
outerSentinelStart,
outerSentinelEnd,
innerSentinelStart,
innerSentinelEnd,
])
dag.loopConfigs.set(outerLoopId, outerLoop)
dag.loopConfigs.set(innerLoopId, innerLoop)
const workflow = createMockWorkflow(
[
createMockBlock(functionId),
createMockBlock(innerFunctionId),
createMockBlock(innerLoopId, 'loop'),
],
[{ source: functionId, target: innerLoopId }],
{ [outerLoopId]: outerLoop, [innerLoopId]: innerLoop }
)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([functionId, innerLoopId, innerFunctionId]),
new Set([
functionId,
innerFunctionId,
innerLoopId,
outerSentinelStart,
outerSentinelEnd,
innerSentinelStart,
innerSentinelEnd,
]),
new Map()
)
const outerStartNode = dag.nodes.get(outerSentinelStart)!
const outerStartTargets = Array.from(outerStartNode.outgoingEdges.values()).map(
(e) => e.target
)
expect(outerStartTargets).toContain(functionId)
const funcNode = dag.nodes.get(functionId)!
const funcTargets = Array.from(funcNode.outgoingEdges.values()).map((e) => e.target)
expect(funcTargets).toContain(innerSentinelStart)
const innerEndNode = dag.nodes.get(innerSentinelEnd)!
const innerEndEdges = Array.from(innerEndNode.outgoingEdges.values())
const exitEdge = innerEndEdges.find((e) => e.target === outerSentinelEnd)
expect(exitEdge).toBeDefined()
expect(exitEdge!.sourceHandle).toBe('loop_exit')
const backEdge = innerEndEdges.find((e) => e.target === innerSentinelStart)
expect(backEdge).toBeDefined()
expect(backEdge!.sourceHandle).toBe('loop_continue')
const outerEndNode = dag.nodes.get(outerSentinelEnd)!
const outerBackEdge = Array.from(outerEndNode.outgoingEdges.values()).find(
(e) => e.target === outerSentinelStart
)
expect(outerBackEdge).toBeDefined()
expect(outerBackEdge!.sourceHandle).toBe('loop_continue')
})
it('should correctly identify boundary nodes when inner loop is the only node', () => {
const outerLoopId = 'outer-loop'
const innerLoopId = 'inner-loop'
const innerFunctionId = 'func-inner'
const outerSentinelStart = `loop-${outerLoopId}-sentinel-start`
const outerSentinelEnd = `loop-${outerLoopId}-sentinel-end`
const innerSentinelStart = `loop-${innerLoopId}-sentinel-start`
const innerSentinelEnd = `loop-${innerLoopId}-sentinel-end`
const outerLoop: SerializedLoop = {
id: outerLoopId,
nodes: [innerLoopId],
iterations: 2,
loopType: 'for',
}
const innerLoop: SerializedLoop = {
id: innerLoopId,
nodes: [innerFunctionId],
iterations: 3,
loopType: 'for',
}
const dag = createMockDAG([
innerFunctionId,
outerSentinelStart,
outerSentinelEnd,
innerSentinelStart,
innerSentinelEnd,
])
dag.loopConfigs.set(outerLoopId, outerLoop)
dag.loopConfigs.set(innerLoopId, innerLoop)
const workflow = createMockWorkflow(
[createMockBlock(innerFunctionId), createMockBlock(innerLoopId, 'loop')],
[],
{ [outerLoopId]: outerLoop, [innerLoopId]: innerLoop }
)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([innerLoopId, innerFunctionId]),
new Set([
innerFunctionId,
innerLoopId,
outerSentinelStart,
outerSentinelEnd,
innerSentinelStart,
innerSentinelEnd,
]),
new Map()
)
const outerStartNode = dag.nodes.get(outerSentinelStart)!
const outerStartTargets = Array.from(outerStartNode.outgoingEdges.values()).map(
(e) => e.target
)
expect(outerStartTargets).toContain(innerSentinelStart)
const innerEndNode = dag.nodes.get(innerSentinelEnd)!
const exitEdge = Array.from(innerEndNode.outgoingEdges.values()).find(
(e) => e.target === outerSentinelEnd
)
expect(exitEdge).toBeDefined()
expect(exitEdge!.sourceHandle).toBe('loop_exit')
})
it('should not drop intra-loop edges when target is a nested loop block', () => {
const outerLoopId = 'outer-loop'
const innerLoopId = 'inner-loop'
const functionId = 'func-1'
const innerFunctionId = 'func-2'
const outerSentinelStart = `loop-${outerLoopId}-sentinel-start`
const outerSentinelEnd = `loop-${outerLoopId}-sentinel-end`
const innerSentinelStart = `loop-${innerLoopId}-sentinel-start`
const innerSentinelEnd = `loop-${innerLoopId}-sentinel-end`
const outerLoop: SerializedLoop = {
id: outerLoopId,
nodes: [functionId, innerLoopId],
iterations: 5,
loopType: 'for',
}
const innerLoop: SerializedLoop = {
id: innerLoopId,
nodes: [innerFunctionId],
iterations: 3,
loopType: 'for',
}
const dag = createMockDAG([
functionId,
innerFunctionId,
outerSentinelStart,
outerSentinelEnd,
innerSentinelStart,
innerSentinelEnd,
])
dag.loopConfigs.set(outerLoopId, outerLoop)
dag.loopConfigs.set(innerLoopId, innerLoop)
const workflow = createMockWorkflow(
[
createMockBlock(functionId),
createMockBlock(innerFunctionId),
createMockBlock(innerLoopId, 'loop'),
],
[{ source: functionId, target: innerLoopId }],
{ [outerLoopId]: outerLoop, [innerLoopId]: innerLoop }
)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([functionId, innerLoopId, innerFunctionId]),
new Set([
functionId,
innerFunctionId,
innerLoopId,
outerSentinelStart,
outerSentinelEnd,
innerSentinelStart,
innerSentinelEnd,
]),
new Map()
)
const funcNode = dag.nodes.get(functionId)!
const edgeToInnerStart = Array.from(funcNode.outgoingEdges.values()).find(
(e) => e.target === innerSentinelStart
)
expect(edgeToInnerStart).toBeDefined()
const innerStartNode = dag.nodes.get(innerSentinelStart)!
expect(innerStartNode.incomingEdges.has(functionId)).toBe(true)
})
})
describe('Nested parallel wiring', () => {
it('should wire inner parallel sentinels into outer parallel sentinel chain', () => {
const outerParallelId = 'outer-parallel'
const innerParallelId = 'inner-parallel'
const functionId = 'func-1'
const outerSentinelStart = `parallel-${outerParallelId}-sentinel-start`
const outerSentinelEnd = `parallel-${outerParallelId}-sentinel-end`
const innerSentinelStart = `parallel-${innerParallelId}-sentinel-start`
const innerSentinelEnd = `parallel-${innerParallelId}-sentinel-end`
const funcTemplate = `${functionId}₍0₎`
const dag = createMockDAG([
outerSentinelStart,
outerSentinelEnd,
innerSentinelStart,
innerSentinelEnd,
funcTemplate,
])
// Set up sentinel metadata
dag.nodes.get(outerSentinelStart)!.metadata = {
isSentinel: true,
sentinelType: 'start',
subflowId: outerParallelId,
subflowType: 'parallel',
}
dag.nodes.get(outerSentinelEnd)!.metadata = {
isSentinel: true,
sentinelType: 'end',
subflowId: outerParallelId,
subflowType: 'parallel',
}
dag.nodes.get(innerSentinelStart)!.metadata = {
isSentinel: true,
sentinelType: 'start',
subflowId: innerParallelId,
subflowType: 'parallel',
}
dag.nodes.get(innerSentinelEnd)!.metadata = {
isSentinel: true,
sentinelType: 'end',
subflowId: innerParallelId,
subflowType: 'parallel',
}
dag.nodes.get(funcTemplate)!.metadata = {
isParallelBranch: true,
subflowId: innerParallelId,
subflowType: 'parallel',
branchIndex: 0,
branchTotal: 1,
originalBlockId: functionId,
}
dag.parallelConfigs.set(outerParallelId, {
id: outerParallelId,
nodes: [innerParallelId],
count: 3,
parallelType: 'count',
})
dag.parallelConfigs.set(innerParallelId, {
id: innerParallelId,
nodes: [functionId],
count: 2,
parallelType: 'count',
})
const workflow = createMockWorkflow(
[createMockBlock(functionId)],
[
// Outer parallel start → inner parallel (intra-parallel, skipped by wireRegularEdges)
{
source: outerParallelId,
target: innerParallelId,
sourceHandle: 'parallel-start-source',
},
// Inner parallel start → function (intra-parallel, skipped by wireRegularEdges)
{
source: innerParallelId,
target: functionId,
sourceHandle: 'parallel-start-source',
},
]
)
const edgeConstructor = new EdgeConstructor()
edgeConstructor.execute(
workflow,
dag,
new Set([innerParallelId, functionId]),
new Set(),
new Set([outerParallelId, innerParallelId, functionId]),
new Map()
)
// Outer sentinel-start → inner sentinel-start
const outerStartNode = dag.nodes.get(outerSentinelStart)!
const edgeToInnerStart = Array.from(outerStartNode.outgoingEdges.values()).find(
(e) => e.target === innerSentinelStart
)
expect(edgeToInnerStart).toBeDefined()
// Inner sentinel-end → outer sentinel-end
const innerEndNode = dag.nodes.get(innerSentinelEnd)!
const edgeToOuterEnd = Array.from(innerEndNode.outgoingEdges.values()).find(
(e) => e.target === outerSentinelEnd
)
expect(edgeToOuterEnd).toBeDefined()
// Inner sentinel-start → func template
const innerStartNode = dag.nodes.get(innerSentinelStart)!
const edgeToFunc = Array.from(innerStartNode.outgoingEdges.values()).find(
(e) => e.target === funcTemplate
)
expect(edgeToFunc).toBeDefined()
// Func template → inner sentinel-end
const funcNode = dag.nodes.get(funcTemplate)!
const edgeToInnerEnd = Array.from(funcNode.outgoingEdges.values()).find(
(e) => e.target === innerSentinelEnd
)
expect(edgeToInnerEnd).toBeDefined()
})
it('should wire parallel-in-loop sentinels correctly', () => {
const loopId = 'outer-loop'
const innerParallelId = 'inner-parallel'
const functionId = 'func-1'
const loopSentinelStart = `loop-${loopId}-sentinel-start`
const loopSentinelEnd = `loop-${loopId}-sentinel-end`
const parallelSentinelStart = `parallel-${innerParallelId}-sentinel-start`
const parallelSentinelEnd = `parallel-${innerParallelId}-sentinel-end`
const funcTemplate = `${functionId}₍0₎`
const dag = createMockDAG([
loopSentinelStart,
loopSentinelEnd,
parallelSentinelStart,
parallelSentinelEnd,
funcTemplate,
])
dag.nodes.get(loopSentinelStart)!.metadata = {
isSentinel: true,
sentinelType: 'start',
subflowId: loopId,
subflowType: 'loop',
}
dag.nodes.get(loopSentinelEnd)!.metadata = {
isSentinel: true,
sentinelType: 'end',
subflowId: loopId,
subflowType: 'loop',
}
dag.nodes.get(parallelSentinelStart)!.metadata = {
isSentinel: true,
sentinelType: 'start',
subflowId: innerParallelId,
subflowType: 'parallel',
}
dag.nodes.get(parallelSentinelEnd)!.metadata = {
isSentinel: true,
sentinelType: 'end',
subflowId: innerParallelId,
subflowType: 'parallel',
}
dag.nodes.get(funcTemplate)!.metadata = {
isParallelBranch: true,
subflowId: innerParallelId,
subflowType: 'parallel',
branchIndex: 0,
branchTotal: 1,
originalBlockId: functionId,
}
const outerLoop: SerializedLoop = {
id: loopId,
nodes: [innerParallelId],
iterations: 5,
loopType: 'for',
}
dag.loopConfigs.set(loopId, outerLoop)
dag.parallelConfigs.set(innerParallelId, {
id: innerParallelId,
nodes: [functionId],
count: 2,
parallelType: 'count',
})
const workflow = createMockWorkflow(
[createMockBlock(functionId)],
[
{
source: loopId,
target: innerParallelId,
sourceHandle: 'loop-start-source',
},
{
source: innerParallelId,
target: functionId,
sourceHandle: 'parallel-start-source',
},
]
)
const edgeConstructor = new EdgeConstructor()
edgeConstructor.execute(
workflow,
dag,
new Set([functionId]),
new Set([innerParallelId]),
new Set([loopId, innerParallelId, functionId]),
new Map()
)
// Loop sentinel-start → parallel sentinel-start
const loopStartNode = dag.nodes.get(loopSentinelStart)!
const edgeToParallelStart = Array.from(loopStartNode.outgoingEdges.values()).find(
(e) => e.target === parallelSentinelStart
)
expect(edgeToParallelStart).toBeDefined()
// Parallel sentinel-end → loop sentinel-end
const parallelEndNode = dag.nodes.get(parallelSentinelEnd)!
const edgeToLoopEnd = Array.from(parallelEndNode.outgoingEdges.values()).find(
(e) => e.target === loopSentinelEnd
)
expect(edgeToLoopEnd).toBeDefined()
// Inner parallel wiring: sentinel-start → func, func → sentinel-end
const parallelStartNode = dag.nodes.get(parallelSentinelStart)!
expect(
Array.from(parallelStartNode.outgoingEdges.values()).some((e) => e.target === funcTemplate)
).toBe(true)
const funcNode = dag.nodes.get(funcTemplate)!
expect(
Array.from(funcNode.outgoingEdges.values()).some((e) => e.target === parallelSentinelEnd)
).toBe(true)
})
it('should wire loop-in-parallel with correct exit handles', () => {
const outerParallelId = 'outer-parallel'
const innerLoopId = 'inner-loop'
const functionId = 'func-1'
const outerSentinelStart = `parallel-${outerParallelId}-sentinel-start`
const outerSentinelEnd = `parallel-${outerParallelId}-sentinel-end`
const innerSentinelStart = `loop-${innerLoopId}-sentinel-start`
const innerSentinelEnd = `loop-${innerLoopId}-sentinel-end`
const dag = createMockDAG([
outerSentinelStart,
outerSentinelEnd,
innerSentinelStart,
innerSentinelEnd,
functionId,
])
dag.nodes.get(outerSentinelStart)!.metadata = {
isSentinel: true,
sentinelType: 'start',
subflowId: outerParallelId,
subflowType: 'parallel',
}
dag.nodes.get(outerSentinelEnd)!.metadata = {
isSentinel: true,
sentinelType: 'end',
subflowId: outerParallelId,
subflowType: 'parallel',
}
dag.nodes.get(innerSentinelStart)!.metadata = {
isSentinel: true,
sentinelType: 'start',
subflowId: innerLoopId,
subflowType: 'loop',
}
dag.nodes.get(innerSentinelEnd)!.metadata = {
isSentinel: true,
sentinelType: 'end',
subflowId: innerLoopId,
subflowType: 'loop',
}
const innerLoop: SerializedLoop = {
id: innerLoopId,
nodes: [functionId],
iterations: 3,
loopType: 'for',
}
dag.loopConfigs.set(innerLoopId, innerLoop)
dag.parallelConfigs.set(outerParallelId, {
id: outerParallelId,
nodes: [innerLoopId],
count: 2,
parallelType: 'count',
})
const workflow = createMockWorkflow(
[createMockBlock(functionId), createMockBlock(innerLoopId, 'loop')],
[
{
source: outerParallelId,
target: innerLoopId,
sourceHandle: 'parallel-start-source',
},
{
source: innerLoopId,
target: functionId,
sourceHandle: 'loop-start-source',
},
],
{ [innerLoopId]: innerLoop }
)
const edgeConstructor = new EdgeConstructor()
edgeConstructor.execute(
workflow,
dag,
new Set([innerLoopId]),
new Set([functionId]),
new Set([outerParallelId, innerLoopId, functionId]),
new Map()
)
// Outer sentinel-start → inner loop sentinel-start
const outerStartNode = dag.nodes.get(outerSentinelStart)!
const edgeToInnerStart = Array.from(outerStartNode.outgoingEdges.values()).find(
(e) => e.target === innerSentinelStart
)
expect(edgeToInnerStart).toBeDefined()
// Inner loop sentinel-end → outer parallel sentinel-end with loop_exit handle
const innerEndNode = dag.nodes.get(innerSentinelEnd)!
const edgeToOuterEnd = Array.from(innerEndNode.outgoingEdges.values()).find(
(e) => e.target === outerSentinelEnd
)
expect(edgeToOuterEnd).toBeDefined()
expect(edgeToOuterEnd!.sourceHandle).toBe('loop_exit')
// Inner loop back-edge: sentinel-end → sentinel-start with loop_continue handle
const backEdge = Array.from(innerEndNode.outgoingEdges.values()).find(
(e) => e.target === innerSentinelStart
)
expect(backEdge).toBeDefined()
expect(backEdge!.sourceHandle).toBe('loop_continue')
// Inner loop wiring: sentinel-start → function
const innerStartNode = dag.nodes.get(innerSentinelStart)!
expect(
Array.from(innerStartNode.outgoingEdges.values()).some((e) => e.target === functionId)
).toBe(true)
// Function → inner loop sentinel-end
const funcNode = dag.nodes.get(functionId)!
expect(
Array.from(funcNode.outgoingEdges.values()).some((e) => e.target === innerSentinelEnd)
).toBe(true)
})
})
})