Files
simstudioai--sim/apps/sim/executor/utils/parallel-expansion.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

648 lines
20 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { CONTROL_BACK_EDGE_HANDLES, EDGE } from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import {
buildBranchNodeId,
buildClonedSubflowId,
buildParallelSentinelEndId,
buildParallelSentinelStartId,
buildSentinelEndId,
buildSentinelStartId,
extractBaseBlockId,
isLoopSentinelNodeId,
isParallelSentinelNodeId,
normalizeNodeId,
stripOuterBranchSuffix,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ParallelExpansion')
export interface ClonedSubflowInfo {
clonedId: string
originalId: string
outerBranchIndex: number
}
export interface ExpansionResult {
entryNodes: string[]
terminalNodes: string[]
allBranchNodes: string[]
clonedSubflows: ClonedSubflowInfo[]
}
export class ParallelExpander {
expandParallel(
dag: DAG,
parallelId: string,
branchCount: number,
distributionItems?: any[],
options: { branchIndexOffset?: number; totalBranches?: number } = {}
): ExpansionResult {
const config = dag.parallelConfigs.get(parallelId)
if (!config) {
throw new Error(`Parallel config not found: ${parallelId}`)
}
const blocksInParallel = config.nodes || []
if (blocksInParallel.length === 0) {
return { entryNodes: [], terminalNodes: [], allBranchNodes: [], clonedSubflows: [] }
}
// Separate nested subflow containers from regular expandable blocks.
// Nested parallels/loops have sentinel nodes instead of branch template nodes,
// so they cannot be cloned per-branch like regular blocks.
const regularBlocks: string[] = []
const nestedSubflows: string[] = []
for (const blockId of blocksInParallel) {
if (dag.parallelConfigs.has(blockId) || dag.loopConfigs.has(blockId)) {
nestedSubflows.push(blockId)
} else {
regularBlocks.push(blockId)
}
}
const allBranchNodes: string[] = []
const branchIndexOffset = options.branchIndexOffset ?? 0
const branchTotal = options.totalBranches ?? branchCount
for (const blockId of regularBlocks) {
const templateId = buildBranchNodeId(blockId, 0)
const templateNode = dag.nodes.get(templateId)
if (!templateNode) {
logger.warn('Template node not found', { blockId, templateId })
continue
}
for (let i = 0; i < branchCount; i++) {
const branchNodeId = buildBranchNodeId(blockId, i)
const globalBranchIndex = branchIndexOffset + i
allBranchNodes.push(branchNodeId)
if (i === 0) {
this.updateBranchMetadata(
templateNode,
globalBranchIndex,
branchTotal,
distributionItems?.[i]
)
continue
}
const branchNode = this.cloneTemplateNode(
templateNode,
blockId,
i,
globalBranchIndex,
branchTotal,
distributionItems?.[i]
)
dag.nodes.set(branchNodeId, branchNode)
}
}
// Clone nested subflow graphs per outer branch so each branch runs independently.
// Branch 0 uses the original sentinel/template nodes; branches 1..N get full clones.
const clonedSubflows: ClonedSubflowInfo[] = []
for (const subflowId of nestedSubflows) {
for (let i = 0; i < branchCount; i++) {
const globalBranchIndex = branchIndexOffset + i
if (globalBranchIndex === 0) {
continue
}
const cloned = this.cloneNestedSubflow(dag, subflowId, globalBranchIndex, clonedSubflows)
clonedSubflows.push({
clonedId: cloned.clonedId,
originalId: subflowId,
outerBranchIndex: globalBranchIndex,
})
}
}
this.wireInternalEdges(
dag,
blocksInParallel,
new Set(blocksInParallel),
branchCount,
branchIndexOffset
)
const { entryNodes, terminalNodes } = this.identifyBoundaryNodes(
dag,
blocksInParallel,
branchCount,
branchIndexOffset
)
this.wireSentinelEdges(dag, parallelId, entryNodes, terminalNodes, branchCount)
logger.info('Parallel expanded', {
parallelId,
branchCount,
blocksCount: blocksInParallel.length,
nestedSubflows: nestedSubflows.length,
totalNodes: allBranchNodes.length,
})
return { entryNodes, terminalNodes, allBranchNodes, clonedSubflows }
}
private updateBranchMetadata(
node: DAGNode,
branchIndex: number,
branchTotal: number,
distributionItem?: any
): void {
node.metadata.branchIndex = branchIndex
node.metadata.branchTotal = branchTotal
if (distributionItem !== undefined) {
node.metadata.distributionItem = distributionItem
}
}
private cloneTemplateNode(
template: DAGNode,
originalBlockId: string,
localBranchIndex: number,
branchIndex: number,
branchTotal: number,
distributionItem?: any
): DAGNode {
const branchNodeId = buildBranchNodeId(originalBlockId, localBranchIndex)
const blockClone: SerializedBlock = {
...template.block,
id: branchNodeId,
}
return {
id: branchNodeId,
block: blockClone,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
...template.metadata,
branchIndex,
branchTotal,
distributionItem,
originalBlockId,
},
}
}
private wireInternalEdges(
dag: DAG,
blocksInParallel: string[],
blocksSet: Set<string>,
branchCount: number,
branchIndexOffset: number
): void {
const topology = this.collectInternalEdgeTopology(dag, blocksInParallel, blocksSet)
const cleanedSourceNodes = new Set<string>()
for (const edge of topology) {
for (let i = 0; i < branchCount; i++) {
const globalBranchIndex = branchIndexOffset + i
const sourceNodeId = this.resolveBranchChildBoundaryId(
dag,
edge.sourceBlockId,
i,
globalBranchIndex,
'end'
)
const targetNodeId = this.resolveBranchChildBoundaryId(
dag,
edge.targetBlockId,
i,
globalBranchIndex,
'start'
)
const sourceNode = dag.nodes.get(sourceNodeId)
const targetNode = dag.nodes.get(targetNodeId)
if (!sourceNode || !targetNode) continue
if (!cleanedSourceNodes.has(sourceNodeId)) {
this.clearStaleInternalEdges(dag, sourceNodeId, sourceNode, blocksSet)
cleanedSourceNodes.add(sourceNodeId)
}
const edgeId = edge.sourceHandle
? `${sourceNodeId}${targetNodeId}-${edge.sourceHandle}`
: `${sourceNodeId}${targetNodeId}`
sourceNode.outgoingEdges.set(edgeId, {
target: targetNodeId,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
})
targetNode.incomingEdges.add(sourceNodeId)
}
}
}
private clearStaleInternalEdges(
dag: DAG,
sourceNodeId: string,
sourceNode: DAGNode,
blocksSet: Set<string>
): void {
for (const [edgeId, edge] of Array.from(sourceNode.outgoingEdges.entries())) {
if (!blocksSet.has(stripOuterBranchSuffix(normalizeNodeId(edge.target)))) continue
sourceNode.outgoingEdges.delete(edgeId)
dag.nodes.get(edge.target)?.incomingEdges.delete(sourceNodeId)
}
}
private identifyBoundaryNodes(
dag: DAG,
blocksInParallel: string[],
branchCount: number,
branchIndexOffset: number
): { entryNodes: string[]; terminalNodes: string[] } {
const entryNodes: string[] = []
const terminalNodes: string[] = []
const topology = this.collectInternalEdgeTopology(
dag,
blocksInParallel,
new Set(blocksInParallel)
)
const blocksWithInternalIncoming = new Set(topology.map((edge) => edge.targetBlockId))
const blocksWithInternalOutgoing = new Set(topology.map((edge) => edge.sourceBlockId))
for (const blockId of blocksInParallel) {
for (let i = 0; i < branchCount; i++) {
const globalBranchIndex = branchIndexOffset + i
if (!blocksWithInternalIncoming.has(blockId)) {
entryNodes.push(
this.resolveBranchChildBoundaryId(dag, blockId, i, globalBranchIndex, 'start')
)
}
if (!blocksWithInternalOutgoing.has(blockId)) {
terminalNodes.push(
this.resolveBranchChildBoundaryId(dag, blockId, i, globalBranchIndex, 'end')
)
}
}
}
return { entryNodes, terminalNodes }
}
private collectInternalEdgeTopology(
dag: DAG,
blocksInParallel: string[],
blocksSet: Set<string>
): Array<{
sourceBlockId: string
targetBlockId: string
sourceHandle?: string
targetHandle?: string
}> {
const topology: Array<{
sourceBlockId: string
targetBlockId: string
sourceHandle?: string
targetHandle?: string
}> = []
for (const blockId of blocksInParallel) {
const sourceNodeId = this.resolveOriginalChildBoundaryId(dag, blockId, 'end')
const sourceNode = dag.nodes.get(sourceNodeId)
if (!sourceNode) continue
for (const [, edge] of sourceNode.outgoingEdges) {
if (edge.sourceHandle && CONTROL_BACK_EDGE_HANDLES.has(edge.sourceHandle)) continue
const targetBoundaryId = extractBaseBlockId(normalizeNodeId(edge.target))
const targetBlockId = blocksSet.has(targetBoundaryId)
? targetBoundaryId
: stripOuterBranchSuffix(targetBoundaryId)
if (!blocksSet.has(targetBlockId)) continue
topology.push({
sourceBlockId: blockId,
targetBlockId,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
})
}
}
return topology
}
private resolveOriginalChildBoundaryId(dag: DAG, blockId: string, side: 'start' | 'end'): string {
if (dag.parallelConfigs.has(blockId)) {
return side === 'start'
? buildParallelSentinelStartId(blockId)
: buildParallelSentinelEndId(blockId)
}
if (dag.loopConfigs.has(blockId)) {
return side === 'start' ? buildSentinelStartId(blockId) : buildSentinelEndId(blockId)
}
return buildBranchNodeId(blockId, 0)
}
private resolveBranchChildBoundaryId(
dag: DAG,
blockId: string,
localBranchIndex: number,
globalBranchIndex: number,
side: 'start' | 'end'
): string {
if (!dag.parallelConfigs.has(blockId) && !dag.loopConfigs.has(blockId)) {
return buildBranchNodeId(blockId, localBranchIndex)
}
const effectiveSubflowId =
globalBranchIndex === 0 ? blockId : buildClonedSubflowId(blockId, globalBranchIndex)
if (dag.parallelConfigs.has(blockId)) {
return side === 'start'
? buildParallelSentinelStartId(effectiveSubflowId)
: buildParallelSentinelEndId(effectiveSubflowId)
}
return side === 'start'
? buildSentinelStartId(effectiveSubflowId)
: buildSentinelEndId(effectiveSubflowId)
}
/**
* Generates a unique clone ID for pre-expansion cloning.
*
* Pre-expansion clones use `{originalId}__clone{digest}__obranch-{branchIndex}` instead
* of the plain `{originalId}__obranch-{branchIndex}` used by runtime expansion.
* The clone segment prevents naming collisions when the original (branch-0)
* subflow later expands at runtime and creates `{child}__obranch-{branchIndex}`.
* Keeping it deterministic lets pause/resume rebuild the same active branch IDs.
*/
private buildPreCloneIdForParent(
originalId: string,
outerBranchIndex: number,
parentCloneId: string
): string {
const input = `${parentCloneId}:${originalId}:${outerBranchIndex}`
const digest = sha256Hex(input).slice(0, 24)
return `${originalId}__clone${digest}__obranch-${outerBranchIndex}`
}
/**
* Clones an entire nested subflow graph for a specific outer branch.
*
* The top-level subflow gets a standard `__obranch-{N}` clone ID (needed by
* `findEffectiveContainerId` at runtime). All deeper children — both containers
* and regular blocks — receive deterministic `__clone{N}__obranch-{M}` IDs to
* avoid collisions with runtime expansion.
*/
private cloneNestedSubflow(
dag: DAG,
subflowId: string,
outerBranchIndex: number,
clonedSubflows: ClonedSubflowInfo[]
): { startId: string; endId: string; clonedId: string; idMap: Map<string, string> } {
const clonedId = buildClonedSubflowId(subflowId, outerBranchIndex)
const { startId, endId, idMap } = this.cloneSubflowGraph(
dag,
subflowId,
clonedId,
outerBranchIndex,
clonedSubflows
)
return { startId, endId, clonedId, idMap }
}
/**
* Core recursive cloning: duplicates a subflow's sentinels, config, child blocks,
* and DAG nodes under the given `clonedId`. Nested containers are recursively
* cloned with unique pre-clone IDs.
*/
private cloneSubflowGraph(
dag: DAG,
originalId: string,
clonedId: string,
outerBranchIndex: number,
clonedSubflows: ClonedSubflowInfo[]
): { startId: string; endId: string; idMap: Map<string, string> } {
const isParallel = dag.parallelConfigs.has(originalId)
const config = isParallel
? dag.parallelConfigs.get(originalId)!
: dag.loopConfigs.get(originalId)!
const blockIds = config.nodes || []
const idMap = new Map<string, string>()
// Map sentinel nodes
const origStartId = isParallel
? buildParallelSentinelStartId(originalId)
: buildSentinelStartId(originalId)
const origEndId = isParallel
? buildParallelSentinelEndId(originalId)
: buildSentinelEndId(originalId)
const clonedStartId = isParallel
? buildParallelSentinelStartId(clonedId)
: buildSentinelStartId(clonedId)
const clonedEndId = isParallel
? buildParallelSentinelEndId(clonedId)
: buildSentinelEndId(clonedId)
idMap.set(origStartId, clonedStartId)
idMap.set(origEndId, clonedEndId)
// Process child blocks — recurse into nested containers, remap regular blocks
const clonedBlockIds: string[] = []
for (const blockId of blockIds) {
const isNestedParallel = dag.parallelConfigs.has(blockId)
const isNestedLoop = dag.loopConfigs.has(blockId)
if (isNestedParallel || isNestedLoop) {
const nestedClonedId = this.buildPreCloneIdForParent(blockId, outerBranchIndex, clonedId)
clonedBlockIds.push(nestedClonedId)
const innerResult = this.cloneSubflowGraph(
dag,
blockId,
nestedClonedId,
outerBranchIndex,
clonedSubflows
)
for (const [k, v] of innerResult.idMap) {
idMap.set(k, v)
}
clonedSubflows.push({
clonedId: nestedClonedId,
originalId: blockId,
outerBranchIndex,
})
} else {
const clonedBlockId = this.buildPreCloneIdForParent(blockId, outerBranchIndex, clonedId)
clonedBlockIds.push(clonedBlockId)
if (isParallel) {
idMap.set(buildBranchNodeId(blockId, 0), buildBranchNodeId(clonedBlockId, 0))
} else {
idMap.set(blockId, clonedBlockId)
}
}
}
// Register cloned config
if (isParallel) {
dag.parallelConfigs.set(clonedId, {
...dag.parallelConfigs.get(originalId)!,
id: clonedId,
nodes: clonedBlockIds,
})
} else {
dag.loopConfigs.set(clonedId, {
...dag.loopConfigs.get(originalId)!,
id: clonedId,
nodes: clonedBlockIds,
})
}
// Clone DAG nodes (sentinels + regular blocks) with remapped edges
const origNodeIds = [origStartId, origEndId]
for (const blockId of blockIds) {
if (dag.parallelConfigs.has(blockId) || dag.loopConfigs.has(blockId)) continue
if (isParallel) {
origNodeIds.push(buildBranchNodeId(blockId, 0))
} else {
origNodeIds.push(blockId)
}
}
for (const origId of origNodeIds) {
const origNode = dag.nodes.get(origId)
if (!origNode) continue
const clonedNodeId = idMap.get(origId)!
this.cloneDAGNode(dag, origNode, clonedNodeId, clonedId, isParallel)
}
this.remapClonedEdges(dag, idMap)
return { startId: clonedStartId, endId: clonedEndId, idMap }
}
/**
* Clones a single DAG node with updated metadata. Edge wiring is owned by remapClonedEdges
* after the full clone id map is available.
*/
private cloneDAGNode(
dag: DAG,
origNode: DAGNode,
clonedNodeId: string,
parentClonedId: string,
parentIsParallel: boolean
): void {
const metadataOverride = parentIsParallel
? { subflowId: parentClonedId, subflowType: 'parallel' as const }
: { subflowId: parentClonedId, subflowType: 'loop' as const }
dag.nodes.set(clonedNodeId, {
id: clonedNodeId,
block: { ...origNode.block, id: clonedNodeId },
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
...origNode.metadata,
...metadataOverride,
...(origNode.metadata.originalBlockId && {
originalBlockId: origNode.metadata.originalBlockId,
}),
},
})
}
private remapClonedEdges(dag: DAG, idMap: Map<string, string>): void {
for (const [origId, clonedNodeId] of idMap) {
const origNode = dag.nodes.get(origId)
const clonedNode = dag.nodes.get(clonedNodeId)
if (!origNode || !clonedNode) continue
const remappedOutgoing = new Map<
string,
{ target: string; sourceHandle?: string; targetHandle?: string }
>()
for (const [, edge] of origNode.outgoingEdges) {
const clonedTarget = idMap.get(edge.target)
if (!clonedTarget) continue
const edgeId = edge.sourceHandle
? `${clonedNodeId}${clonedTarget}-${edge.sourceHandle}`
: `${clonedNodeId}${clonedTarget}`
remappedOutgoing.set(edgeId, {
target: clonedTarget,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
})
}
const remappedIncoming = new Set<string>()
for (const incomingId of origNode.incomingEdges) {
const clonedIncomingId = idMap.get(incomingId)
if (clonedIncomingId) {
remappedIncoming.add(clonedIncomingId)
}
}
clonedNode.outgoingEdges = remappedOutgoing
clonedNode.incomingEdges = remappedIncoming
}
}
private wireSentinelEdges(
dag: DAG,
parallelId: string,
entryNodes: string[],
terminalNodes: string[],
branchCount: number
): void {
const sentinelStartId = buildParallelSentinelStartId(parallelId)
const sentinelEndId = buildParallelSentinelEndId(parallelId)
const sentinelStart = dag.nodes.get(sentinelStartId)
const sentinelEnd = dag.nodes.get(sentinelEndId)
if (!sentinelStart || !sentinelEnd) {
logger.warn('Sentinel nodes not found', { parallelId, sentinelStartId, sentinelEndId })
return
}
sentinelStart.outgoingEdges.clear()
sentinelEnd.incomingEdges.clear()
for (const entryNodeId of entryNodes) {
const entryNode = dag.nodes.get(entryNodeId)
if (!entryNode) continue
const edgeId = `${sentinelStartId}${entryNodeId}`
sentinelStart.outgoingEdges.set(edgeId, { target: entryNodeId })
entryNode.incomingEdges.add(sentinelStartId)
}
for (const terminalNodeId of terminalNodes) {
const terminalNode = dag.nodes.get(terminalNodeId)
if (!terminalNode) continue
const handle = isLoopSentinelNodeId(terminalNodeId)
? EDGE.LOOP_EXIT
: isParallelSentinelNodeId(terminalNodeId)
? EDGE.PARALLEL_EXIT
: undefined
const edgeId = handle
? `${terminalNodeId}${sentinelEndId}-${handle}`
: `${terminalNodeId}${sentinelEndId}`
terminalNode.outgoingEdges.set(edgeId, {
target: sentinelEndId,
sourceHandle: handle,
})
sentinelEnd.incomingEdges.add(terminalNodeId)
}
}
}