chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,738 @@
/**
* @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)
})
})