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

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
File diff suppressed because it is too large Load Diff
+784
View File
@@ -0,0 +1,784 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
CONTROL_BACK_EDGE_HANDLES,
EDGE,
isConditionBlockType,
isRouterBlockType,
isRouterV2BlockType,
} from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import {
buildBranchNodeId,
buildParallelSentinelEndId,
buildParallelSentinelStartId,
buildSentinelEndId,
buildSentinelStartId,
normalizeNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedWorkflow } from '@/serializer/types'
const logger = createLogger('EdgeConstructor')
interface ConditionConfig {
id: string
label?: string
condition: string
}
interface RouterV2RouteConfig {
id: string
title: string
description: string
}
interface EdgeMetadata {
blockTypeMap: Map<string, string>
conditionConfigMap: Map<string, ConditionConfig[]>
routerBlockIds: Set<string>
routerV2ConfigMap: Map<string, RouterV2RouteConfig[]>
}
export class EdgeConstructor {
execute(
workflow: SerializedWorkflow,
dag: DAG,
blocksInParallels: Set<string>,
blocksInLoops: Set<string>,
reachableBlocks: Set<string>,
pauseTriggerMapping: Map<string, string>
): void {
const loopBlockIds = new Set(dag.loopConfigs.keys())
const parallelBlockIds = new Set(dag.parallelConfigs.keys())
const metadata = this.buildMetadataMaps(workflow)
this.wireRegularEdges(
workflow,
dag,
blocksInParallels,
blocksInLoops,
reachableBlocks,
loopBlockIds,
parallelBlockIds,
metadata,
pauseTriggerMapping
)
this.wireLoopSentinels(dag)
this.wireParallelSentinels(dag)
}
private buildMetadataMaps(workflow: SerializedWorkflow): EdgeMetadata {
const blockTypeMap = new Map<string, string>()
const conditionConfigMap = new Map<string, ConditionConfig[]>()
const routerBlockIds = new Set<string>()
const routerV2ConfigMap = new Map<string, RouterV2RouteConfig[]>()
for (const block of workflow.blocks) {
const blockType = block.metadata?.id ?? ''
blockTypeMap.set(block.id, blockType)
if (isConditionBlockType(blockType)) {
const conditions = this.parseConditionConfig(block)
if (conditions) {
conditionConfigMap.set(block.id, conditions)
}
} else if (isRouterV2BlockType(blockType)) {
// Router V2 uses port-based routing with route configs
const routes = this.parseRouterV2Config(block)
if (routes) {
routerV2ConfigMap.set(block.id, routes)
}
} else if (isRouterBlockType(blockType)) {
// Legacy router uses target block IDs
routerBlockIds.add(block.id)
}
}
return { blockTypeMap, conditionConfigMap, routerBlockIds, routerV2ConfigMap }
}
private parseConditionConfig(block: any): ConditionConfig[] | null {
try {
const conditionsJson = block.config.params?.conditions
if (typeof conditionsJson === 'string') {
return JSON.parse(conditionsJson)
}
if (Array.isArray(conditionsJson)) {
return conditionsJson
}
return null
} catch (error) {
logger.warn('Failed to parse condition config', {
blockId: block.id,
error: toError(error).message,
})
return null
}
}
private parseRouterV2Config(block: any): RouterV2RouteConfig[] | null {
try {
const routesJson = block.config.params?.routes
if (typeof routesJson === 'string') {
return JSON.parse(routesJson)
}
if (Array.isArray(routesJson)) {
return routesJson
}
return null
} catch (error) {
logger.warn('Failed to parse router v2 config', {
blockId: block.id,
error: toError(error).message,
})
return null
}
}
private generateSourceHandle(
source: string,
target: string,
sourceHandle: string | undefined,
metadata: EdgeMetadata,
workflow: SerializedWorkflow
): string | undefined {
let handle = sourceHandle
if (!handle && isConditionBlockType(metadata.blockTypeMap.get(source) ?? '')) {
const conditions = metadata.conditionConfigMap.get(source)
if (conditions && conditions.length > 0) {
const edgesFromCondition = workflow.connections.filter((c) => c.source === source)
const edgeIndex = edgesFromCondition.findIndex((e) => e.target === target)
if (edgeIndex >= 0 && edgeIndex < conditions.length) {
const correspondingCondition = conditions[edgeIndex]
handle = `${EDGE.CONDITION_PREFIX}${correspondingCondition.id}`
}
}
}
// Router V2 uses port-based routing - handle is already set from UI (router-{routeId})
// We don't modify it here, just validate it exists
if (metadata.routerV2ConfigMap.has(source)) {
// For router_v2, the sourceHandle should already be set from the UI
// If not set and not an error handle, generate based on route index
if (!handle || (!handle.startsWith(EDGE.ROUTER_PREFIX) && handle !== EDGE.ERROR)) {
const routes = metadata.routerV2ConfigMap.get(source)
if (routes && routes.length > 0) {
const edgesFromRouter = workflow.connections.filter((c) => c.source === source)
const edgeIndex = edgesFromRouter.findIndex((e) => e.target === target)
if (edgeIndex >= 0 && edgeIndex < routes.length) {
const correspondingRoute = routes[edgeIndex]
handle = `${EDGE.ROUTER_PREFIX}${correspondingRoute.id}`
}
}
}
}
// Legacy router uses target block ID
if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) {
handle = `${EDGE.ROUTER_PREFIX}${target}`
}
return handle
}
private wireRegularEdges(
workflow: SerializedWorkflow,
dag: DAG,
blocksInParallels: Set<string>,
blocksInLoops: Set<string>,
reachableBlocks: Set<string>,
loopBlockIds: Set<string>,
parallelBlockIds: Set<string>,
metadata: EdgeMetadata,
pauseTriggerMapping: Map<string, string>
): void {
for (const connection of workflow.connections) {
let { source, target } = connection
const originalSource = source
const originalTarget = target
let sourceHandle = this.generateSourceHandle(
source,
target,
connection.sourceHandle,
metadata,
workflow
)
const targetHandle = connection.targetHandle
const sourceIsLoopBlock = loopBlockIds.has(source)
const targetIsLoopBlock = loopBlockIds.has(target)
const sourceIsParallelBlock = parallelBlockIds.has(source)
const targetIsParallelBlock = parallelBlockIds.has(target)
if (this.edgeStaysWithinSameParallel(originalSource, originalTarget, dag)) {
const sourceId = this.resolveSubflowToSentinelEnd(originalSource, dag)
const targetId = this.resolveSubflowToSentinelStart(originalTarget, dag)
const resolvedSourceHandle = this.resolveParallelChildSourceHandle(
originalSource,
dag,
sourceHandle
)
this.addEdge(dag, sourceId, targetId, resolvedSourceHandle, targetHandle)
this.addSubflowStartExitBypass(dag, originalSource)
continue
}
if (sourceIsLoopBlock) {
const sentinelEndId = buildSentinelEndId(originalSource)
const loopSentinelStartId = buildSentinelStartId(originalSource)
if (!dag.nodes.has(sentinelEndId) || !dag.nodes.has(loopSentinelStartId)) {
continue
}
source = sentinelEndId
sourceHandle = EDGE.LOOP_EXIT
this.addSubflowStartExitBypass(dag, originalSource)
}
if (targetIsLoopBlock) {
const sentinelStartId = buildSentinelStartId(target)
if (!dag.nodes.has(sentinelStartId)) {
continue
}
target = sentinelStartId
}
if (sourceIsParallelBlock) {
// Skip intra-parallel edges (start → child); handled by wireParallelSentinels
const sourceParallelNodes = dag.parallelConfigs.get(originalSource)?.nodes
if (sourceParallelNodes?.includes(originalTarget)) {
continue
}
const sentinelEndId = buildParallelSentinelEndId(originalSource)
if (!dag.nodes.has(sentinelEndId)) {
continue
}
source = sentinelEndId
sourceHandle = EDGE.PARALLEL_EXIT
}
if (targetIsParallelBlock) {
const sentinelStartId = buildParallelSentinelStartId(target)
if (!dag.nodes.has(sentinelStartId)) {
continue
}
target = sentinelStartId
}
if (sourceIsParallelBlock) {
this.addSubflowStartExitBypass(dag, originalSource)
}
if (this.edgeCrossesLoopBoundary(originalSource, originalTarget, blocksInLoops, dag)) {
continue
}
if (!this.isEdgeReachable(source, target, reachableBlocks, dag)) {
continue
}
if (blocksInParallels.has(source) && blocksInParallels.has(target)) {
const sourceParallelId = this.getParallelId(source, dag)
const targetParallelId = this.getParallelId(target, dag)
if (sourceParallelId === targetParallelId) {
this.wireParallelTemplateEdge(source, target, dag, sourceHandle, targetHandle)
} else {
logger.warn('Edge between different parallels - invalid workflow', { source, target })
}
} else if (blocksInParallels.has(source) || blocksInParallels.has(target)) {
// Skip - will be handled by sentinel wiring
} else {
const resolvedSource = pauseTriggerMapping.get(originalSource) ?? source
this.addEdge(dag, resolvedSource, target, sourceHandle, targetHandle)
}
}
}
private wireLoopSentinels(dag: DAG): void {
for (const [loopId, loopConfig] of dag.loopConfigs) {
const nodes = loopConfig.nodes
if (nodes.length === 0) continue
const sentinelStartId = buildSentinelStartId(loopId)
const sentinelEndId = buildSentinelEndId(loopId)
if (!dag.nodes.has(sentinelStartId) || !dag.nodes.has(sentinelEndId)) {
continue
}
this.addSubflowStartExitBypass(dag, loopId)
const { startNodes, terminalNodes } = this.findLoopBoundaryNodes(nodes, dag)
for (const startNodeId of startNodes) {
const resolvedId = this.resolveLoopBlockToSentinelStart(startNodeId, dag)
this.addEdge(dag, sentinelStartId, resolvedId)
}
for (const terminalNodeId of terminalNodes) {
const resolvedId = this.resolveLoopBlockToSentinelEnd(terminalNodeId, dag)
if (resolvedId !== terminalNodeId) {
// Use the sourceHandle that matches the nested subflow's exit route.
// Parallel sentinel-end outputs selectedRoute "parallel_exit",
// loop sentinel-end outputs "loop_exit". The edge manager only activates
// edges whose sourceHandle matches the source node's selectedRoute.
const handle = dag.parallelConfigs.has(terminalNodeId)
? EDGE.PARALLEL_EXIT
: EDGE.LOOP_EXIT
this.addEdge(dag, resolvedId, sentinelEndId, handle)
this.addSubflowStartExitBypass(dag, terminalNodeId)
} else {
this.addEdge(dag, resolvedId, sentinelEndId)
}
}
this.addEdge(dag, sentinelEndId, sentinelStartId, EDGE.LOOP_CONTINUE, undefined, {
registerIncoming: false,
})
}
}
private wireParallelSentinels(dag: DAG): void {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
const nodes = parallelConfig.nodes
if (nodes.length === 0) continue
const sentinelStartId = buildParallelSentinelStartId(parallelId)
const sentinelEndId = buildParallelSentinelEndId(parallelId)
if (!dag.nodes.has(sentinelStartId) || !dag.nodes.has(sentinelEndId)) {
continue
}
this.addSubflowStartExitBypass(dag, parallelId)
const { entryNodes, terminalNodes } = this.findParallelBoundaryNodes(nodes, dag)
for (const entryNodeId of entryNodes) {
const targetId = this.resolveSubflowToSentinelStart(entryNodeId, dag)
if (dag.nodes.has(targetId)) {
this.addEdge(dag, sentinelStartId, targetId)
}
}
for (const terminalNodeId of terminalNodes) {
const sourceId = this.resolveSubflowToSentinelEnd(terminalNodeId, dag)
if (dag.nodes.has(sourceId)) {
const handle = this.resolveSubflowExitHandle(terminalNodeId, dag)
this.addEdge(dag, sourceId, sentinelEndId, handle)
if (handle) {
this.addSubflowStartExitBypass(dag, terminalNodeId)
}
}
}
this.addEdge(dag, sentinelEndId, sentinelStartId, EDGE.PARALLEL_CONTINUE, undefined, {
registerIncoming: false,
})
}
}
/**
* Resolves a node ID to the appropriate entry point for sentinel wiring.
* Nested parallels → their sentinel-start, nested loops → their sentinel-start,
* regular blocks → their branch template node.
*/
private resolveSubflowToSentinelStart(nodeId: string, dag: DAG): string {
if (dag.parallelConfigs.has(nodeId)) {
return buildParallelSentinelStartId(nodeId)
}
if (dag.loopConfigs.has(nodeId)) {
return buildSentinelStartId(nodeId)
}
return buildBranchNodeId(nodeId, 0)
}
/**
* Resolves a node ID to the appropriate exit point for sentinel wiring.
* Nested parallels → their sentinel-end, nested loops → their sentinel-end,
* regular blocks → their branch template node.
*/
private resolveSubflowToSentinelEnd(nodeId: string, dag: DAG): string {
if (dag.parallelConfigs.has(nodeId)) {
return buildParallelSentinelEndId(nodeId)
}
if (dag.loopConfigs.has(nodeId)) {
return buildSentinelEndId(nodeId)
}
return buildBranchNodeId(nodeId, 0)
}
/**
* Checks whether an edge crosses a loop boundary (source and target are in
* different loops, or one is inside a loop and the other is not). Uses the
* original block IDs (pre-sentinel-remapping) because `blocksInLoops` and
* `loopConfigs.nodes` reference original block IDs from the serialized workflow.
*/
private edgeCrossesLoopBoundary(
source: string,
target: string,
blocksInLoops: Set<string>,
dag: DAG
): boolean {
const sourceInLoop = blocksInLoops.has(source)
const targetInLoop = blocksInLoops.has(target)
if (sourceInLoop !== targetInLoop) {
return true
}
if (!sourceInLoop && !targetInLoop) {
return false
}
// Find the innermost loop for each block. In nested loops a block appears
// in multiple loop configs; we need the most deeply nested one.
const sourceLoopId = this.findInnermostLoop(source, dag)
const targetLoopId = this.findInnermostLoop(target, dag)
return sourceLoopId !== targetLoopId
}
/**
* Finds the innermost loop containing a block. When a block is in nested
* loops (A contains B, both list the block), returns B (the one that
* doesn't contain any other candidate loop).
*/
private findInnermostLoop(blockId: string, dag: DAG): string | undefined {
const candidates: string[] = []
for (const [loopId, loopConfig] of dag.loopConfigs) {
if (loopConfig.nodes.includes(blockId)) {
candidates.push(loopId)
}
}
if (candidates.length <= 1) return candidates[0]
return candidates.find((candidateId) =>
candidates.every((otherId) => {
if (otherId === candidateId) return true
const candidateConfig = dag.loopConfigs.get(candidateId)
return !candidateConfig?.nodes.includes(otherId)
})
)
}
private isEdgeReachable(
source: string,
target: string,
reachableBlocks: Set<string>,
dag: DAG
): boolean {
if (!reachableBlocks.has(source) && !dag.nodes.has(source)) {
return false
}
if (!reachableBlocks.has(target) && !dag.nodes.has(target)) {
return false
}
return true
}
private wireParallelTemplateEdge(
source: string,
target: string,
dag: DAG,
sourceHandle?: string,
targetHandle?: string
): void {
const sourceNodeId = buildBranchNodeId(source, 0)
const targetNodeId = buildBranchNodeId(target, 0)
this.addEdge(dag, sourceNodeId, targetNodeId, sourceHandle, targetHandle)
}
/**
* Resolves the DAG node to inspect for a given loop child.
* If the child is a nested subflow (loop or parallel), returns its sentinel node;
* otherwise returns the regular DAG node.
*/
private resolveLoopChildNode(
nodeId: string,
dag: DAG,
sentinel: 'start' | 'end'
): { resolvedId: string; node: DAGNode | undefined } {
if (dag.loopConfigs.has(nodeId)) {
const resolvedId =
sentinel === 'start' ? buildSentinelStartId(nodeId) : buildSentinelEndId(nodeId)
return { resolvedId, node: dag.nodes.get(resolvedId) }
}
if (dag.parallelConfigs.has(nodeId)) {
const resolvedId =
sentinel === 'start'
? buildParallelSentinelStartId(nodeId)
: buildParallelSentinelEndId(nodeId)
return { resolvedId, node: dag.nodes.get(resolvedId) }
}
return { resolvedId: nodeId, node: dag.nodes.get(nodeId) }
}
private resolveLoopBlockToSentinelStart(nodeId: string, dag: DAG): string {
return this.resolveLoopChildNode(nodeId, dag, 'start').resolvedId
}
private resolveLoopBlockToSentinelEnd(nodeId: string, dag: DAG): string {
return this.resolveLoopChildNode(nodeId, dag, 'end').resolvedId
}
/**
* Builds the set of effective DAG node IDs for a loop's children,
* mapping nested subflow block IDs (loops and parallels) to their sentinel IDs.
*/
private buildEffectiveNodeSet(nodes: string[], dag: DAG): Set<string> {
const effective = new Set<string>()
for (const nodeId of nodes) {
if (dag.loopConfigs.has(nodeId)) {
effective.add(buildSentinelStartId(nodeId))
effective.add(buildSentinelEndId(nodeId))
} else if (dag.parallelConfigs.has(nodeId)) {
effective.add(buildParallelSentinelStartId(nodeId))
effective.add(buildParallelSentinelEndId(nodeId))
} else {
effective.add(nodeId)
}
}
return effective
}
private findLoopBoundaryNodes(
nodes: string[],
dag: DAG
): { startNodes: string[]; terminalNodes: string[] } {
const effectiveNodeSet = this.buildEffectiveNodeSet(nodes, dag)
const startNodesSet = new Set<string>()
const terminalNodesSet = new Set<string>()
for (const nodeId of nodes) {
const { node } = this.resolveLoopChildNode(nodeId, dag, 'start')
if (!node) continue
let hasIncomingFromLoop = false
for (const incomingNodeId of node.incomingEdges) {
if (effectiveNodeSet.has(incomingNodeId)) {
hasIncomingFromLoop = true
break
}
}
if (!hasIncomingFromLoop) {
startNodesSet.add(nodeId)
}
}
for (const nodeId of nodes) {
const { node } = this.resolveLoopChildNode(nodeId, dag, 'end')
if (!node) continue
let hasOutgoingToLoop = false
for (const [, edge] of node.outgoingEdges) {
if (this.isControlBackEdge(edge.sourceHandle)) continue
if (effectiveNodeSet.has(edge.target)) {
hasOutgoingToLoop = true
break
}
}
if (!hasOutgoingToLoop) {
terminalNodesSet.add(nodeId)
}
}
return {
startNodes: Array.from(startNodesSet),
terminalNodes: Array.from(terminalNodesSet),
}
}
private findParallelBoundaryNodes(
nodes: string[],
dag: DAG
): { entryNodes: string[]; terminalNodes: string[] } {
const nodesSet = new Set(nodes)
const entryNodes: string[] = []
const terminalNodes: string[] = []
for (const nodeId of nodes) {
// For nested subflow containers, use their sentinel nodes for boundary detection
const { startNode, endNode } = this.resolveParallelChildNodes(nodeId, dag)
if (!startNode && !endNode) continue
// Entry detection: check if the start-facing node has incoming edges from within the parallel
if (startNode) {
let hasIncomingFromParallel = false
for (const incomingNodeId of startNode.incomingEdges) {
const originalNodeId = normalizeNodeId(incomingNodeId)
if (nodesSet.has(originalNodeId)) {
hasIncomingFromParallel = true
break
}
}
if (!hasIncomingFromParallel) {
entryNodes.push(nodeId)
}
}
// Terminal detection: check if the end-facing node has outgoing edges to within the parallel
if (endNode) {
let hasOutgoingToParallel = false
for (const [, edge] of endNode.outgoingEdges) {
if (this.isControlBackEdge(edge.sourceHandle)) continue
const originalTargetId = normalizeNodeId(edge.target)
if (nodesSet.has(originalTargetId)) {
hasOutgoingToParallel = true
break
}
}
if (!hasOutgoingToParallel) {
terminalNodes.push(nodeId)
}
}
}
return { entryNodes, terminalNodes }
}
/**
* Resolves a child node inside a parallel to the correct DAG nodes for boundary detection.
* For regular blocks, returns the branch template node for both start and end.
* For nested parallels, returns the inner parallel's sentinel-start and sentinel-end.
* For nested loops, returns the inner loop's sentinel-start and sentinel-end.
*/
private resolveParallelChildNodes(
nodeId: string,
dag: DAG
): { startNode: DAGNode | undefined; endNode: DAGNode | undefined } {
if (dag.parallelConfigs.has(nodeId)) {
return {
startNode: dag.nodes.get(buildParallelSentinelStartId(nodeId)),
endNode: dag.nodes.get(buildParallelSentinelEndId(nodeId)),
}
}
if (dag.loopConfigs.has(nodeId)) {
return {
startNode: dag.nodes.get(buildSentinelStartId(nodeId)),
endNode: dag.nodes.get(buildSentinelEndId(nodeId)),
}
}
// Regular block — use branch template node for both
const templateNode = dag.nodes.get(buildBranchNodeId(nodeId, 0))
return { startNode: templateNode, endNode: templateNode }
}
private isControlBackEdge(sourceHandle?: string): boolean {
return sourceHandle !== undefined && CONTROL_BACK_EDGE_HANDLES.has(sourceHandle)
}
private getParallelId(blockId: string, dag: DAG): string | null {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
if (parallelConfig.nodes.includes(blockId)) {
return parallelId
}
}
return null
}
private edgeStaysWithinSameParallel(source: string, target: string, dag: DAG): boolean {
const sourceParallelId = this.getParallelId(source, dag)
const targetParallelId = this.getParallelId(target, dag)
return !!sourceParallelId && sourceParallelId === targetParallelId
}
private resolveParallelChildSourceHandle(
source: string,
dag: DAG,
sourceHandle?: string
): string | undefined {
if (dag.parallelConfigs.has(source)) {
return EDGE.PARALLEL_EXIT
}
if (dag.loopConfigs.has(source)) {
return EDGE.LOOP_EXIT
}
return sourceHandle
}
private resolveSubflowExitHandle(nodeId: string, dag: DAG): string | undefined {
if (dag.parallelConfigs.has(nodeId)) {
return EDGE.PARALLEL_EXIT
}
if (dag.loopConfigs.has(nodeId)) {
return EDGE.LOOP_EXIT
}
return undefined
}
private addSubflowStartExitBypass(dag: DAG, subflowId: string): void {
if (dag.parallelConfigs.has(subflowId)) {
const sourceId = buildParallelSentinelStartId(subflowId)
const targetId = buildParallelSentinelEndId(subflowId)
if (dag.nodes.has(sourceId) && dag.nodes.has(targetId)) {
this.addEdge(dag, sourceId, targetId, EDGE.PARALLEL_EXIT, undefined, {
registerIncoming: false,
})
}
return
}
if (dag.loopConfigs.has(subflowId)) {
const sourceId = buildSentinelStartId(subflowId)
const targetId = buildSentinelEndId(subflowId)
if (dag.nodes.has(sourceId) && dag.nodes.has(targetId)) {
this.addEdge(dag, sourceId, targetId, EDGE.LOOP_EXIT, undefined, {
registerIncoming: false,
})
}
}
}
private addEdge(
dag: DAG,
sourceId: string,
targetId: string,
sourceHandle?: string,
targetHandle?: string,
options: { registerIncoming?: boolean } = {}
): void {
const sourceNode = dag.nodes.get(sourceId)
const targetNode = dag.nodes.get(targetId)
if (!sourceNode || !targetNode) {
logger.warn('Edge references non-existent node', { sourceId, targetId })
return
}
const edgeId = `${sourceId}${targetId}${sourceHandle ? `-${sourceHandle}` : ''}`
sourceNode.outgoingEdges.set(edgeId, {
target: targetId,
sourceHandle,
targetHandle,
})
const { registerIncoming = true } = options
if (registerIncoming) {
targetNode.incomingEdges.add(sourceId)
}
}
}
@@ -0,0 +1,52 @@
import { BlockType, LOOP } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import { createSubflowSentinelNode } from '@/executor/dag/construction/sentinels'
import { buildSentinelEndId, buildSentinelStartId } from '@/executor/utils/subflow-utils'
export class LoopConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): void {
for (const [loopId, loopConfig] of dag.loopConfigs) {
if (!reachableBlocks.has(loopId)) {
continue
}
const loopNodes = loopConfig.nodes
const hasReachableChildren = loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
if (!hasReachableChildren) {
loopConfig.nodes = []
}
this.createSentinelPair(dag, loopId)
}
}
private createSentinelPair(dag: DAG, loopId: string): void {
const startId = buildSentinelStartId(loopId)
const endId = buildSentinelEndId(loopId)
dag.nodes.set(
startId,
createSubflowSentinelNode({
id: startId,
subflowId: loopId,
subflowType: 'loop',
sentinelType: LOOP.SENTINEL.START_TYPE,
blockType: BlockType.SENTINEL_START,
name: `${LOOP.SENTINEL.START_NAME_PREFIX} (${loopId})`,
})
)
dag.nodes.set(
endId,
createSubflowSentinelNode({
id: endId,
subflowId: loopId,
subflowType: 'loop',
sentinelType: LOOP.SENTINEL.END_TYPE,
blockType: BlockType.SENTINEL_END,
name: `${LOOP.SENTINEL.END_NAME_PREFIX} (${loopId})`,
})
)
}
}
@@ -0,0 +1,46 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import { NodeConstructor } from '@/executor/dag/construction/nodes'
import type { SerializedWorkflow } from '@/serializer/types'
describe('NodeConstructor', () => {
it('assigns nested loop nodes to the innermost loop metadata', () => {
const dag: DAG = {
nodes: new Map(),
loopConfigs: new Map([
['outer-loop', { id: 'outer-loop', nodes: ['inner-loop', 'task'], iterations: 1 }],
['inner-loop', { id: 'inner-loop', nodes: ['task'], iterations: 1 }],
]),
parallelConfigs: new Map(),
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
{
id: 'task',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: BlockType.FUNCTION, name: 'Task' },
enabled: true,
},
],
connections: [],
loops: {},
parallels: {},
}
new NodeConstructor().execute(workflow, dag, new Set(['task']))
expect(dag.nodes.get('task')?.metadata).toMatchObject({
isLoopNode: true,
subflowId: 'inner-loop',
subflowType: 'loop',
})
})
})
+166
View File
@@ -0,0 +1,166 @@
import { BlockType, isMetadataOnlyBlockType } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import { buildBranchNodeId } from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
export class NodeConstructor {
execute(
workflow: SerializedWorkflow,
dag: DAG,
reachableBlocks: Set<string>
): {
blocksInLoops: Set<string>
blocksInParallels: Set<string>
pauseTriggerMapping: Map<string, string>
} {
const blocksInLoops = new Set<string>()
const blocksInParallels = new Set<string>()
const pauseTriggerMapping = new Map<string, string>()
this.categorizeBlocks(dag, reachableBlocks, blocksInLoops, blocksInParallels)
for (const block of workflow.blocks) {
if (!this.shouldProcessBlock(block, reachableBlocks)) {
continue
}
const parallelId = this.findParallelForBlock(block.id, dag)
if (parallelId) {
this.createParallelTemplateNode(block, parallelId, dag)
} else {
this.createRegularOrLoopNode(block, blocksInLoops, dag)
}
}
return { blocksInLoops, blocksInParallels, pauseTriggerMapping }
}
private shouldProcessBlock(block: SerializedBlock, reachableBlocks: Set<string>): boolean {
if (!block.enabled) {
return false
}
if (!reachableBlocks.has(block.id)) {
return false
}
if (isMetadataOnlyBlockType(block.metadata?.id)) {
return false
}
return true
}
private categorizeBlocks(
dag: DAG,
reachableBlocks: Set<string>,
blocksInLoops: Set<string>,
blocksInParallels: Set<string>
): void {
this.categorizeLoopBlocks(dag, reachableBlocks, blocksInLoops)
this.categorizeParallelBlocks(dag, reachableBlocks, blocksInParallels)
}
private categorizeLoopBlocks(
dag: DAG,
reachableBlocks: Set<string>,
blocksInLoops: Set<string>
): void {
for (const [, loopConfig] of dag.loopConfigs) {
for (const nodeId of loopConfig.nodes) {
if (reachableBlocks.has(nodeId)) {
blocksInLoops.add(nodeId)
}
}
}
}
private categorizeParallelBlocks(
dag: DAG,
reachableBlocks: Set<string>,
blocksInParallels: Set<string>
): void {
for (const [, parallelConfig] of dag.parallelConfigs) {
for (const nodeId of parallelConfig.nodes) {
if (reachableBlocks.has(nodeId)) {
blocksInParallels.add(nodeId)
}
}
}
}
private createParallelTemplateNode(block: SerializedBlock, parallelId: string, dag: DAG): void {
const templateNodeId = buildBranchNodeId(block.id, 0)
const blockClone: SerializedBlock = {
...block,
id: templateNodeId,
}
dag.nodes.set(templateNodeId, {
id: templateNodeId,
block: blockClone,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
isParallelBranch: true,
subflowId: parallelId,
subflowType: 'parallel',
branchIndex: 0,
branchTotal: 1,
isPauseResponse: block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP,
originalBlockId: block.id,
},
})
}
private createRegularOrLoopNode(
block: SerializedBlock,
blocksInLoops: Set<string>,
dag: DAG
): void {
const isLoopNode = blocksInLoops.has(block.id)
const loopId = isLoopNode ? this.findLoopIdForBlock(block.id, dag) : undefined
const isPauseBlock = block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP
dag.nodes.set(block.id, {
id: block.id,
block,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
isLoopNode,
...(loopId && { subflowId: loopId, subflowType: 'loop' as const }),
isPauseResponse: isPauseBlock,
originalBlockId: block.id,
},
})
}
private findLoopIdForBlock(blockId: string, dag: DAG): string | undefined {
const candidates: string[] = []
for (const [loopId, loopConfig] of dag.loopConfigs) {
if (loopConfig.nodes.includes(blockId)) {
candidates.push(loopId)
}
}
if (candidates.length <= 1) return candidates[0]
return candidates.find((candidateId) =>
candidates.every((otherId) => {
if (otherId === candidateId) return true
const candidateConfig = dag.loopConfigs.get(candidateId)
return !candidateConfig?.nodes.includes(otherId)
})
)
}
private findParallelForBlock(blockId: string, dag: DAG): string | null {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
if (parallelConfig.nodes.includes(blockId)) {
return parallelId
}
}
return null
}
}
@@ -0,0 +1,55 @@
import { BlockType, PARALLEL } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import { createSubflowSentinelNode } from '@/executor/dag/construction/sentinels'
import {
buildParallelSentinelEndId,
buildParallelSentinelStartId,
} from '@/executor/utils/subflow-utils'
export class ParallelConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): void {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
if (!reachableBlocks.has(parallelId)) {
continue
}
const parallelNodes = parallelConfig.nodes
const hasReachableChildren = parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
if (!hasReachableChildren) {
parallelConfig.nodes = []
}
this.createSentinelPair(dag, parallelId)
}
}
private createSentinelPair(dag: DAG, parallelId: string): void {
const startId = buildParallelSentinelStartId(parallelId)
const endId = buildParallelSentinelEndId(parallelId)
dag.nodes.set(
startId,
createSubflowSentinelNode({
id: startId,
subflowId: parallelId,
subflowType: 'parallel',
sentinelType: PARALLEL.SENTINEL.START_TYPE,
blockType: BlockType.SENTINEL_START,
name: `${PARALLEL.SENTINEL.START_NAME_PREFIX} (${parallelId})`,
})
)
dag.nodes.set(
endId,
createSubflowSentinelNode({
id: endId,
subflowId: parallelId,
subflowType: 'parallel',
sentinelType: PARALLEL.SENTINEL.END_TYPE,
blockType: BlockType.SENTINEL_END,
name: `${PARALLEL.SENTINEL.END_NAME_PREFIX} (${parallelId})`,
})
)
}
}
+184
View File
@@ -0,0 +1,184 @@
import { createLogger } from '@sim/logger'
import { isMetadataOnlyBlockType, isTriggerBlockType } from '@/executor/constants'
import { extractBaseBlockId } from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const logger = createLogger('PathConstructor')
export class PathConstructor {
execute(
workflow: SerializedWorkflow,
triggerBlockId?: string,
includeAllBlocks?: boolean
): Set<string> {
// For run-from-block mode, include all enabled blocks regardless of trigger reachability
if (includeAllBlocks) {
return this.getAllEnabledBlocks(workflow)
}
const resolvedTriggerId = this.findTriggerBlock(workflow, triggerBlockId)
if (!resolvedTriggerId) {
logger.warn('No trigger block found, including all enabled blocks as fallback')
return this.getAllEnabledBlocks(workflow)
}
const adjacency = this.buildAdjacencyMap(workflow)
const reachable = this.performBFS(resolvedTriggerId, adjacency)
return reachable
}
private findTriggerBlock(
workflow: SerializedWorkflow,
triggerBlockId?: string
): string | undefined {
if (triggerBlockId) {
const block = workflow.blocks.find((b) => b.id === triggerBlockId)
if (block) {
if (!block.enabled) {
logger.error('Provided triggerBlockId is disabled, finding alternative', {
triggerBlockId,
blockEnabled: block.enabled,
})
// Try to find an alternative enabled trigger instead of failing
const alternativeTrigger = this.findExplicitTrigger(workflow)
if (alternativeTrigger) {
logger.info('Using alternative enabled trigger', {
disabledTriggerId: triggerBlockId,
alternativeTriggerId: alternativeTrigger,
})
return alternativeTrigger
}
throw new Error(
`Trigger block ${triggerBlockId} is disabled and no alternative enabled trigger found`
)
}
return triggerBlockId
}
const fallbackTriggerId = this.resolveResumeTriggerFallback(triggerBlockId, workflow)
if (fallbackTriggerId) {
return fallbackTriggerId
}
logger.error('Provided triggerBlockId not found in workflow', {
triggerBlockId,
availableBlocks: workflow.blocks.map((b) => ({ id: b.id, type: b.metadata?.id })),
})
throw new Error(`Trigger block not found: ${triggerBlockId}`)
}
const explicitTrigger = this.findExplicitTrigger(workflow)
if (explicitTrigger) {
return explicitTrigger
}
const rootBlock = this.findRootBlock(workflow)
if (rootBlock) {
return rootBlock
}
return undefined
}
private findExplicitTrigger(workflow: SerializedWorkflow): string | undefined {
for (const block of workflow.blocks) {
if (block.enabled && this.isTriggerBlock(block)) {
return block.id
}
}
return undefined
}
private findRootBlock(workflow: SerializedWorkflow): string | undefined {
const hasIncoming = new Set(workflow.connections.map((c) => c.target))
for (const block of workflow.blocks) {
if (
!hasIncoming.has(block.id) &&
block.enabled &&
!isMetadataOnlyBlockType(block.metadata?.id)
) {
return block.id
}
}
return undefined
}
private isTriggerBlock(block: SerializedBlock): boolean {
return isTriggerBlockType(block.metadata?.id)
}
private getAllEnabledBlocks(workflow: SerializedWorkflow): Set<string> {
return new Set(workflow.blocks.filter((b) => b.enabled).map((b) => b.id))
}
private buildAdjacencyMap(workflow: SerializedWorkflow): Map<string, string[]> {
const adjacency = new Map<string, string[]>()
const enabledBlocks = new Set(workflow.blocks.filter((b) => b.enabled).map((b) => b.id))
for (const connection of workflow.connections) {
if (!enabledBlocks.has(connection.source) || !enabledBlocks.has(connection.target)) {
continue
}
const neighbors = adjacency.get(connection.source) ?? []
neighbors.push(connection.target)
adjacency.set(connection.source, neighbors)
}
return adjacency
}
private performBFS(triggerBlockId: string, adjacency: Map<string, string[]>): Set<string> {
const reachable = new Set<string>([triggerBlockId])
const queue = [triggerBlockId]
while (queue.length > 0) {
const currentBlockId = queue.shift()
if (!currentBlockId) break
const neighbors = adjacency.get(currentBlockId) ?? []
for (const neighborId of neighbors) {
if (!reachable.has(neighborId)) {
reachable.add(neighborId)
queue.push(neighborId)
}
}
}
return reachable
}
private resolveResumeTriggerFallback(
triggerBlockId: string,
workflow: SerializedWorkflow
): string | undefined {
if (!triggerBlockId.endsWith('__trigger')) {
return undefined
}
const baseId = triggerBlockId.replace(/__trigger$/, '')
const normalizedBaseId = extractBaseBlockId(baseId)
const candidates = baseId === normalizedBaseId ? [baseId] : [baseId, normalizedBaseId]
for (const candidate of candidates) {
const block = workflow.blocks.find((b) => b.id === candidate)
if (block) {
return candidate
}
}
return undefined
}
}
@@ -0,0 +1,38 @@
import type { BlockType, SentinelType } from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import type { SentinelSubflowType } from '@/executor/dag/types'
interface SubflowSentinelNodeConfig {
id: string
subflowId: string
subflowType: SentinelSubflowType
sentinelType: SentinelType
blockType: BlockType
name: string
}
export function createSubflowSentinelNode(config: SubflowSentinelNodeConfig): DAGNode {
return {
id: config.id,
block: {
id: config.id,
enabled: true,
position: { x: 0, y: 0 },
metadata: {
id: config.blockType,
name: config.name,
},
config: { tool: config.blockType, params: {} },
inputs: {},
outputs: {},
},
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
isSentinel: true,
sentinelType: config.sentinelType,
subflowId: config.subflowId,
subflowType: config.subflowType,
},
}
}