d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
1837 lines
59 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|