import { createLogger, type Logger } from '@sim/logger' import { normalizeStringArray } from '@/lib/core/utils/arrays' import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records' import { collectUserFileKeys } from '@/lib/core/utils/user-file' import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys' import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value' import { StartBlockPath } from '@/lib/workflows/triggers/triggers' import type { DAG } from '@/executor/dag/builder' import { DAGBuilder } from '@/executor/dag/builder' import { BlockExecutor } from '@/executor/execution/block-executor' import { EdgeManager } from '@/executor/execution/edge-manager' import { ExecutionEngine } from '@/executor/execution/engine' import { ExecutionState } from '@/executor/execution/state' import type { ContextExtensions, SerializableExecutionState, WorkflowInput, } from '@/executor/execution/types' import { createBlockHandlers } from '@/executor/handlers/registry' import { LoopOrchestrator } from '@/executor/orchestrators/loop' import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import { ParallelOrchestrator } from '@/executor/orchestrators/parallel' import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types' import { type ClonedSubflowInfo, ParallelExpander } from '@/executor/utils/parallel-expansion' import { computeExecutionSets, type RunFromBlockContext, resolveContainerToSentinelStart, validateRunFromBlock, } from '@/executor/utils/run-from-block' import { buildResolutionFromBlock, buildStartBlockOutput, resolveExecutorStartBlock, } from '@/executor/utils/start-block' import { extractLoopIdFromSentinel, extractParallelIdFromSentinel, stripCloneSuffixes, stripOuterBranchSuffix, } from '@/executor/utils/subflow-utils' import { VariableResolver } from '@/executor/variables/resolver' import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' import type { SerializedWorkflow } from '@/serializer/types' import type { SubflowType } from '@/stores/workflows/workflow/types' const logger = createLogger('DAGExecutor') interface RestoredClonedSubflowInfo extends ClonedSubflowInfo { parentParallelId: string } export interface DAGExecutorOptions { workflow: SerializedWorkflow envVarValues?: Record workflowInput?: WorkflowInput workflowVariables?: Record contextExtensions?: ContextExtensions } export class DAGExecutor { private workflow: SerializedWorkflow private environmentVariables: Record private workflowInput: WorkflowInput private workflowVariables: Record private contextExtensions: ContextExtensions private dagBuilder: DAGBuilder private execLogger: Logger constructor(options: DAGExecutorOptions) { this.workflow = options.workflow this.environmentVariables = normalizeStringRecord(options.envVarValues) this.workflowInput = options.workflowInput ?? {} this.workflowVariables = normalizeWorkflowVariables(options.workflowVariables) this.contextExtensions = options.contextExtensions ?? {} this.dagBuilder = new DAGBuilder() this.execLogger = logger.withMetadata({ workflowId: this.contextExtensions.metadata?.workflowId, workspaceId: this.contextExtensions.workspaceId, executionId: this.contextExtensions.executionId, userId: this.contextExtensions.userId, requestId: this.contextExtensions.metadata?.requestId, }) } async execute(workflowId: string, triggerBlockId?: string): Promise { const savedIncomingEdges = this.contextExtensions.dagIncomingEdges const dag = this.dagBuilder.build(this.workflow, { triggerBlockId, savedIncomingEdges, includeAllBlocks: this.contextExtensions.resumeFromSnapshot === true, }) const restoredClonedSubflows = this.restoreSnapshotParallelBatches( dag, this.contextExtensions.snapshotState ) this.restoreSavedIncomingEdges(dag, savedIncomingEdges) const { context, state } = this.createExecutionContext(workflowId, triggerBlockId) context.subflowParentMap = this.buildSubflowParentMap(dag) this.registerRestoredClonedSubflows(context.subflowParentMap, restoredClonedSubflows) const engine = this.buildExecutionPipeline(context, dag, state) return await engine.run(triggerBlockId) } async continueExecution( _pendingBlocks: string[], context: ExecutionContext ): Promise { this.execLogger.warn( 'Debug mode (continueExecution) is not yet implemented in the refactored executor' ) return { success: false, output: {}, logs: context.blockLogs ?? [], error: 'Debug mode is not yet supported in the refactored executor', metadata: { duration: 0, startTime: new Date().toISOString(), }, } } /** * Execute from a specific block using cached outputs for upstream blocks. */ async executeFromBlock( workflowId: string, startBlockId: string, sourceSnapshot: SerializableExecutionState ): Promise { // Build full DAG with all blocks to compute upstream set for snapshot filtering // includeAllBlocks is needed because the startBlockId might be a trigger not reachable from the main trigger const dag = this.dagBuilder.build(this.workflow, { includeAllBlocks: true }) const executedBlocks = new Set(sourceSnapshot.executedBlocks) const validation = validateRunFromBlock(startBlockId, dag, executedBlocks) if (!validation.valid) { throw new Error(validation.error) } const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, startBlockId) const effectiveStartBlockId = resolveContainerToSentinelStart(startBlockId, dag) ?? startBlockId // Extract container IDs from sentinel IDs in reachable upstream set // Use reachableUpstreamSet (not upstreamSet) to preserve sibling branch outputs // Example: A->C, B->C where C references A.result || B.result // When running from A, B's output should be preserved for C to reference const reachableContainerIds = new Set() for (const nodeId of reachableUpstreamSet) { const loopId = extractLoopIdFromSentinel(nodeId) if (loopId) reachableContainerIds.add(loopId) const parallelId = extractParallelIdFromSentinel(nodeId) if (parallelId) reachableContainerIds.add(parallelId) } // Filter snapshot to include all blocks reachable from dirty blocks // This preserves sibling branch outputs that dirty blocks may reference const filteredBlockStates: Record = {} for (const [blockId, state] of Object.entries(sourceSnapshot.blockStates)) { const aliasBaseId = stripOuterBranchSuffix(blockId) const isReachableOuterBranchAlias = aliasBaseId !== blockId && Array.from(reachableUpstreamSet).some( (reachableId) => stripCloneSuffixes(reachableId) === aliasBaseId ) if ( reachableUpstreamSet.has(blockId) || reachableContainerIds.has(blockId) || isReachableOuterBranchAlias ) { filteredBlockStates[blockId] = state } } const filteredExecutedBlocks = sourceSnapshot.executedBlocks.filter((id) => { const aliasBaseId = stripOuterBranchSuffix(id) const isReachableOuterBranchAlias = aliasBaseId !== id && Array.from(reachableUpstreamSet).some( (reachableId) => stripCloneSuffixes(reachableId) === aliasBaseId ) return ( reachableUpstreamSet.has(id) || reachableContainerIds.has(id) || isReachableOuterBranchAlias ) }) // Filter loop/parallel executions to only include reachable containers const filteredLoopExecutions: Record = {} if (sourceSnapshot.loopExecutions) { for (const [loopId, execution] of Object.entries(sourceSnapshot.loopExecutions)) { if (reachableContainerIds.has(loopId)) { filteredLoopExecutions[loopId] = execution } } } const filteredParallelExecutions: Record = {} if (sourceSnapshot.parallelExecutions) { for (const [parallelId, execution] of Object.entries(sourceSnapshot.parallelExecutions)) { if (reachableContainerIds.has(parallelId)) { filteredParallelExecutions[parallelId] = execution } } } const filteredSnapshot: SerializableExecutionState = { ...sourceSnapshot, blockStates: filteredBlockStates, executedBlocks: filteredExecutedBlocks, loopExecutions: filteredLoopExecutions, parallelExecutions: filteredParallelExecutions, } this.execLogger.info('Executing from block', { workflowId, startBlockId, effectiveStartBlockId, dirtySetSize: dirtySet.size, upstreamSetSize: upstreamSet.size, reachableUpstreamSetSize: reachableUpstreamSet.size, }) // Remove incoming edges from non-dirty sources so convergent blocks don't wait for cached upstream for (const nodeId of dirtySet) { const node = dag.nodes.get(nodeId) if (!node) continue const nonDirtyIncoming: string[] = [] for (const sourceId of node.incomingEdges) { if (!dirtySet.has(sourceId)) { nonDirtyIncoming.push(sourceId) } } for (const sourceId of nonDirtyIncoming) { node.incomingEdges.delete(sourceId) } } const runFromBlockContext = { startBlockId: effectiveStartBlockId, dirtySet } const { context, state } = this.createExecutionContext(workflowId, undefined, { snapshotState: filteredSnapshot, runFromBlockContext, }) const filteredLargeValueKeys = collectLargeValueKeys({ blockStates: filteredBlockStates, loopExecutions: filteredLoopExecutions, parallelExecutions: filteredParallelExecutions, }) mergeLargeValueKeys(context, filteredLargeValueKeys) const filteredFileKeys = collectUserFileKeys({ blockStates: filteredBlockStates, loopExecutions: filteredLoopExecutions, parallelExecutions: filteredParallelExecutions, }) mergeFileKeys(context, filteredFileKeys) context.subflowParentMap = this.buildSubflowParentMap(dag) const engine = this.buildExecutionPipeline(context, dag, state, filteredSnapshot) const result = await engine.run() if (result.metadata) { result.metadata.largeValueKeys = context.largeValueKeys result.metadata.fileKeys = context.fileKeys } return result } private restoreSavedIncomingEdges(dag: DAG, savedIncomingEdges?: Record): void { if (!savedIncomingEdges) return for (const [nodeId, incomingEdges] of Object.entries(savedIncomingEdges)) { const node = dag.nodes.get(nodeId) if (node) { node.incomingEdges = new Set(incomingEdges) } } } private restoreSnapshotParallelBatches( dag: DAG, snapshotState?: SerializableExecutionState ): RestoredClonedSubflowInfo[] { if (!snapshotState?.parallelExecutions) return [] const expander = new ParallelExpander() const clonedSubflows: RestoredClonedSubflowInfo[] = [] for (const [parallelId, scope] of Object.entries(snapshotState.parallelExecutions)) { const currentBatchSize = Number(scope.currentBatchSize ?? 0) if (!Number.isFinite(currentBatchSize) || currentBatchSize <= 0) continue const currentBatchStart = Number(scope.currentBatchStart ?? 0) const totalBranches = Number(scope.totalBranches ?? currentBatchStart + currentBatchSize) const items = Array.isArray(scope.items) ? scope.items.slice(currentBatchStart, currentBatchStart + currentBatchSize) : undefined const restoredBatch = expander.expandParallel(dag, parallelId, currentBatchSize, items, { branchIndexOffset: currentBatchStart, totalBranches, }) clonedSubflows.push( ...restoredBatch.clonedSubflows.map((clone) => ({ ...clone, parentParallelId: parallelId, })) ) } return clonedSubflows } private registerRestoredClonedSubflows( parentMap: Map, clonedSubflows: RestoredClonedSubflowInfo[] ): void { const branchCloneMaps = new Map>>() for (const clone of clonedSubflows) { let parallelBranchMaps = branchCloneMaps.get(clone.parentParallelId) if (!parallelBranchMaps) { parallelBranchMaps = new Map() branchCloneMaps.set(clone.parentParallelId, parallelBranchMaps) } let cloneMap = parallelBranchMaps.get(clone.outerBranchIndex) if (!cloneMap) { cloneMap = new Map() parallelBranchMaps.set(clone.outerBranchIndex, cloneMap) } cloneMap.set(clone.originalId, clone.clonedId) } for (const clone of clonedSubflows) { const originalEntry = parentMap.get(clone.originalId) const cloneMap = branchCloneMaps.get(clone.parentParallelId)?.get(clone.outerBranchIndex) const clonedParentId = originalEntry ? cloneMap?.get(originalEntry.parentId) : undefined parentMap.set(clone.clonedId, { parentId: clonedParentId ?? clone.parentParallelId, parentType: clonedParentId && originalEntry ? originalEntry.parentType : 'parallel', branchIndex: clonedParentId ? 0 : clone.outerBranchIndex, }) } } private buildExecutionPipeline( context: ExecutionContext, dag: DAG, state: ExecutionState, snapshotState = this.contextExtensions.snapshotState ) { const resolver = new VariableResolver(this.workflow, this.workflowVariables, state, { navigatePathAsync, }) const allHandlers = createBlockHandlers() const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state) const edgeManager = new EdgeManager(dag) const loopOrchestrator = new LoopOrchestrator( dag, state, resolver, this.contextExtensions, edgeManager ) const parallelOrchestrator = new ParallelOrchestrator( dag, state, resolver, this.contextExtensions, edgeManager ) edgeManager.restoreDeactivatedEdges( snapshotState?.deactivatedEdges, snapshotState?.nodesWithActivatedEdge ) const nodeOrchestrator = new NodeExecutionOrchestrator( dag, state, blockExecutor, loopOrchestrator, parallelOrchestrator ) return new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) } private createExecutionContext( workflowId: string, triggerBlockId?: string, overrides?: { snapshotState?: SerializableExecutionState runFromBlockContext?: RunFromBlockContext } ): { context: ExecutionContext; state: ExecutionState } { const snapshotState = overrides?.snapshotState ?? this.contextExtensions.snapshotState const blockStates = snapshotState?.blockStates ? new Map(Object.entries(snapshotState.blockStates)) : new Map() let executedBlocks = snapshotState?.executedBlocks ? new Set(snapshotState.executedBlocks) : new Set() if (overrides?.runFromBlockContext) { const { dirtySet } = overrides.runFromBlockContext executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id))) this.execLogger.info('Cleared executed status for dirty blocks', { dirtySetSize: dirtySet.size, remainingExecutedBlocks: executedBlocks.size, }) } const state = new ExecutionState(blockStates, executedBlocks) const context: ExecutionContext = { workflowId, workspaceId: this.contextExtensions.workspaceId, executionId: this.contextExtensions.executionId, largeValueExecutionIds: this.contextExtensions.largeValueExecutionIds, largeValueKeys: this.contextExtensions.largeValueKeys, fileKeys: this.contextExtensions.fileKeys, allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope, userId: this.contextExtensions.userId, isDeployedContext: this.contextExtensions.isDeployedContext, enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess, piiBlockOutputRedaction: this.contextExtensions.piiBlockOutputRedaction, blockStates: state.getBlockStates(), blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []), metadata: { ...this.contextExtensions.metadata, startTime: new Date().toISOString(), duration: 0, useDraftState: this.contextExtensions.metadata?.useDraftState ?? this.contextExtensions.isDeployedContext !== true, }, environmentVariables: this.environmentVariables, workflowVariables: this.workflowVariables, decisions: { router: snapshotState?.decisions?.router ? new Map(Object.entries(snapshotState.decisions.router)) : new Map(), condition: snapshotState?.decisions?.condition ? new Map(Object.entries(snapshotState.decisions.condition)) : new Map(), }, completedLoops: snapshotState?.completedLoops ? new Set(snapshotState.completedLoops) : new Set(), loopExecutions: snapshotState?.loopExecutions ? new Map( Object.entries(snapshotState.loopExecutions).map(([loopId, scope]) => [ loopId, { ...scope, currentIterationOutputs: scope.currentIterationOutputs ? new Map(Object.entries(scope.currentIterationOutputs)) : new Map(), }, ]) ) : new Map(), parallelExecutions: snapshotState?.parallelExecutions ? new Map( Object.entries(snapshotState.parallelExecutions).map(([parallelId, scope]) => [ parallelId, { ...scope, branchOutputs: scope.branchOutputs ? new Map(Object.entries(scope.branchOutputs).map(([k, v]) => [Number(k), v])) : new Map(), accumulatedOutputs: scope.accumulatedOutputs ? new Map( Object.entries(scope.accumulatedOutputs).map(([k, v]) => [Number(k), v]) ) : new Map(), }, ]) ) : new Map(), parallelBlockMapping: snapshotState?.parallelBlockMapping ? new Map(Object.entries(snapshotState.parallelBlockMapping)) : new Map(), executedBlocks: state.getExecutedBlocks(), activeExecutionPath: snapshotState?.activeExecutionPath ? new Set(snapshotState.activeExecutionPath) : new Set(), workflow: this.workflow, stream: this.contextExtensions.stream ?? false, selectedOutputs: normalizeStringArray(this.contextExtensions.selectedOutputs), edges: this.contextExtensions.edges ?? [], onStream: this.contextExtensions.onStream, onBlockStart: this.contextExtensions.onBlockStart, onBlockComplete: this.contextExtensions.onBlockComplete, onChildWorkflowInstanceReady: this.contextExtensions.onChildWorkflowInstanceReady, abortSignal: this.contextExtensions.abortSignal, childWorkflowContext: this.contextExtensions.childWorkflowContext, includeFileBase64: this.contextExtensions.includeFileBase64, base64MaxBytes: this.contextExtensions.base64MaxBytes, runFromBlockContext: overrides?.runFromBlockContext, stopAfterBlockId: this.contextExtensions.stopAfterBlockId, callChain: this.contextExtensions.callChain, } if (this.contextExtensions.resumeFromSnapshot) { context.metadata.resumeFromSnapshot = true this.execLogger.info('Resume from snapshot enabled', { resumePendingQueue: this.contextExtensions.resumePendingQueue, remainingEdges: this.contextExtensions.remainingEdges, triggerBlockId, }) } if (this.contextExtensions.remainingEdges) { ;(context.metadata as any).remainingEdges = this.contextExtensions.remainingEdges this.execLogger.info('Set remaining edges for resume', { edgeCount: this.contextExtensions.remainingEdges.length, }) } if (this.contextExtensions.resumePendingQueue?.length) { context.metadata.pendingBlocks = [...this.contextExtensions.resumePendingQueue] this.execLogger.info('Set pending blocks from resume queue', { pendingBlocks: context.metadata.pendingBlocks, skipStarterBlockInit: true, }) } else if (overrides?.runFromBlockContext) { // In run-from-block mode, initialize the start block only if it's a regular block // Skip for sentinels/containers (loop/parallel) which aren't real blocks const startBlockId = overrides.runFromBlockContext.startBlockId const isRegularBlock = this.workflow.blocks.some((b) => b.id === startBlockId) if (isRegularBlock) { this.initializeStarterBlock(context, state, startBlockId) } } else { this.initializeStarterBlock(context, state, triggerBlockId) } return { context, state } } /** * Builds a unified child-subflow → parent-subflow mapping that covers all nesting * combinations: loop-in-loop, parallel-in-parallel, loop-in-parallel, parallel-in-loop. * Used by the iteration context builder to walk the full ancestor chain for SSE events. */ private buildSubflowParentMap( dag: DAG ): Map { const parentMap = new Map< string, { parentId: string; parentType: SubflowType; branchIndex?: number } >() // Scan loop configs: children can be loops or parallels for (const [loopId, config] of dag.loopConfigs) { for (const nodeId of config.nodes) { if (dag.loopConfigs.has(nodeId) || dag.parallelConfigs.has(nodeId)) { parentMap.set(nodeId, { parentId: loopId, parentType: 'loop' }) } } } // Scan parallel configs: children can be parallels or loops for (const [parallelId, config] of dag.parallelConfigs) { for (const nodeId of config.nodes ?? []) { if (dag.parallelConfigs.has(nodeId) || dag.loopConfigs.has(nodeId)) { parentMap.set(nodeId, { parentId: parallelId, parentType: 'parallel', branchIndex: 0 }) } } } return parentMap } private initializeStarterBlock( context: ExecutionContext, state: ExecutionState, triggerBlockId?: string ): void { let startResolution: ReturnType | null = null if (triggerBlockId) { const triggerBlock = this.workflow.blocks.find((b) => b.id === triggerBlockId) if (!triggerBlock) { this.execLogger.error('Specified trigger block not found in workflow', { triggerBlockId, }) throw new Error(`Trigger block not found: ${triggerBlockId}`) } startResolution = buildResolutionFromBlock(triggerBlock) if (!startResolution) { startResolution = { blockId: triggerBlock.id, block: triggerBlock, path: StartBlockPath.SPLIT_MANUAL, } } } else { startResolution = resolveExecutorStartBlock(this.workflow.blocks, { execution: 'manual', isChildWorkflow: false, }) if (!startResolution?.block) { this.execLogger.warn('No start block found in workflow') return } } if (state.getBlockStates().has(startResolution.block.id)) { return } const blockOutput = buildStartBlockOutput({ resolution: startResolution, workflowInput: this.workflowInput, }) state.setBlockState(startResolution.block.id, { output: blockOutput, executed: false, executionTime: 0, }) } }