Files
simstudioai--sim/apps/sim/executor/utils/parallel-expansion.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

739 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/constants'
import { DAGBuilder } from '@/executor/dag/builder'
import { EdgeManager } from '@/executor/execution/edge-manager'
import { ParallelExpander } from '@/executor/utils/parallel-expansion'
import {
buildBranchNodeId,
buildParallelSentinelEndId,
buildParallelSentinelStartId,
buildSentinelStartId,
stripCloneSuffixes,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
function createBlock(id: string, metadataId: string): SerializedBlock {
return {
id,
position: { x: 0, y: 0 },
config: { tool: 'noop', params: {} },
inputs: {},
outputs: {},
metadata: { id: metadataId, name: id },
enabled: true,
}
}
describe('Nested parallel expansion + edge resolution', () => {
it('waits for every branch terminal before queuing the parallel end sentinel', () => {
const parallelId = 'parallel-1'
const taskId = 'task-1'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(parallelId, BlockType.PARALLEL),
createBlock(taskId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: parallelId },
{
source: parallelId,
target: taskId,
sourceHandle: 'parallel-start-source',
},
],
loops: {},
parallels: {
[parallelId]: {
id: parallelId,
nodes: [taskId],
count: 2,
parallelType: 'count',
},
},
}
const dag = new DAGBuilder().build(workflow)
const expander = new ParallelExpander()
const { terminalNodes } = expander.expandParallel(dag, parallelId, 2)
const edgeManager = new EdgeManager(dag)
const parallelEndId = buildParallelSentinelEndId(parallelId)
expect(terminalNodes).toEqual([buildBranchNodeId(taskId, 0), buildBranchNodeId(taskId, 1)])
expect(dag.nodes.get(parallelEndId)?.incomingEdges).toEqual(new Set(terminalNodes))
const firstBranchReady = edgeManager.processOutgoingEdges(dag.nodes.get(terminalNodes[0])!, {
value: 'first',
})
expect(firstBranchReady).not.toContain(parallelEndId)
const secondBranchReady = edgeManager.processOutgoingEdges(dag.nodes.get(terminalNodes[1])!, {
value: 'second',
})
expect(secondBranchReady).toContain(parallelEndId)
})
it('outer parallel expansion clones inner subflow per branch and edge manager resolves correctly', () => {
const outerParallelId = 'outer-parallel'
const innerParallelId = 'inner-parallel'
const functionId = 'func-1'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(outerParallelId, BlockType.PARALLEL),
createBlock(innerParallelId, BlockType.PARALLEL),
createBlock(functionId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: outerParallelId },
{
source: outerParallelId,
target: innerParallelId,
sourceHandle: 'parallel-start-source',
},
{
source: innerParallelId,
target: functionId,
sourceHandle: 'parallel-start-source',
},
],
loops: {},
parallels: {
[innerParallelId]: {
id: innerParallelId,
nodes: [functionId],
count: 3,
parallelType: 'count',
},
[outerParallelId]: {
id: outerParallelId,
nodes: [innerParallelId],
count: 2,
parallelType: 'count',
},
},
}
// Step 1: Build the DAG
const builder = new DAGBuilder()
const dag = builder.build(workflow)
const outerStartId = buildParallelSentinelStartId(outerParallelId)
const outerEndId = buildParallelSentinelEndId(outerParallelId)
const innerStartId = buildParallelSentinelStartId(innerParallelId)
const innerEndId = buildParallelSentinelEndId(innerParallelId)
// Verify DAG construction: start → outer-sentinel-start
const startNode = dag.nodes.get('start')!
const startTargets = Array.from(startNode.outgoingEdges.values()).map((e) => e.target)
expect(startTargets).toContain(outerStartId)
// Step 2: Simulate runtime expansion of outer parallel (count=2)
const expander = new ParallelExpander()
const outerResult = expander.expandParallel(dag, outerParallelId, 2)
// After expansion, outer-sentinel-start should point to 2 entry nodes:
// branch 0 uses original inner-sentinel-start, branch 1 uses cloned sentinel
const outerStart = dag.nodes.get(outerStartId)!
const outerStartTargets = Array.from(outerStart.outgoingEdges.values()).map((e) => e.target)
expect(outerStartTargets).toHaveLength(2)
expect(outerStartTargets).toContain(innerStartId) // branch 0
// Verify cloned subflow info
expect(outerResult.clonedSubflows).toHaveLength(1)
expect(outerResult.clonedSubflows[0].originalId).toBe(innerParallelId)
expect(outerResult.clonedSubflows[0].outerBranchIndex).toBe(1)
const clonedInnerParallelId = outerResult.clonedSubflows[0].clonedId
const clonedInnerStartId = buildParallelSentinelStartId(clonedInnerParallelId)
const clonedInnerEndId = buildParallelSentinelEndId(clonedInnerParallelId)
expect(outerStartTargets).toContain(clonedInnerStartId) // branch 1
expect(dag.nodes.get(clonedInnerStartId)?.metadata).toMatchObject({
subflowId: clonedInnerParallelId,
subflowType: 'parallel',
})
// Verify cloned parallel config was registered
expect(dag.parallelConfigs.has(clonedInnerParallelId)).toBe(true)
const clonedConfig = dag.parallelConfigs.get(clonedInnerParallelId)!
expect(clonedConfig.count).toBe(3)
expect(clonedConfig.nodes).toHaveLength(1)
// inner-sentinel-end → outer-sentinel-end (branch 0)
const innerEnd = dag.nodes.get(innerEndId)!
const innerEndTargets = Array.from(innerEnd.outgoingEdges.values()).map((e) => e.target)
expect(innerEndTargets).toContain(outerEndId)
// cloned inner sentinel-end → outer-sentinel-end (branch 1)
const clonedInnerEnd = dag.nodes.get(clonedInnerEndId)!
const clonedInnerEndTargets = Array.from(clonedInnerEnd.outgoingEdges.values()).map(
(e) => e.target
)
expect(clonedInnerEndTargets).toContain(outerEndId)
// Entry/terminal nodes from expansion
expect(outerResult.entryNodes).toContain(innerStartId)
expect(outerResult.entryNodes).toContain(clonedInnerStartId)
expect(outerResult.terminalNodes).toContain(innerEndId)
expect(outerResult.terminalNodes).toContain(clonedInnerEndId)
// Step 3: Verify edge manager resolves ready nodes after outer-sentinel-start completes
const edgeManager = new EdgeManager(dag)
const readyAfterOuterStart = edgeManager.processOutgoingEdges(
outerStart,
{ sentinelStart: true },
false
)
expect(readyAfterOuterStart).toContain(innerStartId)
expect(readyAfterOuterStart).toContain(clonedInnerStartId)
// Step 4: Expand inner parallel (branch 0's inner) with count=3
expander.expandParallel(dag, innerParallelId, 3)
// Inner sentinel-start should now point to 3 branch nodes
const innerStart = dag.nodes.get(innerStartId)!
const innerStartTargets = Array.from(innerStart.outgoingEdges.values()).map((e) => e.target)
expect(innerStartTargets).toHaveLength(3)
const branch0 = buildBranchNodeId(functionId, 0)
const branch1 = buildBranchNodeId(functionId, 1)
const branch2 = buildBranchNodeId(functionId, 2)
expect(innerStartTargets).toContain(branch0)
expect(innerStartTargets).toContain(branch1)
expect(innerStartTargets).toContain(branch2)
// Step 5: Verify edge manager resolves branch nodes after inner-sentinel-start
const readyAfterInnerStart = edgeManager.processOutgoingEdges(
innerStart,
{ sentinelStart: true },
false
)
expect(readyAfterInnerStart).toContain(branch0)
expect(readyAfterInnerStart).toContain(branch1)
expect(readyAfterInnerStart).toContain(branch2)
// Step 6: Simulate branch completions → inner-sentinel-end becomes ready
const branch0Node = dag.nodes.get(branch0)!
const branch1Node = dag.nodes.get(branch1)!
const branch2Node = dag.nodes.get(branch2)!
edgeManager.processOutgoingEdges(branch0Node, {}, false)
edgeManager.processOutgoingEdges(branch1Node, {}, false)
const readyAfterBranch2 = edgeManager.processOutgoingEdges(branch2Node, {}, false)
expect(readyAfterBranch2).toContain(innerEndId)
// Step 7: inner-sentinel-end completes → outer-sentinel-end becomes ready
// (only if both branches are done — cloned branch must also complete)
const readyAfterInnerEnd = edgeManager.processOutgoingEdges(
innerEnd,
{ sentinelEnd: true, selectedRoute: 'parallel_exit' },
false
)
// outer-sentinel-end has 2 incoming (innerEnd + clonedInnerEnd), not ready yet
expect(readyAfterInnerEnd).not.toContain(outerEndId)
// Expand and complete cloned inner parallel (branch 1's inner)
const clonedBlockId = clonedConfig.nodes![0]
expander.expandParallel(dag, clonedInnerParallelId, 3)
const clonedInnerStart = dag.nodes.get(clonedInnerStartId)!
const clonedBranch0 = buildBranchNodeId(clonedBlockId, 0)
const clonedBranch1 = buildBranchNodeId(clonedBlockId, 1)
const clonedBranch2 = buildBranchNodeId(clonedBlockId, 2)
edgeManager.processOutgoingEdges(clonedInnerStart, { sentinelStart: true }, false)
edgeManager.processOutgoingEdges(dag.nodes.get(clonedBranch0)!, {}, false)
edgeManager.processOutgoingEdges(dag.nodes.get(clonedBranch1)!, {}, false)
edgeManager.processOutgoingEdges(dag.nodes.get(clonedBranch2)!, {}, false)
const readyAfterClonedInnerEnd = edgeManager.processOutgoingEdges(
clonedInnerEnd,
{ sentinelEnd: true, selectedRoute: 'parallel_exit' },
false
)
// Now both branches done → outer-sentinel-end becomes ready
expect(readyAfterClonedInnerEnd).toContain(outerEndId)
})
it('preserves regular-to-nested subflow dependencies across expanded branches', () => {
const outerParallelId = 'outer-parallel'
const innerParallelId = 'inner-parallel'
const prepareId = 'prepare'
const finishId = 'finish'
const innerTaskId = 'inner-task'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(outerParallelId, BlockType.PARALLEL),
createBlock(prepareId, BlockType.FUNCTION),
createBlock(innerParallelId, BlockType.PARALLEL),
createBlock(innerTaskId, BlockType.FUNCTION),
createBlock(finishId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: outerParallelId },
{ source: outerParallelId, target: prepareId, sourceHandle: 'parallel-start-source' },
{ source: prepareId, target: innerParallelId },
{ source: innerParallelId, target: finishId },
{ source: innerParallelId, target: innerTaskId, sourceHandle: 'parallel-start-source' },
],
loops: {},
parallels: {
[innerParallelId]: {
id: innerParallelId,
nodes: [innerTaskId],
count: 1,
parallelType: 'count',
},
[outerParallelId]: {
id: outerParallelId,
nodes: [prepareId, innerParallelId, finishId],
count: 2,
parallelType: 'count',
},
},
}
const dag = new DAGBuilder().build(workflow)
const expander = new ParallelExpander()
const result = expander.expandParallel(dag, outerParallelId, 2)
const clonedInnerId = result.clonedSubflows[0].clonedId
const prepareBranchOne = dag.nodes.get(buildBranchNodeId(prepareId, 1))!
const clonedInnerStart = buildParallelSentinelStartId(clonedInnerId)
const clonedInnerEnd = buildParallelSentinelEndId(clonedInnerId)
const finishBranchOne = buildBranchNodeId(finishId, 1)
expect(
Array.from(prepareBranchOne.outgoingEdges.values()).map((edge) => edge.target)
).toContain(clonedInnerStart)
expect(
Array.from(dag.nodes.get(clonedInnerEnd)!.outgoingEdges.values()).map((edge) => edge.target)
).toContain(finishBranchOne)
expect(
Array.from(dag.nodes.get(clonedInnerEnd)!.outgoingEdges.values()).map((edge) => edge.target)
).not.toContain(buildBranchNodeId(finishId, 0))
const outerStartTargets = Array.from(
dag.nodes.get(buildParallelSentinelStartId(outerParallelId))!.outgoingEdges.values()
).map((edge) => edge.target)
expect(outerStartTargets).toEqual([buildBranchNodeId(prepareId, 0), prepareBranchOne.id])
const outerEndIncoming = dag.nodes.get(
buildParallelSentinelEndId(outerParallelId)
)!.incomingEdges
expect(outerEndIncoming.has(buildBranchNodeId(finishId, 0))).toBe(true)
expect(outerEndIncoming.has(finishBranchOne)).toBe(true)
})
it('uses global branch indexes for nested subflow clones in later batches', () => {
const outerParallelId = 'outer-parallel'
const innerParallelId = 'inner-parallel'
const functionId = 'func-1'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(outerParallelId, BlockType.PARALLEL),
createBlock(innerParallelId, BlockType.PARALLEL),
createBlock(functionId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: outerParallelId },
{
source: outerParallelId,
target: innerParallelId,
sourceHandle: 'parallel-start-source',
},
{
source: innerParallelId,
target: functionId,
sourceHandle: 'parallel-start-source',
},
],
loops: {},
parallels: {
[innerParallelId]: {
id: innerParallelId,
nodes: [functionId],
count: 1,
parallelType: 'count',
},
[outerParallelId]: {
id: outerParallelId,
nodes: [innerParallelId],
count: 4,
parallelType: 'count',
},
},
}
const builder = new DAGBuilder()
const dag = builder.build(workflow)
const expander = new ParallelExpander()
const result = expander.expandParallel(dag, outerParallelId, 2, undefined, {
branchIndexOffset: 2,
totalBranches: 4,
})
expect(result.entryNodes).not.toContain(buildParallelSentinelStartId(innerParallelId))
expect(result.clonedSubflows.map((clone) => clone.outerBranchIndex)).toEqual([2, 3])
expect(result.clonedSubflows.map((clone) => clone.clonedId)).toEqual([
`${innerParallelId}__obranch-2`,
`${innerParallelId}__obranch-3`,
])
})
it('clears stale regular-to-nested edges when local branch nodes are reused in later batches', () => {
const outerParallelId = 'outer-parallel'
const innerParallelId = 'inner-parallel'
const prepareId = 'prepare'
const innerTaskId = 'inner-task'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(outerParallelId, BlockType.PARALLEL),
createBlock(prepareId, BlockType.FUNCTION),
createBlock(innerParallelId, BlockType.PARALLEL),
createBlock(innerTaskId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: outerParallelId },
{ source: outerParallelId, target: prepareId, sourceHandle: 'parallel-start-source' },
{ source: prepareId, target: innerParallelId },
{ source: innerParallelId, target: innerTaskId, sourceHandle: 'parallel-start-source' },
],
loops: {},
parallels: {
[innerParallelId]: {
id: innerParallelId,
nodes: [innerTaskId],
count: 1,
parallelType: 'count',
},
[outerParallelId]: {
id: outerParallelId,
nodes: [prepareId, innerParallelId],
count: 5,
parallelType: 'count',
},
},
}
const dag = new DAGBuilder().build(workflow)
const expander = new ParallelExpander()
expander.expandParallel(dag, outerParallelId, 2, undefined, {
branchIndexOffset: 0,
totalBranches: 5,
})
expander.expandParallel(dag, outerParallelId, 2, undefined, {
branchIndexOffset: 2,
totalBranches: 5,
})
expander.expandParallel(dag, outerParallelId, 1, undefined, {
branchIndexOffset: 4,
totalBranches: 5,
})
const prepareBranchZero = dag.nodes.get(buildBranchNodeId(prepareId, 0))!
const originalInnerStart = buildParallelSentinelStartId(innerParallelId)
const staleClonedInnerStart = buildParallelSentinelStartId(`${innerParallelId}__obranch-2`)
const clonedInnerStart = buildParallelSentinelStartId(`${innerParallelId}__obranch-4`)
const prepareTargets = Array.from(prepareBranchZero.outgoingEdges.values()).map(
(edge) => edge.target
)
expect(prepareTargets).toEqual([clonedInnerStart])
expect(dag.nodes.get(originalInnerStart)!.incomingEdges.has(prepareBranchZero.id)).toBe(false)
expect(dag.nodes.get(staleClonedInnerStart)!.incomingEdges.has(prepareBranchZero.id)).toBe(
false
)
expect(dag.nodes.get(clonedInnerStart)!.incomingEdges.has(prepareBranchZero.id)).toBe(true)
})
it('preserves internal edge topology when expanding cloned nested parallels', () => {
const outerParallelId = 'outer-parallel'
const innerParallelId = 'inner-parallel'
const firstTaskId = 'first-task'
const secondTaskId = 'second-task'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(outerParallelId, BlockType.PARALLEL),
createBlock(innerParallelId, BlockType.PARALLEL),
createBlock(firstTaskId, BlockType.FUNCTION),
createBlock(secondTaskId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: outerParallelId },
{ source: outerParallelId, target: innerParallelId, sourceHandle: 'parallel-start-source' },
{ source: innerParallelId, target: firstTaskId, sourceHandle: 'parallel-start-source' },
{ source: firstTaskId, target: secondTaskId },
],
loops: {},
parallels: {
[innerParallelId]: {
id: innerParallelId,
nodes: [firstTaskId, secondTaskId],
count: 2,
parallelType: 'count',
},
[outerParallelId]: {
id: outerParallelId,
nodes: [innerParallelId],
count: 3,
parallelType: 'count',
},
},
}
const dag = new DAGBuilder().build(workflow)
const expander = new ParallelExpander()
const outerResult = expander.expandParallel(dag, outerParallelId, 1, undefined, {
branchIndexOffset: 2,
totalBranches: 3,
})
const clonedInnerId = outerResult.clonedSubflows[0].clonedId
const clonedInnerConfig = dag.parallelConfigs.get(clonedInnerId)!
const [clonedFirstTaskId, clonedSecondTaskId] = clonedInnerConfig.nodes
const innerResult = expander.expandParallel(dag, clonedInnerId, 2)
const firstTaskBranchOneId = buildBranchNodeId(clonedFirstTaskId, 1)
const secondTaskBranchOneId = buildBranchNodeId(clonedSecondTaskId, 1)
const firstTaskBranchOne = dag.nodes.get(firstTaskBranchOneId)!
const secondTaskBranchOne = dag.nodes.get(secondTaskBranchOneId)!
expect(innerResult.entryNodes).toEqual([
buildBranchNodeId(clonedFirstTaskId, 0),
firstTaskBranchOneId,
])
expect(
Array.from(firstTaskBranchOne.outgoingEdges.values()).map((edge) => edge.target)
).toContain(secondTaskBranchOneId)
expect(secondTaskBranchOne.incomingEdges.has(firstTaskBranchOneId)).toBe(true)
expect(innerResult.entryNodes).not.toContain(secondTaskBranchOneId)
})
it('clears stale sentinel-end incoming edges when a later batch is smaller', () => {
const parallelId = 'parallel-1'
const functionId = 'func-1'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(parallelId, BlockType.PARALLEL),
createBlock(functionId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: parallelId },
{ source: parallelId, target: functionId, sourceHandle: 'parallel-start-source' },
],
loops: {},
parallels: {
[parallelId]: {
id: parallelId,
nodes: [functionId],
count: 4,
parallelType: 'count',
},
},
}
const builder = new DAGBuilder()
const dag = builder.build(workflow)
const expander = new ParallelExpander()
const sentinelEnd = dag.nodes.get(buildParallelSentinelEndId(parallelId))!
expander.expandParallel(dag, parallelId, 3, undefined, {
branchIndexOffset: 0,
totalBranches: 4,
})
expect(sentinelEnd.incomingEdges).toEqual(
new Set([
buildBranchNodeId(functionId, 0),
buildBranchNodeId(functionId, 1),
buildBranchNodeId(functionId, 2),
])
)
expander.expandParallel(dag, parallelId, 1, undefined, {
branchIndexOffset: 3,
totalBranches: 4,
})
expect(sentinelEnd.incomingEdges).toEqual(new Set([buildBranchNodeId(functionId, 0)]))
})
it('updates unified subflow metadata on cloned nested loop sentinels', () => {
const parallelId = 'parallel-1'
const loopId = 'loop-1'
const functionId = 'func-1'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(parallelId, BlockType.PARALLEL),
createBlock(loopId, BlockType.LOOP),
createBlock(functionId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: parallelId },
{ source: parallelId, target: loopId, sourceHandle: 'parallel-start-source' },
{ source: loopId, target: functionId, sourceHandle: 'loop-start-source' },
],
loops: {
[loopId]: {
id: loopId,
nodes: [functionId],
iterations: 1,
loopType: 'for',
},
},
parallels: {
[parallelId]: {
id: parallelId,
nodes: [loopId],
count: 2,
parallelType: 'count',
},
},
}
const builder = new DAGBuilder()
const dag = builder.build(workflow)
const expander = new ParallelExpander()
const result = expander.expandParallel(dag, parallelId, 2)
const clonedLoopId = result.clonedSubflows.find(
(clone) => clone.originalId === loopId
)?.clonedId
expect(clonedLoopId).toBe(`${loopId}__obranch-1`)
expect(dag.nodes.get(buildSentinelStartId(clonedLoopId!))?.metadata).toMatchObject({
subflowId: clonedLoopId,
subflowType: 'loop',
})
})
it('3-level nesting: pre-expansion clone IDs do not collide with runtime expansion', () => {
const p1 = 'p1'
const p2 = 'p2'
const p3 = 'p3'
const leafBlock = 'leaf'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(p1, BlockType.PARALLEL),
createBlock(p2, BlockType.PARALLEL),
createBlock(p3, BlockType.PARALLEL),
createBlock(leafBlock, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: p1 },
{ source: p1, target: p2, sourceHandle: 'parallel-start-source' },
{ source: p2, target: p3, sourceHandle: 'parallel-start-source' },
{ source: p3, target: leafBlock, sourceHandle: 'parallel-start-source' },
],
loops: {},
parallels: {
[p3]: { id: p3, nodes: [leafBlock], count: 2, parallelType: 'count' },
[p2]: { id: p2, nodes: [p3], count: 2, parallelType: 'count' },
[p1]: { id: p1, nodes: [p2], count: 2, parallelType: 'count' },
},
}
const builder = new DAGBuilder()
const dag = builder.build(workflow)
const expander = new ParallelExpander()
// Step 1: Expand P1 (outermost) — this pre-clones P2 and recursively P3
const p1Result = expander.expandParallel(dag, p1, 2)
// P1 should have cloned P2 (and recursively P3 inside it)
const p2Clone = p1Result.clonedSubflows.find((c) => c.originalId === p2)!
expect(p2Clone).toBeDefined()
expect(p2Clone.clonedId).toBe('p2__obranch-1')
// P3 should also be cloned (inside P2__obranch-1) with a __clone prefix
const p3Clone = p1Result.clonedSubflows.find((c) => c.originalId === p3)!
expect(p3Clone).toBeDefined()
expect(p3Clone.clonedId).toMatch(/^p3__clone[0-9a-f]{24}__obranch-1$/)
expect(stripCloneSuffixes(p3Clone.clonedId)).toBe('p3')
expect(
Array.from(
dag.nodes.get(buildParallelSentinelEndId(p3Clone.clonedId))!.outgoingEdges.values()
).map((edge) => edge.target)
).toContain(buildParallelSentinelEndId(p2Clone.clonedId))
// Step 2: Expand P2 (original, branch 0 of P1) — this creates P3__obranch-1 at runtime
const p2Result = expander.expandParallel(dag, p2, 2)
// P2 should clone P3 as P3__obranch-1 (standard runtime naming)
const p3RuntimeClone = p2Result.clonedSubflows.find((c) => c.originalId === p3)!
expect(p3RuntimeClone).toBeDefined()
expect(p3RuntimeClone.clonedId).toBe('p3__obranch-1')
// Key assertion: P3__obranch-1 (runtime) !== P3__clone*__obranch-1 (pre-expansion)
expect(p3RuntimeClone.clonedId).not.toBe(p3Clone.clonedId)
// Both P3 configs should exist independently in the DAG
expect(dag.parallelConfigs.has(p3RuntimeClone.clonedId)).toBe(true)
expect(dag.parallelConfigs.has(p3Clone.clonedId)).toBe(true)
// Step 3: Expand P2__obranch-1 (cloned, branch 1 of P1)
// Its inner P3 is the pre-cloned variant P3__clone*__obranch-1
const p2ClonedConfig = dag.parallelConfigs.get(p2Clone.clonedId)!
const p3InsideP2Clone = p2ClonedConfig.nodes![0]
expect(p3InsideP2Clone).toBe(p3Clone.clonedId)
const p2CloneResult = expander.expandParallel(dag, p2Clone.clonedId, 2)
// P2__obranch-1 should clone its P3 (the pre-cloned variant) with __obranch-1 suffix
const p3DeepClone = p2CloneResult.clonedSubflows.find((c) => c.originalId === p3Clone.clonedId)!
expect(p3DeepClone).toBeDefined()
// This ID should be unique (no collision with any earlier P3 clone)
expect(dag.parallelConfigs.has(p3DeepClone.clonedId)).toBe(true)
// Step 4: Expand all P3 variants and verify no node collisions
const allP3Variants = [p3, p3RuntimeClone.clonedId, p3Clone.clonedId, p3DeepClone.clonedId]
const allLeafNodes = new Set<string>()
for (const p3Id of allP3Variants) {
const p3Config = dag.parallelConfigs.get(p3Id)!
const leafId = p3Config.nodes![0]
const p3Result = expander.expandParallel(dag, p3Id, 2)
// Each expansion creates branch nodes — verify they're unique
const branch0 = buildBranchNodeId(leafId, 0)
const branch1 = buildBranchNodeId(leafId, 1)
expect(dag.nodes.has(branch0)).toBe(true)
expect(dag.nodes.has(branch1)).toBe(true)
// No duplicate node IDs across all expansions
expect(allLeafNodes.has(branch0)).toBe(false)
expect(allLeafNodes.has(branch1)).toBe(false)
allLeafNodes.add(branch0)
allLeafNodes.add(branch1)
}
// 4 P3 variants × 2 branches each = 8 unique leaf nodes
expect(allLeafNodes.size).toBe(8)
})
})