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
@@ -0,0 +1,382 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { BlockType } from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { BlockExecutor } from '@/executor/execution/block-executor'
import { ExecutionState } from '@/executor/execution/state'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
validateBlockType: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createBlock(): SerializedBlock {
return {
id: 'function-block-1',
metadata: { id: BlockType.FUNCTION, name: 'Function' },
position: { x: 0, y: 0 },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createContext(state: ExecutionState): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: state.getBlockStates(),
blockLogs: [],
metadata: { requestId: 'request-1', duration: 0 },
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
} as ExecutionContext
}
function createNode(block: SerializedBlock): DAGNode {
return {
id: block.id,
block,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {},
}
}
describe('BlockExecutor', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('persists function output arrays as manifests in execution state', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
result: Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
payload: 'x'.repeat(100),
})),
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
await executor.execute(createContext(state), createNode(block), block)
const storedOutput = state.getBlockOutput(block.id)
expect(isLargeArrayManifest(storedOutput?.result)).toBe(true)
expect(storedOutput?.result).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: output.result.length,
})
})
it('persists stable outer-branch aliases for completed parallel branch outputs', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'branch-2' }
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const node = createNode(block)
node.id = 'function-block-1₍0₎'
node.metadata = {
isParallelBranch: true,
subflowId: 'parallel-1',
subflowType: 'parallel',
originalBlockId: block.id,
branchIndex: 2,
}
await executor.execute(createContext(state), node, block)
expect(state.getBlockOutput('function-block-1__obranch-2')).toEqual(output)
expect(state.getBlockOutput('function-block-1₍2₎')).toEqual(output)
expect(state.getBlockOutput('function-block-1₍0₎')).toEqual(output)
})
it('does not write global aliases for parallel branches inside cloned outer branches', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'outer-2-inner-0' }
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const node = createNode(block)
node.id = 'function-block-1__cloneabc__obranch-2₍0₎'
node.metadata = {
isParallelBranch: true,
subflowId: 'inner-parallel',
subflowType: 'parallel',
originalBlockId: block.id,
branchIndex: 0,
}
await executor.execute(createContext(state), node, block)
expect(state.getBlockOutput(node.id)).toEqual(output)
expect(state.getBlockOutput('function-block-1__obranch-0')).toBeUndefined()
expect(state.getBlockOutput('function-block-1₍0₎')).toBeUndefined()
})
it('does not let block completion callbacks overtake pending start callbacks', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'done' }
const execute = vi.fn(async () => {
events.push('execute')
return output
})
const handler: BlockHandler = {
canHandle: () => true,
execute,
}
const events: string[] = []
let resolveStart!: () => void
const startGate = new Promise<void>((resolve) => {
resolveStart = resolve
})
const onBlockStart = vi.fn(async () => {
events.push('start-called')
await startGate
events.push('start-done')
})
const onBlockComplete = vi.fn(async () => {
events.push('complete')
})
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart,
onBlockComplete,
},
state
)
const execution = executor.execute(createContext(state), createNode(block), block)
expect(onBlockStart).toHaveBeenCalled()
expect(execute).not.toHaveBeenCalled()
expect(onBlockComplete).not.toHaveBeenCalled()
resolveStart()
await execution
await vi.waitFor(() => {
expect(onBlockComplete).toHaveBeenCalled()
})
expect(events).toEqual(['start-called', 'start-done', 'execute', 'complete'])
})
it('fires block completion callbacks for pausing blocks so clients receive pause output', async () => {
const block = {
...createBlock(),
id: 'hitl-block-1',
metadata: { id: BlockType.HUMAN_IN_THE_LOOP, name: 'Human in the Loop' },
config: { tool: BlockType.HUMAN_IN_THE_LOOP, params: {} },
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
response: { status: 'paused' },
_pauseMetadata: {
contextId: 'pause-context-1',
blockId: block.id,
response: { status: 'paused' },
timestamp: new Date().toISOString(),
pauseKind: 'human' as const,
},
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const onBlockStart = vi.fn(async () => {})
const onBlockComplete = vi.fn(async () => {})
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart,
onBlockComplete,
},
state
)
await executor.execute(createContext(state), createNode(block), block)
expect(onBlockStart).toHaveBeenCalled()
expect(onBlockComplete).toHaveBeenCalledWith(
block.id,
'Human in the Loop',
BlockType.HUMAN_IN_THE_LOOP,
expect.objectContaining({
output: expect.objectContaining({
response: { status: 'paused' },
}),
}),
undefined,
undefined
)
expect(state.getBlockOutput(block.id)).toEqual(output)
})
})
@@ -0,0 +1,931 @@
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { normalizeStringArray } from '@/lib/core/utils/arrays'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values'
import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
import {
containsUserFileWithMetadata,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
buildResumeApiUrl,
buildResumeUiUrl,
DEFAULTS,
EDGE,
isSentinelBlockType,
} from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import type {
BlockStateWriter,
ContextExtensions,
WorkflowNodeMetadata,
} from '@/executor/execution/types'
import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils.ts'
import {
type BlockHandler,
type BlockLog,
type BlockState,
type ExecutionContext,
getNextExecutionOrder,
type NormalizedBlockOutput,
type StreamingExecution,
} from '@/executor/types'
import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import {
buildUnifiedParentIterations,
getIterationContext,
} from '@/executor/utils/iteration-context'
import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter'
import {
buildBranchNodeId,
buildOuterBranchScopedId,
extractOuterBranchIndex,
} from '@/executor/utils/subflow-utils'
import {
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
type VariableResolver,
} from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('BlockExecutor')
export class BlockExecutor {
private execLogger: Logger
constructor(
private blockHandlers: BlockHandler[],
private resolver: VariableResolver,
private contextExtensions: ContextExtensions,
private state: BlockStateWriter
) {
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(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock
): Promise<NormalizedBlockOutput> {
const handler = this.findHandler(block)
if (!handler) {
throw buildBlockExecutionError({
block,
context: ctx,
error: `No handler found for block type: ${block.metadata?.id ?? 'unknown'}`,
})
}
const blockType = block.metadata?.id ?? ''
const isSentinel = isSentinelBlockType(blockType)
// Capture startedAt and startTime at the same synchronous instant so
// blockLog.startedAt and performance.now()-derived durationMs share a
// single reference point. Any executor work below counts toward this block.
const startedAt = new Date().toISOString()
const startTime = performance.now()
let blockLog: BlockLog | undefined
let blockStartPromise: Promise<void> | undefined
if (!isSentinel) {
blockLog = this.createBlockLog(ctx, node.id, block, node, startedAt)
ctx.blockLogs.push(blockLog)
blockStartPromise = this.fireBlockStartCallback(ctx, node, block, blockLog.executionOrder)
await blockStartPromise
}
let resolvedInputs: Record<string, any> = {}
let inputsForLog: Record<string, any> = {}
const nodeMetadata = {
...this.buildNodeMetadata(node),
executionOrder: blockLog?.executionOrder,
}
let cleanupSelfReference: (() => void) | undefined
if (block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP) {
cleanupSelfReference = this.preparePauseResumeSelfReference(ctx, node, block, nodeMetadata)
}
try {
if (!isSentinel && blockType) {
await validateBlockType(ctx.userId, ctx.workspaceId, blockType, ctx)
}
if (block.metadata?.id === BlockType.FUNCTION) {
const {
resolvedInputs: fnInputs,
displayInputs,
contextVariables,
} = await this.resolver.resolveInputsForFunctionBlock(
ctx,
node.id,
block.config.params,
block
)
resolvedInputs = {
...fnInputs,
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables,
...(displayInputs.code !== undefined
? { [FUNCTION_BLOCK_DISPLAY_CODE_KEY]: displayInputs.code }
: {}),
}
inputsForLog = displayInputs
} else {
resolvedInputs = await this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
inputsForLog = resolvedInputs
}
if (blockLog) {
blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id)
}
} catch (error) {
cleanupSelfReference?.()
return await this.handleBlockError(
error,
ctx,
node,
block,
blockStartPromise,
startTime,
blockLog,
inputsForLog,
isSentinel,
'input_resolution'
)
}
cleanupSelfReference?.()
try {
const output = handler.executeWithNode
? await handler.executeWithNode(ctx, block, resolvedInputs, nodeMetadata)
: await handler.execute(ctx, block, resolvedInputs)
const isStreamingExecution =
output && typeof output === 'object' && 'stream' in output && 'execution' in output
let normalizedOutput: NormalizedBlockOutput
if (isStreamingExecution) {
const streamingExec = output as StreamingExecution
// The stream must still be drained to populate `execution.output`, but
// forwarding raw chunks to the client (or persisting them to memory)
// before redaction would leak PII. When block-output redaction is on we
// drain in buffer-only mode (no `onStream`, content masked before it's
// stored); the masked final output reaches the client via block-complete.
if (ctx.onStream) {
await this.handleStreamingExecution(
ctx,
node,
block,
streamingExec,
resolvedInputs,
normalizeStringArray(ctx.selectedOutputs),
!ctx.piiBlockOutputRedaction?.enabled
)
}
normalizedOutput = this.normalizeOutput(
streamingExec.execution.output ?? streamingExec.execution
)
} else {
normalizedOutput = this.normalizeOutput(output)
}
if (ctx.includeFileBase64 === true && containsUserFileWithMetadata(normalizedOutput)) {
normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, {
requestId: ctx.metadata.requestId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
maxBytes: ctx.base64MaxBytes,
preserveLargeValueMetadata: true,
})) as NormalizedBlockOutput
}
if (ctx.piiBlockOutputRedaction?.enabled) {
// In-flight redaction before the log/state split below, so both the
// downstream state copy and the persisted log copy are masked.
// `onFailure: 'throw'` aborts the run rather than feeding corrupted/leaked
// data downstream.
const redactionOptions = {
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
language: ctx.piiBlockOutputRedaction.language,
onFailure: 'throw' as const,
}
// Tools like the function executor offload large outputs to large-value
// refs BEFORE they reach here, and the string walk treats a ref as opaque.
// So hydrate → mask → re-store any refs first, then mask inline strings —
// otherwise PII inside an offloaded output is never redacted.
normalizedOutput = await redactLargeValueRefsInValue(normalizedOutput, {
...redactionOptions,
store: {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
},
})
normalizedOutput = await redactObjectStrings(normalizedOutput, redactionOptions)
}
normalizedOutput = (await compactExecutionPayload(normalizedOutput, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
preserveUserFileBase64: ctx.includeFileBase64 === true,
requireDurable: true,
})) as NormalizedBlockOutput
const endedAt = new Date().toISOString()
const duration = performance.now() - startTime
if (blockLog) {
blockLog.endedAt = endedAt
blockLog.durationMs = duration
blockLog.success = true
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
}
}
const { childTraceSpans: _traces, ...outputForState } = normalizedOutput
this.setNodeOutput(node, outputForState as NormalizedBlockOutput, duration)
if (!isSentinel && blockLog) {
const childWorkflowInstanceId =
typeof normalizedOutput._childWorkflowInstanceId === 'string'
? normalizedOutput._childWorkflowInstanceId
: undefined
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
block,
})
this.fireBlockCompleteCallback(
blockStartPromise,
ctx,
node,
block,
this.sanitizeInputsForLog(inputsForLog, block.metadata?.id),
displayOutput,
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt,
childWorkflowInstanceId
)
}
return outputForState as NormalizedBlockOutput
} catch (error) {
return await this.handleBlockError(
error,
ctx,
node,
block,
blockStartPromise,
startTime,
blockLog,
inputsForLog,
isSentinel,
'execution'
)
}
}
private buildNodeMetadata(node: DAGNode): WorkflowNodeMetadata {
const metadata = node?.metadata ?? {}
return {
nodeId: node.id,
loopId: metadata.subflowType === 'loop' ? metadata.subflowId : undefined,
parallelId: metadata.subflowType === 'parallel' ? metadata.subflowId : undefined,
subflowId: metadata.subflowId,
subflowType: metadata.subflowType,
branchIndex: metadata.branchIndex,
branchTotal: metadata.branchTotal,
originalBlockId: metadata.originalBlockId,
isLoopNode: metadata.isLoopNode,
}
}
private setNodeOutput(node: DAGNode, output: NormalizedBlockOutput, duration = 0): void {
this.state.setBlockOutput(node.id, output, duration)
const originalBlockId = node.metadata.originalBlockId
const branchIndex = node.metadata.branchIndex
if (
node.metadata.isParallelBranch &&
originalBlockId &&
branchIndex !== undefined &&
extractOuterBranchIndex(node.id) === undefined
) {
const globalBranchNodeId = buildBranchNodeId(originalBlockId, branchIndex)
if (globalBranchNodeId !== node.id) {
this.state.setBlockOutput(globalBranchNodeId, output, duration)
}
this.state.setBlockOutput(
buildOuterBranchScopedId(originalBlockId, branchIndex),
output,
duration
)
}
}
private findHandler(block: SerializedBlock): BlockHandler | undefined {
return this.blockHandlers.find((h) => h.canHandle(block))
}
private async handleBlockError(
error: unknown,
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
blockStartPromise: Promise<void> | undefined,
startTime: number,
blockLog: BlockLog | undefined,
inputsForLog: Record<string, any>,
isSentinel: boolean,
phase: 'input_resolution' | 'execution'
): Promise<NormalizedBlockOutput> {
const endedAt = new Date().toISOString()
const duration = performance.now() - startTime
const errorMessage = normalizeError(error)
const hasLogInputs =
inputsForLog && typeof inputsForLog === 'object' && Object.keys(inputsForLog).length > 0
const input = hasLogInputs
? inputsForLog
: ((block.config?.params as Record<string, any> | undefined) ?? {})
const errorOutput: NormalizedBlockOutput = {
error: errorMessage,
}
if (ChildWorkflowError.isChildWorkflowError(error)) {
errorOutput.childWorkflowName = error.childWorkflowName
if (error.childWorkflowSnapshotId) {
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
}
}
this.setNodeOutput(node, errorOutput, duration)
if (blockLog) {
blockLog.endedAt = endedAt
blockLog.durationMs = duration
blockLog.success = false
blockLog.error = errorMessage
blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id)
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) {
blockLog.childTraceSpans = error.childTraceSpans
}
}
this.execLogger.error(
phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed',
{
blockId: node.id,
blockType: block.metadata?.id,
error: errorMessage,
}
)
if (!isSentinel && blockLog) {
const childWorkflowInstanceId = ChildWorkflowError.isChildWorkflowError(error)
? error.childWorkflowInstanceId
: undefined
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
this.fireBlockCompleteCallback(
blockStartPromise,
ctx,
node,
block,
this.sanitizeInputsForLog(input, block.metadata?.id),
displayOutput,
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt,
childWorkflowInstanceId
)
}
const hasErrorPort = this.hasErrorPortEdge(node)
if (hasErrorPort) {
if (blockLog) {
blockLog.errorHandled = true
}
this.execLogger.info('Block has error port - returning error output instead of throwing', {
blockId: node.id,
error: errorMessage,
})
return errorOutput
}
const errorToThrow = error instanceof Error ? error : new Error(errorMessage)
throw buildBlockExecutionError({
block,
error: errorToThrow,
context: ctx,
additionalInfo: {
nodeId: node.id,
executionTime: duration,
},
})
}
private hasErrorPortEdge(node: DAGNode): boolean {
for (const [_, edge] of node.outgoingEdges) {
if (edge.sourceHandle === EDGE.ERROR) {
return true
}
}
return false
}
private createBlockLog(
ctx: ExecutionContext,
blockId: string,
block: SerializedBlock,
node: DAGNode,
startedAt: string
): BlockLog {
let blockName = block.metadata?.name ?? blockId
let loopId: string | undefined
let parallelId: string | undefined
let iterationIndex: number | undefined
if (node?.metadata) {
if (
node.metadata.branchIndex !== undefined &&
node.metadata.subflowType === 'parallel' &&
node.metadata.subflowId
) {
blockName = `${blockName} (iteration ${node.metadata.branchIndex})`
iterationIndex = node.metadata.branchIndex
parallelId = node.metadata.subflowId
} else if (
node.metadata.isLoopNode &&
node.metadata.subflowType === 'loop' &&
node.metadata.subflowId
) {
loopId = node.metadata.subflowId
const loopScope = ctx.loopExecutions?.get(loopId)
if (loopScope && loopScope.iteration !== undefined) {
blockName = `${blockName} (iteration ${loopScope.iteration})`
iterationIndex = loopScope.iteration
} else {
this.execLogger.warn('Loop scope not found for block', { blockId, loopId })
}
}
}
const containerId = parallelId ?? loopId
const parentIterations = containerId
? buildUnifiedParentIterations(ctx, containerId)
: undefined
return {
blockId,
blockName,
blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE,
startedAt,
executionOrder: getNextExecutionOrder(ctx),
endedAt: '',
durationMs: 0,
success: false,
loopId,
parallelId,
iterationIndex,
...(parentIterations?.length && { parentIterations }),
}
}
private normalizeOutput(output: unknown): NormalizedBlockOutput {
if (output === null || output === undefined) {
return {}
}
if (typeof output === 'object' && !Array.isArray(output)) {
return output as NormalizedBlockOutput
}
return { result: output }
}
/**
* Sanitizes inputs for log display.
* - Filters out system fields (UI-only, readonly, internal flags)
* - Removes UI state from inputFormat items (e.g., collapsed)
* - Parses JSON strings to objects for readability
* - Redacts sensitive fields (privateKey, password, tokens, etc.)
* Returns a new object - does not mutate the original inputs.
*/
private sanitizeInputsForLog(
inputs: Record<string, any>,
blockType?: string
): Record<string, any> {
// Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the
// baked `workflowId`/`inputMapping` wrapper is plumbing. Log the mapped input
// field values (the inputMapping contents) instead.
if (isCustomBlockType(blockType)) {
const mapping = inputs.inputMapping
const parsed =
typeof mapping === 'string'
? (() => {
try {
return JSON.parse(mapping)
} catch {
return {}
}
})()
: mapping
inputs = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(inputs)) {
if (
SYSTEM_SUBBLOCK_IDS.includes(key) ||
key === 'triggerMode' ||
key === FUNCTION_BLOCK_CONTEXT_VARS_KEY ||
key === FUNCTION_BLOCK_DISPLAY_CODE_KEY
) {
continue
}
if (key === 'inputFormat' && Array.isArray(value)) {
result[key] = sanitizeInputFormat(value)
continue
}
if (key === 'tools' && Array.isArray(value)) {
result[key] = sanitizeTools(value)
continue
}
// isJSONString is a quick heuristic (checks for { or [), not a validator.
// Invalid JSON is safely caught below - this just avoids JSON.parse on every string.
if (typeof value === 'string' && isJSONString(value)) {
try {
result[key] = JSON.parse(value.trim())
} catch {
// Not valid JSON, keep original string
result[key] = value
}
} else {
result[key] = value
}
}
return redactApiKeys(result)
}
/**
* Fires the `onBlockStart` progress callback before block execution continues.
* Returning the promise lets completion callbacks preserve lifecycle ordering.
*/
private fireBlockStartCallback(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
executionOrder: number
): Promise<void> | undefined {
if (!this.contextExtensions.onBlockStart) return undefined
const blockId = node.metadata?.originalBlockId ?? node.id
const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
const iterationContext = getIterationContext(ctx, node?.metadata)
return this.contextExtensions
.onBlockStart(
blockId,
blockName,
blockType,
executionOrder,
iterationContext,
ctx.childWorkflowContext
)
.catch((error) => {
this.execLogger.warn('Block start callback failed', {
blockId,
blockType,
error: toError(error).message,
})
})
}
/**
* Fires the `onBlockComplete` progress callback without blocking subsequent blocks.
* Completion is chained behind the matching start callback so SSE/log consumers
* never observe `block:completed` before `block:started` for the same execution.
*/
private fireBlockCompleteCallback(
blockStartPromise: Promise<void> | undefined,
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
input: Record<string, any>,
output: NormalizedBlockOutput,
duration: number,
startedAt: string,
executionOrder: number,
endedAt: string,
childWorkflowInstanceId?: string
): void {
if (!this.contextExtensions.onBlockComplete) return
const blockId = node.metadata?.originalBlockId ?? node.id
const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
const iterationContext = getIterationContext(ctx, node?.metadata)
void (async () => {
await blockStartPromise
await this.contextExtensions.onBlockComplete?.(
blockId,
blockName,
blockType,
{
input,
output,
executionTime: duration,
startedAt,
executionOrder,
endedAt,
childWorkflowInstanceId,
},
iterationContext,
ctx.childWorkflowContext
)
})().catch((error) => {
this.execLogger.warn('Block completion callback failed', {
blockId,
blockType,
error: toError(error).message,
})
})
}
private preparePauseResumeSelfReference(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
nodeMetadata: {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
}
): (() => void) | undefined {
const blockId = node.id
const existingState = ctx.blockStates.get(blockId)
if (existingState?.executed) {
return undefined
}
const executionId = ctx.executionId ?? ctx.metadata?.executionId
const workflowId = ctx.workflowId
if (!executionId || !workflowId) {
return undefined
}
const { loopScope } = mapNodeMetadataToPauseScopes(ctx, nodeMetadata)
const contextId = generatePauseContextId(block.id, nodeMetadata, loopScope)
let resumeLinks: { apiUrl: string; uiUrl: string }
try {
const baseUrl = getBaseUrl()
resumeLinks = {
apiUrl: buildResumeApiUrl(baseUrl, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(baseUrl, workflowId, executionId),
}
} catch {
resumeLinks = {
apiUrl: buildResumeApiUrl(undefined, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(undefined, workflowId, executionId),
}
}
let previousState: BlockState | undefined
if (existingState) {
previousState = { ...existingState }
}
const hadPrevious = existingState !== undefined
const placeholderState: BlockState = {
output: {
url: resumeLinks.uiUrl,
resumeEndpoint: resumeLinks.apiUrl,
},
executed: false,
executionTime: existingState?.executionTime ?? 0,
}
this.state.setBlockState(blockId, placeholderState)
return () => {
if (hadPrevious && previousState) {
this.state.setBlockState(blockId, previousState)
} else {
this.state.deleteBlockState(blockId)
}
}
}
private async handleStreamingExecution(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
streamingExec: StreamingExecution,
resolvedInputs: Record<string, any>,
selectedOutputs: string[],
forwardToClient = true
): Promise<void> {
const blockId = node.id
const responseFormat =
resolvedInputs?.responseFormat ??
(block.config?.params as Record<string, any> | undefined)?.responseFormat ??
(block.config as Record<string, any> | undefined)?.responseFormat
const sourceReader = streamingExec.stream.getReader()
const decoder = new TextDecoder()
const accumulated: string[] = []
let drainError: unknown
let sourceFullyDrained = false
if (forwardToClient) {
const clientSource = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await sourceReader.read()
if (done) {
const tail = decoder.decode()
if (tail) accumulated.push(tail)
sourceFullyDrained = true
controller.close()
return
}
accumulated.push(decoder.decode(value, { stream: true }))
controller.enqueue(value)
} catch (error) {
drainError = error
controller.error(error)
}
},
async cancel(reason) {
try {
await sourceReader.cancel(reason)
} catch {}
},
})
const processedClientStream = streamingResponseFormatProcessor.processStream(
clientSource,
blockId,
selectedOutputs,
responseFormat
)
try {
await ctx.onStream?.({
stream: processedClientStream,
execution: streamingExec.execution,
})
} catch (error) {
this.execLogger.error('Error in onStream callback', { blockId, error })
await processedClientStream.cancel().catch(() => {})
} finally {
try {
sourceReader.releaseLock()
} catch {}
}
} else {
// Buffer-only drain: consume the source so `execution.output` is complete,
// but never forward raw chunks to the client (block-output redaction is on).
try {
while (true) {
const { done, value } = await sourceReader.read()
if (done) {
const tail = decoder.decode()
if (tail) accumulated.push(tail)
sourceFullyDrained = true
break
}
accumulated.push(decoder.decode(value, { stream: true }))
}
} catch (error) {
drainError = error
} finally {
try {
sourceReader.releaseLock()
} catch {}
}
}
if (drainError) {
this.execLogger.error('Error reading stream for block', { blockId, error: drainError })
return
}
// If the onStream consumer exited before the source drained (e.g. it caught
// an internal error and returned normally), `accumulated` holds a truncated
// response. Persisting that to memory or setting it as the block output
// would corrupt downstream state — skip and log instead.
if (!sourceFullyDrained) {
this.execLogger.warn(
'Stream consumer exited before source drained; skipping content persistence',
{
blockId,
}
)
return
}
let fullContent = accumulated.join('')
if (!fullContent) {
return
}
if (!forwardToClient && ctx.piiBlockOutputRedaction?.enabled) {
// Mask before the content is written to `execution.output` or persisted to
// memory via `onFullContent`, so the streamed agent response can't leak PII
// through either path. The block-output redaction below is then idempotent.
fullContent = await redactObjectStrings(fullContent, {
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
language: ctx.piiBlockOutputRedaction.language,
onFailure: 'throw',
})
}
const executionOutput = streamingExec.execution?.output
if (executionOutput && typeof executionOutput === 'object') {
let parsedForFormat = false
if (responseFormat) {
try {
const parsed = JSON.parse(fullContent.trim())
streamingExec.execution.output = {
...parsed,
tokens: executionOutput.tokens,
toolCalls: executionOutput.toolCalls,
providerTiming: executionOutput.providerTiming,
cost: executionOutput.cost,
model: executionOutput.model,
}
parsedForFormat = true
} catch (error) {
this.execLogger.warn('Failed to parse streamed content for response format', {
blockId,
error,
})
}
}
if (!parsedForFormat) {
executionOutput.content = fullContent
}
}
if (streamingExec.onFullContent) {
try {
await streamingExec.onFullContent(fullContent)
} catch (error) {
this.execLogger.error('onFullContent callback failed', { blockId, error })
}
}
}
}
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
import { createLogger } from '@sim/logger'
import { CONTROL_BACK_EDGE_HANDLES, EDGE, SUBFLOW_CONTROL_EDGE_HANDLES } from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { DAGEdge } from '@/executor/dag/types'
import type { NormalizedBlockOutput } from '@/executor/types'
const logger = createLogger('EdgeManager')
export class EdgeManager {
private deactivatedEdges = new Set<string>()
private nodesWithActivatedEdge = new Set<string>()
constructor(private dag: DAG) {}
processOutgoingEdges(
node: DAGNode,
output: NormalizedBlockOutput,
skipBackwardsEdge = false
): string[] {
const readyNodes: string[] = []
const activatedTargets: string[] = []
const edgesToDeactivate: Array<{ target: string; handle?: string }> = []
for (const [, edge] of node.outgoingEdges) {
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
continue
}
if (!this.shouldActivateEdge(edge, output)) {
if (!this.isSubflowControlEdge(edge.sourceHandle)) {
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
}
continue
}
activatedTargets.push(edge.target)
}
// Track nodes that have received at least one activated edge
for (const targetId of activatedTargets) {
this.nodesWithActivatedEdge.add(targetId)
}
const cascadeTargets = new Set<string>()
for (const { target, handle } of edgesToDeactivate) {
this.deactivateEdgeAndDescendants(node.id, target, handle, cascadeTargets)
}
if (activatedTargets.length === 0) {
for (const { target } of edgesToDeactivate) {
if (this.isTerminalControlNode(target)) {
cascadeTargets.add(target)
}
}
}
for (const targetId of activatedTargets) {
const targetNode = this.dag.nodes.get(targetId)
if (!targetNode) {
logger.warn('Target node not found', { target: targetId })
continue
}
targetNode.incomingEdges.delete(node.id)
}
for (const targetId of activatedTargets) {
if (this.isTargetReady(targetId)) {
readyNodes.push(targetId)
}
}
const isDeadEnd = activatedTargets.length === 0
const isRoutedDeadEnd = isDeadEnd && !!(output.selectedOption || output.selectedRoute)
for (const targetId of cascadeTargets) {
if (!readyNodes.includes(targetId) && !activatedTargets.includes(targetId)) {
if (!isDeadEnd || !this.isTargetReady(targetId)) continue
if (isRoutedDeadEnd) {
// A condition/router deliberately selected a dead-end path.
// Only queue the sentinel if it belongs to the SAME subflow as the
// current node (the condition is inside the loop/parallel and the
// loop still needs to continue/exit). Downstream subflow sentinels
// should NOT fire.
if (this.isEnclosingSentinel(node, targetId)) {
readyNodes.push(targetId)
}
} else {
readyNodes.push(targetId)
}
}
}
if (output.selectedRoute !== EDGE.LOOP_EXIT && output.selectedRoute !== EDGE.PARALLEL_EXIT) {
for (const { target } of edgesToDeactivate) {
if (
!readyNodes.includes(target) &&
!activatedTargets.includes(target) &&
this.nodesWithActivatedEdge.has(target) &&
this.isTargetReady(target)
) {
readyNodes.push(target)
}
}
}
return readyNodes
}
isNodeReady(node: DAGNode): boolean {
return node.incomingEdges.size === 0 || this.countActiveIncomingEdges(node) === 0
}
restoreIncomingEdge(targetNodeId: string, sourceNodeId: string): void {
const targetNode = this.dag.nodes.get(targetNodeId)
if (!targetNode) {
logger.warn('Cannot restore edge - target node not found', { targetNodeId })
return
}
targetNode.incomingEdges.add(sourceNodeId)
}
clearDeactivatedEdges(): void {
this.deactivatedEdges.clear()
this.nodesWithActivatedEdge.clear()
}
getDeactivatedEdges(): string[] {
return Array.from(this.deactivatedEdges)
}
getNodesWithActivatedEdge(): string[] {
return Array.from(this.nodesWithActivatedEdge)
}
hasActivatedEdge(nodeId: string): boolean {
return this.nodesWithActivatedEdge.has(nodeId)
}
restoreDeactivatedEdges(edgeKeys?: string[], activatedNodeIds?: string[]): void {
this.deactivatedEdges = new Set(
(edgeKeys ?? []).map((edgeKey) => this.normalizeSerializedEdgeKey(edgeKey))
)
this.nodesWithActivatedEdge = new Set(activatedNodeIds ?? [])
}
markNodeWithActivatedEdge(nodeId: string): void {
this.nodesWithActivatedEdge.add(nodeId)
}
/**
* Deactivates the `error` edge of a successfully-resumed pause block instead of
* firing it: the block completed normally, so its error path is pruned (and any
* now-dead descendants cascaded), mirroring how a normally-succeeding block's
* error edge is handled in {@link processOutgoingEdges}.
*
* `cascadeTargets` is intentionally left undefined here (unlike the
* {@link processOutgoingEdges} call site, which passes an explicit Set): the
* resume path has no loop/parallel sentinels to queue — the pause block's
* `source` edge drives continuation — so cascade-target collection is omitted.
*/
deactivateResumedEdge(sourceId: string, targetId: string, sourceHandle?: string): void {
this.deactivateEdgeAndDescendants(sourceId, targetId, sourceHandle)
}
/**
* Clear deactivated edges for a set of nodes (used when restoring loop state for next iteration).
*
* Only clears edges whose SOURCE is in the provided set. Edges pointing INTO a node in the set
* whose source lives outside (e.g. an external branch whose path was cascade-deactivated) must
* remain deactivated — otherwise `countActiveIncomingEdges` would count a source that will never
* fire again, stalling the loop on its next iteration.
*
* Deactivated edge keys encode the source separately so node IDs with shared prefixes
* cannot clear each other's deactivated edges.
*/
clearDeactivatedEdgesForNodes(nodeIds: Set<string>): void {
const edgesToRemove: string[] = []
for (const edgeKey of this.deactivatedEdges) {
const sourceId = this.parseEdgeKey(edgeKey)?.sourceId
if (!sourceId) continue
for (const nodeId of nodeIds) {
if (sourceId === nodeId) {
edgesToRemove.push(edgeKey)
break
}
}
}
for (const edgeKey of edgesToRemove) {
this.deactivatedEdges.delete(edgeKey)
}
for (const nodeId of nodeIds) {
this.nodesWithActivatedEdge.delete(nodeId)
}
}
private isTargetReady(targetId: string): boolean {
const targetNode = this.dag.nodes.get(targetId)
return targetNode ? this.isNodeReady(targetNode) : false
}
/**
* Checks if the cascade target sentinel belongs to the same subflow as the source node.
* A condition inside a loop that hits a dead-end should still allow the enclosing
* loop's sentinel to fire so the loop can continue or exit.
*/
private isEnclosingSentinel(sourceNode: DAGNode, sentinelId: string): boolean {
const sentinel = this.dag.nodes.get(sentinelId)
if (!sentinel?.metadata.isSentinel) return false
const sourceSubflowType = sourceNode.metadata.subflowType
const sentinelSubflowType = sentinel.metadata.subflowType
const sourceSubflowId = sourceNode.metadata.subflowId
const sentinelSubflowId = sentinel.metadata.subflowId
if (
sourceSubflowType &&
sentinelSubflowType &&
sourceSubflowType === sentinelSubflowType &&
sourceSubflowId &&
sentinelSubflowId &&
sourceSubflowId === sentinelSubflowId
) {
return true
}
return false
}
private isSubflowControlEdge(handle?: string): boolean {
return handle !== undefined && SUBFLOW_CONTROL_EDGE_HANDLES.has(handle)
}
private isBackwardsEdge(sourceHandle?: string): boolean {
return sourceHandle !== undefined && CONTROL_BACK_EDGE_HANDLES.has(sourceHandle)
}
private isTerminalControlNode(nodeId: string): boolean {
const node = this.dag.nodes.get(nodeId)
if (!node || node.outgoingEdges.size === 0) return false
for (const [, edge] of node.outgoingEdges) {
if (!this.isSubflowControlEdge(edge.sourceHandle)) {
return false
}
}
return true
}
private shouldActivateEdge(edge: DAGEdge, output: NormalizedBlockOutput): boolean {
const handle = edge.sourceHandle
if (output.selectedRoute === EDGE.LOOP_EXIT) {
return handle === EDGE.LOOP_EXIT
}
if (output.selectedRoute === EDGE.LOOP_CONTINUE) {
return handle === EDGE.LOOP_CONTINUE || handle === EDGE.LOOP_CONTINUE_ALT
}
if (output.selectedRoute === EDGE.PARALLEL_EXIT) {
return handle === EDGE.PARALLEL_EXIT
}
if (output.selectedRoute === EDGE.PARALLEL_CONTINUE) {
return handle === EDGE.PARALLEL_CONTINUE
}
if (this.isSubflowControlEdge(handle)) {
return false
}
if (!handle) {
return true
}
if (handle.startsWith(EDGE.CONDITION_PREFIX)) {
const conditionValue = handle.substring(EDGE.CONDITION_PREFIX.length)
return output.selectedOption === conditionValue
}
if (handle.startsWith(EDGE.ROUTER_PREFIX)) {
const routeId = handle.substring(EDGE.ROUTER_PREFIX.length)
return output.selectedRoute === routeId
}
switch (handle) {
case EDGE.ERROR:
return !!output.error
case EDGE.SOURCE:
return !output.error
default:
return true
}
}
private deactivateEdgeAndDescendants(
sourceId: string,
targetId: string,
sourceHandle?: string,
cascadeTargets?: Set<string>,
isCascade = false
): void {
const edgeKey = this.createEdgeKey(sourceId, targetId, sourceHandle)
if (this.deactivatedEdges.has(edgeKey)) {
return
}
this.deactivatedEdges.add(edgeKey)
const targetNode = this.dag.nodes.get(targetId)
if (!targetNode) return
if (isCascade && this.isTerminalControlNode(targetId)) {
cascadeTargets?.add(targetId)
}
// Don't cascade if node has active incoming edges OR has received an activated edge
if (
this.hasActiveIncomingEdges(targetNode, edgeKey) ||
this.nodesWithActivatedEdge.has(targetId)
) {
return
}
for (const [, outgoingEdge] of targetNode.outgoingEdges) {
if (!this.isBackwardsEdge(outgoingEdge.sourceHandle)) {
this.deactivateEdgeAndDescendants(
targetId,
outgoingEdge.target,
outgoingEdge.sourceHandle,
cascadeTargets,
true
)
}
}
}
/**
* Checks if a node has any active incoming edges besides the one being excluded.
*/
private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean {
for (const incomingSourceId of node.incomingEdges) {
const incomingNode = this.dag.nodes.get(incomingSourceId)
if (!incomingNode) continue
for (const [, incomingEdge] of incomingNode.outgoingEdges) {
if (incomingEdge.target === node.id) {
const incomingEdgeKey = this.createEdgeKey(
incomingSourceId,
node.id,
incomingEdge.sourceHandle
)
if (incomingEdgeKey === excludeEdgeKey) continue
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
return true
}
}
}
}
return false
}
private countActiveIncomingEdges(node: DAGNode): number {
let count = 0
for (const sourceId of node.incomingEdges) {
const sourceNode = this.dag.nodes.get(sourceId)
if (!sourceNode) continue
for (const [, edge] of sourceNode.outgoingEdges) {
if (edge.target === node.id) {
const edgeKey = this.createEdgeKey(sourceId, edge.target, edge.sourceHandle)
if (!this.deactivatedEdges.has(edgeKey)) {
count++
break
}
}
}
}
return count
}
private createEdgeKey(sourceId: string, targetId: string, sourceHandle?: string): string {
return JSON.stringify([sourceId, targetId, sourceHandle ?? EDGE.DEFAULT])
}
private parseEdgeKey(
edgeKey: string
): { sourceId: string; targetId: string; handle: string } | null {
let parsed: unknown
try {
parsed = JSON.parse(edgeKey)
} catch {
return null
}
if (
Array.isArray(parsed) &&
parsed.length === 3 &&
typeof parsed[0] === 'string' &&
typeof parsed[1] === 'string' &&
typeof parsed[2] === 'string'
) {
return { sourceId: parsed[0], targetId: parsed[1], handle: parsed[2] }
}
return null
}
private normalizeSerializedEdgeKey(edgeKey: string): string {
if (this.parseEdgeKey(edgeKey)) {
return edgeKey
}
for (const [sourceId, sourceNode] of this.dag.nodes) {
for (const [, edge] of sourceNode.outgoingEdges) {
const legacyKey = `${sourceId}-${edge.target}-${edge.sourceHandle ?? EDGE.DEFAULT}`
if (legacyKey === edgeKey) {
return this.createEdgeKey(sourceId, edge.target, edge.sourceHandle)
}
}
}
return edgeKey
}
}
File diff suppressed because it is too large Load Diff
+573
View File
@@ -0,0 +1,573 @@
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
getCancellationChannel,
isExecutionCancelled,
isRedisCancellationEnabled,
} from '@/lib/execution/cancellation'
import { BlockType, EDGE } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import type {
ExecutionContext,
ExecutionResult,
NormalizedBlockOutput,
PauseMetadata,
PausePoint,
ResumeStatus,
} from '@/executor/types'
import { attachExecutionResult, normalizeError } from '@/executor/utils/errors'
const logger = createLogger('ExecutionEngine')
export class ExecutionEngine {
private readyQueue: string[] = []
private executing = new Set<Promise<void>>()
private queueLock = Promise.resolve()
private finalOutput: NormalizedBlockOutput = {}
private responseOutputLocked = false
private pausedBlocks: Map<string, PauseMetadata> = new Map()
private allowResumeTriggers: boolean
private cancelledFlag = false
private errorFlag = false
private stoppedEarlyFlag = false
private executionError: Error | null = null
private abortPromise!: Promise<void>
private abortResolve!: () => void
private cancellationUnsubscribe: (() => void) | null = null
private execLogger: Logger
constructor(
private context: ExecutionContext,
private dag: DAG,
private edgeManager: EdgeManager,
private nodeOrchestrator: NodeExecutionOrchestrator
) {
this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true
this.execLogger = logger.withMetadata({
workflowId: this.context.workflowId,
workspaceId: this.context.workspaceId,
executionId: this.context.executionId,
userId: this.context.userId,
requestId: this.context.metadata.requestId,
})
this.initializeAbortHandler()
this.subscribeToCancellationChannel()
}
private subscribeToCancellationChannel(): void {
if (!this.context.executionId) return
const executionId = this.context.executionId
this.cancellationUnsubscribe = getCancellationChannel().subscribe((event) => {
if (event.executionId !== executionId) return
this.execLogger.info('Execution cancelled via pub/sub', { executionId })
this.signalCancelled()
})
}
private initializeAbortHandler(): void {
this.abortPromise = new Promise<void>((resolve) => {
this.abortResolve = resolve
})
if (!this.context.abortSignal) return
if (this.context.abortSignal.aborted) {
this.signalCancelled()
return
}
this.context.abortSignal.addEventListener('abort', () => this.signalCancelled(), { once: true })
}
private signalCancelled(): void {
if (this.cancelledFlag) return
this.cancelledFlag = true
this.abortResolve()
}
private checkCancellation(): boolean {
return this.cancelledFlag
}
/** Catches cancellations published before this engine subscribed (e.g. resume from snapshot). */
private async checkCancellationBackstop(): Promise<void> {
if (!this.context.executionId || !isRedisCancellationEnabled()) return
const cancelled = await isExecutionCancelled(this.context.executionId)
if (cancelled) {
this.execLogger.info('Execution already cancelled at engine start (Redis backstop)', {
executionId: this.context.executionId,
})
this.signalCancelled()
}
}
async run(triggerBlockId?: string): Promise<ExecutionResult> {
const startTime = performance.now()
try {
this.initializeQueue(triggerBlockId)
await this.checkCancellationBackstop()
while (this.hasWork()) {
if (this.checkCancellation() || this.errorFlag || this.stoppedEarlyFlag) {
break
}
await this.processQueue()
}
if (!this.cancelledFlag) {
await this.waitForAllExecutions()
}
if (this.errorFlag && this.executionError && !this.responseOutputLocked) {
throw this.executionError
}
if (this.pausedBlocks.size > 0) {
return this.buildPausedResult(startTime)
}
const endTime = performance.now()
this.context.metadata.endTime = new Date().toISOString()
this.context.metadata.duration = endTime - startTime
if (this.cancelledFlag) {
this.finalizeIncompleteLogs()
return {
success: false,
output: this.finalOutput,
logs: this.context.blockLogs,
executionState: this.getSerializableExecutionState(),
metadata: this.context.metadata,
status: 'cancelled',
}
}
return {
success: true,
output: this.finalOutput,
logs: this.context.blockLogs,
executionState: this.getSerializableExecutionState(),
metadata: this.context.metadata,
}
} catch (error) {
const endTime = performance.now()
this.context.metadata.endTime = new Date().toISOString()
this.context.metadata.duration = endTime - startTime
if (this.cancelledFlag) {
this.finalizeIncompleteLogs()
return {
success: false,
output: this.finalOutput,
logs: this.context.blockLogs,
executionState: this.getSerializableExecutionState(),
metadata: this.context.metadata,
status: 'cancelled',
}
}
this.finalizeIncompleteLogs()
const errorMessage = normalizeError(error)
this.execLogger.error('Execution failed', { error: errorMessage })
const executionResult: ExecutionResult = {
success: false,
output: this.finalOutput,
error: errorMessage,
logs: this.context.blockLogs,
metadata: this.context.metadata,
}
if (error instanceof Error) {
attachExecutionResult(error, executionResult)
}
throw error
} finally {
this.cleanup()
}
}
private cleanup(): void {
if (this.cancellationUnsubscribe) {
this.cancellationUnsubscribe()
this.cancellationUnsubscribe = null
}
}
private hasWork(): boolean {
return this.readyQueue.length > 0 || this.executing.size > 0
}
private addToQueue(nodeId: string): void {
const node = this.dag.nodes.get(nodeId)
if (node?.metadata?.isResumeTrigger && !this.allowResumeTriggers) {
return
}
if (!this.readyQueue.includes(nodeId)) {
this.readyQueue.push(nodeId)
}
}
private addMultipleToQueue(nodeIds: string[]): void {
for (const nodeId of nodeIds) {
this.addToQueue(nodeId)
}
}
private dequeue(): string | undefined {
return this.readyQueue.shift()
}
private trackExecution(promise: Promise<void>): void {
const trackedPromise = promise
.catch((error) => {
if (!this.errorFlag) {
this.errorFlag = true
this.executionError = toError(error)
}
})
.finally(() => {
this.executing.delete(trackedPromise)
})
this.executing.add(trackedPromise)
}
private async waitForAnyExecution(): Promise<void> {
if (this.executing.size > 0) {
await Promise.race([...this.executing, this.abortPromise])
}
}
private async waitForAllExecutions(): Promise<void> {
await Promise.race([Promise.all(this.executing), this.abortPromise])
if (this.executing.size > 0) {
await Promise.allSettled(this.executing)
}
}
private async withQueueLock<T>(fn: () => Promise<T> | T): Promise<T> {
const prevLock = this.queueLock
let resolveLock: () => void
this.queueLock = new Promise((resolve) => {
resolveLock = resolve
})
await prevLock
try {
return await fn()
} finally {
resolveLock!()
}
}
private initializeQueue(triggerBlockId?: string): void {
if (this.context.runFromBlockContext) {
const { startBlockId } = this.context.runFromBlockContext
this.execLogger.info('Initializing queue for run-from-block mode', {
startBlockId,
dirtySetSize: this.context.runFromBlockContext.dirtySet.size,
})
this.addToQueue(startBlockId)
return
}
const pendingBlocks = this.context.metadata.pendingBlocks
const remainingEdges = (this.context.metadata as any).remainingEdges
if (remainingEdges && Array.isArray(remainingEdges) && remainingEdges.length > 0) {
this.execLogger.info('Removing edges from resumed pause blocks', {
edgeCount: remainingEdges.length,
edges: remainingEdges,
})
for (const edge of remainingEdges) {
const targetNode = this.dag.nodes.get(edge.target)
if (!targetNode) continue
const sourceHandle = this.resolveRemainingEdgeHandle(edge)
if (sourceHandle === EDGE.ERROR) {
this.edgeManager.deactivateResumedEdge(edge.source, targetNode.id, sourceHandle)
if (
this.edgeManager.hasActivatedEdge(targetNode.id) &&
this.edgeManager.isNodeReady(targetNode)
) {
this.execLogger.info('Convergence node ready after pruning resumed error edge', {
nodeId: targetNode.id,
})
this.addToQueue(targetNode.id)
}
continue
}
const hadEdge = targetNode.incomingEdges.has(edge.source)
targetNode.incomingEdges.delete(edge.source)
if (hadEdge) {
this.edgeManager.markNodeWithActivatedEdge(targetNode.id)
}
if (this.edgeManager.isNodeReady(targetNode)) {
this.execLogger.info('Node became ready after edge removal', { nodeId: targetNode.id })
this.addToQueue(targetNode.id)
}
}
this.execLogger.info('Edge removal complete, queued ready nodes', {
queueLength: this.readyQueue.length,
queuedNodes: this.readyQueue,
})
return
}
if (pendingBlocks && pendingBlocks.length > 0) {
this.execLogger.info('Initializing queue from pending blocks (resume mode)', {
pendingBlocks,
allowResumeTriggers: this.allowResumeTriggers,
dagNodeCount: this.dag.nodes.size,
})
for (const nodeId of pendingBlocks) {
this.addToQueue(nodeId)
}
this.execLogger.info('Pending blocks queued', {
queueLength: this.readyQueue.length,
queuedNodes: this.readyQueue,
})
this.context.metadata.pendingBlocks = []
return
}
if (this.context.metadata.resumeFromSnapshot === true) {
this.execLogger.info('Resume snapshot has no downstream work to queue')
return
}
if (triggerBlockId) {
this.addToQueue(triggerBlockId)
return
}
const startNode = Array.from(this.dag.nodes.values()).find(
(node) =>
node.block.metadata?.id === BlockType.START_TRIGGER ||
node.block.metadata?.id === BlockType.STARTER
)
if (startNode) {
this.addToQueue(startNode.id)
} else {
this.execLogger.warn('No start node found in DAG')
}
}
/**
* Resolves the source handle for an edge released during pause/resume.
* Persisted `remainingEdges` may omit the handle, so fall back to the live DAG
* edge. When a source has both a continuation and an `error` edge to the same
* target, the continuation handle wins — a successful resume must not prune it.
*/
private resolveRemainingEdgeHandle(edge: {
source: string
target: string
sourceHandle?: string
}): string | undefined {
if (edge.sourceHandle !== undefined) return edge.sourceHandle
const sourceNode = this.dag.nodes.get(edge.source)
if (!sourceNode) return undefined
let hasErrorEdge = false
for (const [, outgoing] of sourceNode.outgoingEdges) {
if (outgoing.target !== edge.target) continue
if (outgoing.sourceHandle === EDGE.ERROR) {
hasErrorEdge = true
continue
}
return outgoing.sourceHandle
}
return hasErrorEdge ? EDGE.ERROR : undefined
}
private async processQueue(): Promise<void> {
while (this.readyQueue.length > 0) {
if (this.checkCancellation() || this.errorFlag) {
break
}
const nodeId = this.dequeue()
if (!nodeId) continue
const promise = this.executeNodeAsync(nodeId)
this.trackExecution(promise)
}
if (this.executing.size > 0 && !this.cancelledFlag && !this.errorFlag) {
await this.waitForAnyExecution()
}
}
private async executeNodeAsync(nodeId: string): Promise<void> {
try {
const wasAlreadyExecuted = this.context.executedBlocks.has(nodeId)
const result = await this.nodeOrchestrator.executeNode(this.context, nodeId)
if (!wasAlreadyExecuted) {
await this.withQueueLock(async () => {
await this.handleNodeCompletion(nodeId, result.output, result.isFinalOutput)
})
}
} catch (error) {
const errorMessage = normalizeError(error)
this.execLogger.error('Node execution failed', { nodeId, error: errorMessage })
throw error
}
}
private async handleNodeCompletion(
nodeId: string,
output: NormalizedBlockOutput,
isFinalOutput: boolean
): Promise<void> {
const node = this.dag.nodes.get(nodeId)
if (!node) {
this.execLogger.error('Node not found during completion', { nodeId })
return
}
if (this.stoppedEarlyFlag && this.responseOutputLocked) {
// Workflow already ended via Response block. Skip state persistence (setBlockOutput),
// parallel/loop scope tracking, and edge propagation — no downstream blocks will run.
return
}
if (output._pauseMetadata) {
await this.nodeOrchestrator.handleNodeCompletion(this.context, nodeId, output)
const pauseMetadata = output._pauseMetadata
this.pausedBlocks.set(pauseMetadata.contextId, pauseMetadata)
this.context.metadata.status = 'paused'
this.context.metadata.pausePoints = Array.from(this.pausedBlocks.keys())
return
}
await this.nodeOrchestrator.handleNodeCompletion(this.context, nodeId, output)
const isResponseBlock = node.block.metadata?.id === BlockType.RESPONSE
if (isResponseBlock) {
if (!this.responseOutputLocked) {
this.finalOutput = output
this.responseOutputLocked = true
}
this.stoppedEarlyFlag = true
return
}
if (isFinalOutput && !this.responseOutputLocked) {
this.finalOutput = output
}
if (this.context.stopAfterBlockId === nodeId) {
// For loop/parallel sentinels, only stop if the subflow has fully exited (all iterations done)
// shouldContinue: true means more iterations, shouldExit: true means loop is done
const shouldContinue =
output.shouldContinue === true || output.selectedRoute === EDGE.PARALLEL_CONTINUE
if (!shouldContinue) {
this.execLogger.info('Stopping execution after target block', { nodeId })
this.stoppedEarlyFlag = true
return
}
}
const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false)
this.addMultipleToQueue(readyNodes)
}
private buildPausedResult(startTime: number): ExecutionResult {
const endTime = performance.now()
this.context.metadata.endTime = new Date().toISOString()
this.context.metadata.duration = endTime - startTime
this.context.metadata.status = 'paused'
const snapshotSeed = serializePauseSnapshot(this.context, [], this.dag, this.edgeManager)
const pausePoints: PausePoint[] = Array.from(this.pausedBlocks.values()).map((pause) => ({
contextId: pause.contextId,
blockId: pause.blockId,
response: pause.response,
registeredAt: pause.timestamp,
resumeStatus: 'paused' as ResumeStatus,
snapshotReady: true,
parallelScope: pause.parallelScope,
loopScope: pause.loopScope,
resumeLinks: pause.resumeLinks,
pauseKind: pause.pauseKind,
resumeAt: pause.resumeAt,
}))
return {
success: true,
output: this.collectPauseResponses(),
logs: this.context.blockLogs,
executionState: this.getSerializableExecutionState(snapshotSeed),
metadata: this.context.metadata,
status: 'paused',
pausePoints,
snapshotSeed,
}
}
private getSerializableExecutionState(snapshotSeed?: {
snapshot: string
}): SerializableExecutionState | undefined {
try {
const serializedSnapshot =
snapshotSeed?.snapshot ??
serializePauseSnapshot(this.context, [], this.dag, this.edgeManager).snapshot
const parsedSnapshot = JSON.parse(serializedSnapshot) as {
state?: SerializableExecutionState
}
return parsedSnapshot.state
} catch (error) {
this.execLogger.warn('Failed to serialize execution state', {
error: toError(error).message,
})
return undefined
}
}
private collectPauseResponses(): NormalizedBlockOutput {
const responses = Array.from(this.pausedBlocks.values()).map((pause) => pause.response)
if (responses.length === 1) {
return responses[0]
}
return {
pausedBlocks: responses,
pauseCount: responses.length,
}
}
/**
* Finalizes any block logs that were still running when execution was cancelled.
* Sets their endedAt to now and calculates the actual elapsed duration.
*/
private finalizeIncompleteLogs(): void {
const now = new Date()
const nowIso = now.toISOString()
for (const log of this.context.blockLogs) {
if (!log.endedAt) {
log.endedAt = nowIso
log.durationMs = now.getTime() - new Date(log.startedAt).getTime()
}
}
}
}
@@ -0,0 +1,431 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { DAGBuilder } from '@/executor/dag/builder'
import { DAGExecutor } from '@/executor/execution/executor'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionContext, ExecutionResult } from '@/executor/types'
import { buildSentinelStartId } from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
function createExecutor(): DAGExecutor {
return new DAGExecutor({
workflow: {
version: '1',
blocks: [],
connections: [],
},
})
}
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('DAGExecutor restored cloned subflow registration', () => {
it('registers restored cloned subflows under their parent parallel branch', () => {
const executor = createExecutor() as unknown as {
registerRestoredClonedSubflows: (
parentMap: Map<
string,
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
>,
clonedSubflows: Array<{
originalId: string
clonedId: string
outerBranchIndex: number
parentParallelId: string
}>
) => void
}
const parentMap = new Map<
string,
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
>([['nested-loop', { parentId: 'parent-parallel', parentType: 'parallel' }]])
executor.registerRestoredClonedSubflows(parentMap, [
{
originalId: 'nested-loop',
clonedId: 'nested-loop__obranch-2',
outerBranchIndex: 2,
parentParallelId: 'parent-parallel',
},
])
expect(parentMap.get('nested-loop__obranch-2')).toEqual({
parentId: 'parent-parallel',
parentType: 'parallel',
branchIndex: 2,
})
})
it('preserves cloned nested parent relationships within the same restored branch', () => {
const executor = createExecutor() as unknown as {
registerRestoredClonedSubflows: (
parentMap: Map<
string,
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
>,
clonedSubflows: Array<{
originalId: string
clonedId: string
outerBranchIndex: number
parentParallelId: string
}>
) => void
}
const parentMap = new Map<
string,
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
>([
['middle-loop', { parentId: 'parent-parallel', parentType: 'parallel' }],
['inner-parallel', { parentId: 'middle-loop', parentType: 'loop' }],
])
executor.registerRestoredClonedSubflows(parentMap, [
{
originalId: 'middle-loop',
clonedId: 'middle-loop__obranch-2',
outerBranchIndex: 2,
parentParallelId: 'parent-parallel',
},
{
originalId: 'inner-parallel',
clonedId: 'inner-parallel__obranch-2',
outerBranchIndex: 2,
parentParallelId: 'parent-parallel',
},
])
expect(parentMap.get('inner-parallel__obranch-2')).toEqual({
parentId: 'middle-loop__obranch-2',
parentType: 'loop',
branchIndex: 0,
})
})
it('restores snapshot parallel batches with later global branch indexes', () => {
const parallelId = 'parallel-1'
const loopId = 'loop-1'
const taskId = 'task-1'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(parallelId, BlockType.PARALLEL),
createBlock(loopId, BlockType.LOOP),
createBlock(taskId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: parallelId },
{ source: parallelId, target: loopId, sourceHandle: 'parallel-start-source' },
{ source: loopId, target: taskId, sourceHandle: 'loop-start-source' },
],
loops: {
[loopId]: {
id: loopId,
nodes: [taskId],
iterations: 1,
loopType: 'for',
},
},
parallels: {
[parallelId]: {
id: parallelId,
nodes: [loopId],
count: 4,
parallelType: 'count',
},
},
}
const dag = new DAGBuilder().build(workflow)
const executor = new DAGExecutor({ workflow }) as unknown as {
restoreSnapshotParallelBatches: (
dag: ReturnType<DAGBuilder['build']>,
snapshotState?: SerializableExecutionState
) => Array<{
originalId: string
clonedId: string
outerBranchIndex: number
parentParallelId: string
}>
}
const restoredClones = executor.restoreSnapshotParallelBatches(dag, {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
parallelExecutions: {
[parallelId]: {
currentBatchStart: 2,
currentBatchSize: 1,
totalBranches: 4,
items: ['zero', 'one', 'two', 'three'],
},
},
})
expect(dag.nodes.has(buildSentinelStartId(`${loopId}__obranch-2`))).toBe(true)
expect(restoredClones).toContainEqual(
expect.objectContaining({
originalId: loopId,
clonedId: `${loopId}__obranch-2`,
outerBranchIndex: 2,
parentParallelId: parallelId,
})
)
})
})
describe('DAGExecutor run-from-block snapshot metadata', () => {
it('preserves reachable large value and file keys in run-from-block metadata', async () => {
const reachableLargeValue = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEF123456',
kind: 'object',
size: 1024,
key: 'execution/ws/wf/exec/large-value-lv_ABCDEF123456.json',
}
const unreachableLargeValue = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ZYXWVU654321',
kind: 'object',
size: 1024,
key: 'execution/ws/wf/exec/large-value-lv_ZYXWVU654321.json',
}
const reachableFile = {
id: 'file-1',
name: 'reachable.txt',
url: '/api/files/serve/reachable',
size: 10,
type: 'text/plain',
key: 'execution/ws/wf/exec/reachable.txt',
}
const unreachableFile = {
id: 'file-2',
name: 'unreachable.txt',
url: '/api/files/serve/unreachable',
size: 10,
type: 'text/plain',
key: 'execution/ws/wf/exec/unreachable.txt',
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('producer', BlockType.FUNCTION),
createBlock('consumer', BlockType.FUNCTION),
createBlock('unreachable', BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: 'producer' },
{ source: 'producer', target: 'consumer' },
],
loops: {},
parallels: {},
}
const executor = new DAGExecutor({
workflow,
contextExtensions: {
workspaceId: 'ws',
executionId: 'exec',
largeValueKeys: ['existing-large-key'],
fileKeys: ['existing-file-key'],
},
}) as unknown as DAGExecutor & {
buildExecutionPipeline: (context: ExecutionContext) => { run: () => Promise<ExecutionResult> }
}
const run = vi.fn(async (): Promise<ExecutionResult> => {
return {
success: true,
output: { ok: true },
metadata: {} as ExecutionResult['metadata'],
}
})
executor.buildExecutionPipeline = vi.fn(() => ({ run }))
const sourceSnapshot: SerializableExecutionState = {
blockStates: {
producer: { output: { reachableLargeValue, reachableFile } },
consumer: { output: { previous: true } },
unreachable: { output: { unreachableLargeValue, unreachableFile } },
},
executedBlocks: ['producer', 'consumer', 'unreachable'],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
}
const result = await executor.executeFromBlock('wf', 'consumer', sourceSnapshot)
expect(result.metadata?.largeValueKeys).toEqual(['existing-large-key', reachableLargeValue.key])
expect(result.metadata?.fileKeys).toEqual(['existing-file-key', reachableFile.key])
expect(result.metadata?.largeValueKeys).not.toContain(unreachableLargeValue.key)
expect(result.metadata?.fileKeys).not.toContain(unreachableFile.key)
})
it('preserves reachable stable branch aliases in run-from-block snapshots', async () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('producer', BlockType.FUNCTION),
createBlock('consumer', BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: 'producer' },
{ source: 'producer', target: 'consumer' },
],
loops: {},
parallels: {},
}
let capturedContext: ExecutionContext | undefined
const executor = new DAGExecutor({ workflow }) as unknown as DAGExecutor & {
buildExecutionPipeline: (context: ExecutionContext) => { run: () => Promise<ExecutionResult> }
}
executor.buildExecutionPipeline = vi.fn((context: ExecutionContext) => {
capturedContext = context
return {
run: async (): Promise<ExecutionResult> => ({
success: true,
output: { ok: true },
metadata: {},
}),
}
})
const sourceSnapshot: SerializableExecutionState = {
blockStates: {
producer: { output: { result: 'latest-local-batch' } },
'producer__obranch-0': { output: { result: 'global-branch-0' } },
'unreachable__obranch-0': { output: { result: 'unreachable' } },
consumer: { output: { previous: true } },
},
executedBlocks: ['producer', 'producer__obranch-0', 'unreachable__obranch-0', 'consumer'],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
}
await executor.executeFromBlock('wf', 'consumer', sourceSnapshot)
expect(capturedContext?.blockStates.get('producer__obranch-0')?.output).toEqual({
result: 'global-branch-0',
})
expect(capturedContext?.blockStates.has('unreachable__obranch-0')).toBe(false)
})
})
describe('DAGExecutor resume DAG construction', () => {
it('includes non-starter resume targets when a workflow has a disconnected starter', async () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start-t2-v2', BlockType.STARTER),
createBlock('webhook-start', 'generic_webhook'),
createBlock('hitl', BlockType.HUMAN_IN_THE_LOOP),
createBlock('generate-report', BlockType.FUNCTION),
],
connections: [
{ source: 'webhook-start', target: 'hitl', sourceHandle: 'source' },
{ source: 'hitl', target: 'generate-report', sourceHandle: 'source' },
],
loops: {},
parallels: {},
}
let capturedDag: ReturnType<DAGBuilder['build']> | undefined
const executor = new DAGExecutor({
workflow,
contextExtensions: {
resumeFromSnapshot: true,
remainingEdges: [{ source: 'hitl', target: 'generate-report', sourceHandle: 'source' }],
dagIncomingEdges: { 'start-t2-v2': [] },
snapshotState: {
blockStates: {},
executedBlocks: ['webhook-start', 'hitl'],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
},
},
}) as unknown as DAGExecutor & {
buildExecutionPipeline: (
context: ExecutionContext,
dag: ReturnType<DAGBuilder['build']>
) => { run: () => Promise<ExecutionResult> }
}
executor.buildExecutionPipeline = vi.fn((_context, dag) => {
capturedDag = dag
return {
run: async (): Promise<ExecutionResult> => ({
success: true,
output: { ok: true },
metadata: {},
}),
}
})
await executor.execute('wf')
expect(capturedDag?.nodes.has('generate-report')).toBe(true)
expect(capturedDag?.nodes.get('generate-report')?.incomingEdges.has('hitl')).toBe(true)
})
})
describe('DAGExecutor createExecutionContext useDraftState', () => {
function buildMetadataUseDraftState(opts: {
metadataUseDraftState?: boolean
isDeployedContext?: boolean
}): boolean | undefined {
const executor = new DAGExecutor({
workflow: { version: '1', blocks: [], connections: [] },
contextExtensions: {
workspaceId: 'ws-1',
isDeployedContext: opts.isDeployedContext,
metadata:
opts.metadataUseDraftState === undefined
? undefined
: ({ useDraftState: opts.metadataUseDraftState } as ExecutionContext['metadata']),
},
})
const { context } = (
executor as unknown as {
createExecutionContext: (workflowId: string) => { context: ExecutionContext }
}
).createExecutionContext('wf-1')
return context.metadata.useDraftState
}
it('honors explicit useDraftState=true even when isDeployedContext is true (table dispatcher)', () => {
expect(
buildMetadataUseDraftState({ metadataUseDraftState: true, isDeployedContext: true })
).toBe(true)
})
it('honors explicit useDraftState=false even when isDeployedContext is false', () => {
expect(
buildMetadataUseDraftState({ metadataUseDraftState: false, isDeployedContext: false })
).toBe(false)
})
it('falls back to the isDeployedContext heuristic when useDraftState is not provided', () => {
expect(buildMetadataUseDraftState({ isDeployedContext: true })).toBe(false)
expect(buildMetadataUseDraftState({ isDeployedContext: false })).toBe(true)
})
})
+628
View File
@@ -0,0 +1,628 @@
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<string, string>
workflowInput?: WorkflowInput
workflowVariables?: Record<string, unknown>
contextExtensions?: ContextExtensions
}
export class DAGExecutor {
private workflow: SerializedWorkflow
private environmentVariables: Record<string, string>
private workflowInput: WorkflowInput
private workflowVariables: Record<string, unknown>
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<ExecutionResult> {
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<ExecutionResult> {
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<ExecutionResult> {
// 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<string>()
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<string, any> = {}
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<string, any> = {}
if (sourceSnapshot.loopExecutions) {
for (const [loopId, execution] of Object.entries(sourceSnapshot.loopExecutions)) {
if (reachableContainerIds.has(loopId)) {
filteredLoopExecutions[loopId] = execution
}
}
}
const filteredParallelExecutions: Record<string, any> = {}
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<string, string[]>): 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<string, { parentId: string; parentType: SubflowType; branchIndex?: number }>,
clonedSubflows: RestoredClonedSubflowInfo[]
): void {
const branchCloneMaps = new Map<string, Map<number, Map<string, string>>>()
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<string, BlockState>()
let executedBlocks = snapshotState?.executedBlocks
? new Set(snapshotState.executedBlocks)
: new Set<string>()
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<string, { parentId: string; parentType: SubflowType; branchIndex?: number }> {
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<typeof resolveExecutorStartBlock> | 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,
})
}
}
@@ -0,0 +1,166 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import { EdgeManager } from '@/executor/execution/edge-manager'
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
import type { ExecutionContext } from '@/executor/types'
function createContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: true,
startTime: '2026-01-01T00:00:00.000Z',
},
environmentVariables: {},
decisions: {
router: new Map(),
condition: new Map(),
},
completedLoops: new Set(),
activeExecutionPath: new Set(),
...overrides,
} as ExecutionContext
}
describe('serializePauseSnapshot', () => {
it('serializes batched parallel accumulated outputs for cross-process resume', () => {
const context = createContext({
parallelExecutions: new Map([
[
'parallel-1',
{
parallelId: 'parallel-1',
totalBranches: 3,
branchOutputs: new Map([[2, [{ output: 'current-batch' }]]]),
accumulatedOutputs: new Map([
[0, [{ output: 'batch-0' }]],
[1, [{ output: 'batch-1' }]],
]),
},
],
]),
})
const snapshot = serializePauseSnapshot(context, ['next-block'])
const serialized = JSON.parse(snapshot.snapshot)
expect(serialized.state.parallelExecutions?.['parallel-1']).toMatchObject({
branchOutputs: {
2: [{ output: 'current-batch' }],
},
accumulatedOutputs: {
0: [{ output: 'batch-0' }],
1: [{ output: 'batch-1' }],
},
})
})
it('serializes deactivated edge state for resume', () => {
const context = createContext()
const sourceNode = {
id: 'condition',
block: {} as DAGNode['block'],
incomingEdges: new Set<string>(),
outgoingEdges: new Map([['if-edge', { target: 'target', sourceHandle: 'condition-if' }]]),
metadata: {},
}
const targetNode = {
id: 'target',
block: {} as DAGNode['block'],
incomingEdges: new Set(['condition']),
outgoingEdges: new Map(),
metadata: {},
}
const activeSourceNode = {
id: 'active-source',
block: {} as DAGNode['block'],
incomingEdges: new Set<string>(),
outgoingEdges: new Map([['active-edge', { target: 'active-target' }]]),
metadata: {},
}
const activeTargetNode = {
id: 'active-target',
block: {} as DAGNode['block'],
incomingEdges: new Set(['active-source']),
outgoingEdges: new Map(),
metadata: {},
}
const dag: DAG = {
nodes: new Map([
[sourceNode.id, sourceNode],
[targetNode.id, targetNode],
[activeSourceNode.id, activeSourceNode],
[activeTargetNode.id, activeTargetNode],
]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const edgeManager = new EdgeManager(dag)
edgeManager.processOutgoingEdges(sourceNode, { selectedOption: 'else' })
edgeManager.processOutgoingEdges(activeSourceNode, { result: true })
const snapshot = serializePauseSnapshot(context, ['next-block'], dag, edgeManager)
const serialized = JSON.parse(snapshot.snapshot)
expect(serialized.state.deactivatedEdges).toHaveLength(1)
expect(serialized.state.nodesWithActivatedEdge).toEqual(['active-target'])
})
it('rejects oversized snapshot values without full JSON serialization', () => {
const stringifySpy = vi.spyOn(JSON, 'stringify').mockImplementation(() => {
throw new Error('full stringify should not be used for compactness checks')
})
const context = createContext({
workflowVariables: {
oversized: {
type: 'string',
value: 'x'.repeat(9 * 1024 * 1024),
},
},
})
try {
expect(() => serializePauseSnapshot(context, ['next-block'])).toThrow(
'Cannot serialize pause snapshot with oversized workflow variables'
)
} finally {
stringifySpy.mockRestore()
}
})
it('preserves an explicit useDraftState=true even when the context is a deployed (server-side) context', () => {
const context = createContext({
isDeployedContext: true,
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: true,
startTime: '2026-01-01T00:00:00.000Z',
},
})
const snapshot = serializePauseSnapshot(context, ['next-block'])
const serialized = JSON.parse(snapshot.snapshot)
expect(serialized.metadata.useDraftState).toBe(true)
})
})
@@ -0,0 +1,269 @@
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
import type { DAG } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionContext, SerializedSnapshot } from '@/executor/types'
const JSON_SYNTAX_BYTES = {
QUOTE: 1,
COLON: 1,
COMMA: 1,
ARRAY_BRACKETS: 2,
OBJECT_BRACES: 2,
NULL: 4,
} as const
function getEscapedJsonStringByteLength(value: string): number {
let bytes = JSON_SYNTAX_BYTES.QUOTE * 2
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index)
if (code === 0x22 || code === 0x5c) {
bytes += 2
} else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
bytes += 2
} else if (code < 0x20) {
bytes += 6
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index++
} else {
bytes += 6
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
bytes += 6
} else if (code < 0x80) {
bytes += 1
} else if (code < 0x800) {
bytes += 2
} else {
bytes += 3
}
}
return bytes
}
function getPrimitiveJsonByteLength(value: unknown): number | undefined {
if (value === null) {
return JSON_SYNTAX_BYTES.NULL
}
if (typeof value === 'string') {
return getEscapedJsonStringByteLength(value)
}
if (typeof value === 'number') {
return Number.isFinite(value)
? Buffer.byteLength(String(value), 'utf8')
: JSON_SYNTAX_BYTES.NULL
}
if (typeof value === 'boolean') {
return value ? 4 : 5
}
if (typeof value === 'bigint') {
throw new TypeError('Do not know how to serialize a BigInt')
}
return undefined
}
function getBoundedJsonByteLength(
value: unknown,
maxBytes: number,
seen = new WeakSet<object>()
): number | undefined {
const primitiveSize = getPrimitiveJsonByteLength(value)
if (primitiveSize !== undefined) {
return primitiveSize
}
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined
}
if (!value || typeof value !== 'object') {
return undefined
}
if (seen.has(value)) {
throw new TypeError('Converting circular structure to JSON')
}
seen.add(value)
let bytes = Array.isArray(value)
? JSON_SYNTAX_BYTES.ARRAY_BRACKETS
: JSON_SYNTAX_BYTES.OBJECT_BRACES
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
if (index > 0) bytes += JSON_SYNTAX_BYTES.COMMA
const itemSize = getBoundedJsonByteLength(value[index], maxBytes - bytes, seen)
bytes += itemSize ?? JSON_SYNTAX_BYTES.NULL
if (bytes > maxBytes) return bytes
}
seen.delete(value)
return bytes
}
let hasEntries = false
for (const key of Object.keys(value)) {
const entryValue = (value as Record<string, unknown>)[key]
if (
entryValue === undefined ||
typeof entryValue === 'function' ||
typeof entryValue === 'symbol'
) {
continue
}
if (hasEntries) bytes += JSON_SYNTAX_BYTES.COMMA
bytes += getEscapedJsonStringByteLength(key) + JSON_SYNTAX_BYTES.COLON
const entrySize = getBoundedJsonByteLength(entryValue, maxBytes - bytes, seen)
bytes += entrySize ?? JSON_SYNTAX_BYTES.NULL
hasEntries = true
if (bytes > maxBytes) return bytes
}
seen.delete(value)
return bytes
}
function assertSnapshotValueIsCompact(value: unknown, label: string): void {
const byteLength = getBoundedJsonByteLength(value, LARGE_VALUE_THRESHOLD_BYTES)
if (byteLength !== undefined && byteLength > LARGE_VALUE_THRESHOLD_BYTES) {
throw new Error(`Cannot serialize pause snapshot with oversized ${label}; compact it first.`)
}
}
function mapFromEntries<T>(map?: Map<string, T>): Record<string, T> | undefined {
if (!map) return undefined
return Object.fromEntries(map)
}
function serializeLoopExecutions(
loopExecutions?: Map<string, any>
): Record<string, any> | undefined {
if (!loopExecutions) return undefined
const result: Record<string, any> = {}
for (const [loopId, scope] of loopExecutions.entries()) {
let currentIterationOutputs: any
if (scope.currentIterationOutputs instanceof Map) {
currentIterationOutputs = Object.fromEntries(scope.currentIterationOutputs)
} else {
currentIterationOutputs = scope.currentIterationOutputs ?? {}
}
result[loopId] = {
...scope,
currentIterationOutputs,
}
}
return result
}
function serializeParallelExecutions(
parallelExecutions?: Map<string, any>
): Record<string, any> | undefined {
if (!parallelExecutions) return undefined
const result: Record<string, any> = {}
for (const [parallelId, scope] of parallelExecutions.entries()) {
const branchOutputs =
scope.branchOutputs instanceof Map
? Object.fromEntries(scope.branchOutputs)
: (scope.branchOutputs ?? {})
const accumulatedOutputs =
scope.accumulatedOutputs instanceof Map
? Object.fromEntries(scope.accumulatedOutputs)
: (scope.accumulatedOutputs ?? {})
result[parallelId] = {
...scope,
branchOutputs,
accumulatedOutputs,
}
}
return result
}
export function serializePauseSnapshot(
context: ExecutionContext,
triggerBlockIds: string[],
dag?: DAG,
edgeManager?: EdgeManager
): SerializedSnapshot {
const metadataFromContext = context.metadata as ExecutionMetadata | undefined
let useDraftState: boolean
if (metadataFromContext?.useDraftState !== undefined) {
useDraftState = metadataFromContext.useDraftState
} else if (context.isDeployedContext === true) {
useDraftState = false
} else {
useDraftState = true
}
const dagIncomingEdges: Record<string, string[]> | undefined = dag
? Object.fromEntries(
Array.from(dag.nodes.entries()).map(([nodeId, node]) => [
nodeId,
Array.from(node.incomingEdges),
])
)
: undefined
const state: SerializableExecutionState = {
blockStates: Object.fromEntries(context.blockStates),
executedBlocks: Array.from(context.executedBlocks),
blockLogs: context.blockLogs,
decisions: {
router: Object.fromEntries(context.decisions.router),
condition: Object.fromEntries(context.decisions.condition),
},
completedLoops: Array.from(context.completedLoops),
loopExecutions: serializeLoopExecutions(context.loopExecutions),
parallelExecutions: serializeParallelExecutions(context.parallelExecutions),
parallelBlockMapping: mapFromEntries(context.parallelBlockMapping),
activeExecutionPath: Array.from(context.activeExecutionPath),
pendingQueue: triggerBlockIds,
dagIncomingEdges,
deactivatedEdges: edgeManager?.getDeactivatedEdges(),
nodesWithActivatedEdge: edgeManager?.getNodesWithActivatedEdge(),
}
assertSnapshotValueIsCompact(context.workflowVariables, 'workflow variables')
assertSnapshotValueIsCompact(state.loopExecutions, 'loop execution state')
const workspaceId = metadataFromContext?.workspaceId ?? context.workspaceId
if (!workspaceId) {
throw new Error(
`Cannot serialize pause snapshot: missing workspaceId for workflow ${context.workflowId}`
)
}
const executionMetadata: ExecutionMetadata = {
requestId:
metadataFromContext?.requestId ?? context.executionId ?? context.workflowId ?? 'unknown',
executionId: context.executionId ?? 'unknown',
workflowId: context.workflowId,
workspaceId,
userId: metadataFromContext?.userId ?? '',
sessionUserId: metadataFromContext?.sessionUserId,
workflowUserId: metadataFromContext?.workflowUserId,
triggerType: metadataFromContext?.triggerType ?? 'manual',
triggerBlockId: triggerBlockIds[0],
useDraftState,
startTime: metadataFromContext?.startTime ?? new Date().toISOString(),
isClientSession: metadataFromContext?.isClientSession,
executionMode: metadataFromContext?.executionMode,
}
const snapshot = new ExecutionSnapshot(
executionMetadata,
context.workflow,
{},
context.workflowVariables,
context.selectedOutputs,
state
)
return {
snapshot: snapshot.toJSON(),
triggerIds: triggerBlockIds,
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
const metadata: ExecutionMetadata = {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
startTime: '2026-05-06T00:00:00.000Z',
}
describe('ExecutionSnapshot', () => {
it('normalizes untyped persisted execution state at construction', () => {
const variable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
const snapshot = new ExecutionSnapshot(
metadata,
{ blocks: [] },
{},
[variable],
['agent.content', 123, 'function.result']
)
expect(snapshot.workflowVariables).toEqual({ 'var-1': variable })
expect(snapshot.selectedOutputs).toEqual(['agent.content', 'function.result'])
})
})
+51
View File
@@ -0,0 +1,51 @@
import { normalizeStringArray } from '@/lib/core/utils/arrays'
import { normalizeWorkflowVariables } from '@/lib/core/utils/records'
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
export class ExecutionSnapshot {
public readonly metadata: ExecutionMetadata
public readonly workflow: any
public readonly input: any
public readonly workflowVariables: Record<string, any>
public readonly selectedOutputs: string[]
public readonly state?: SerializableExecutionState
constructor(
metadata: ExecutionMetadata,
workflow: any,
input: any,
workflowVariables: unknown,
selectedOutputs: unknown = [],
state?: SerializableExecutionState
) {
this.metadata = metadata
this.workflow = workflow
this.input = input
this.workflowVariables = normalizeWorkflowVariables(workflowVariables)
this.selectedOutputs = normalizeStringArray(selectedOutputs)
this.state = state
}
toJSON(): string {
return JSON.stringify({
metadata: this.metadata,
workflow: this.workflow,
input: this.input,
workflowVariables: this.workflowVariables,
selectedOutputs: this.selectedOutputs,
state: this.state,
})
}
static fromJSON(json: string): ExecutionSnapshot {
const data = JSON.parse(json)
return new ExecutionSnapshot(
data.metadata,
data.workflow,
data.input,
data.workflowVariables,
data.selectedOutputs,
data.state
)
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { ExecutionState } from '@/executor/execution/state'
describe('ExecutionState', () => {
it('returns exact suffixed cached node outputs', () => {
const state = new ExecutionState()
state.setBlockOutput('producer₍1₎', { value: 'branch-1' })
state.setBlockOutput('producer_loop1', { value: 'loop-1' })
expect(state.getBlockOutput('producer₍1₎')).toEqual({ value: 'branch-1' })
expect(state.getBlockOutput('producer_loop1')).toEqual({ value: 'loop-1' })
})
it('prefers branch-local cloned outputs when resolving original block references', () => {
const state = new ExecutionState()
state.setBlockOutput('producer', { value: 'branch-0' })
state.setBlockOutput('producer__cloneaaa__obranch-2', { value: 'branch-2' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2')).toEqual({
value: 'branch-2',
})
})
it('keeps cloned parallel branch references scoped to the same branch index', () => {
const state = new ExecutionState()
state.setBlockOutput('producer__cloneaaa__obranch-2₍0₎', { value: 'branch-0' })
state.setBlockOutput('producer__cloneaaa__obranch-2₍1₎', { value: 'branch-1' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2₍1₎')).toEqual({
value: 'branch-1',
})
})
it('does not fall back to another branch when cloned scoped output is missing', () => {
const state = new ExecutionState()
state.setBlockOutput('producer₍0₎', { value: 'wrong-branch' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2')).toBeUndefined()
})
it('resolves regular sibling outputs from the same parent parallel branch', () => {
const state = new ExecutionState()
state.setBlockOutput('producer₍2₎', { value: 'parent-branch-2' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2₍0₎')).toEqual({
value: 'parent-branch-2',
})
})
it('does not fall back to direct branch-zero output from cloned nodes', () => {
const state = new ExecutionState()
state.setBlockOutput('producer', { value: 'branch-0' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2')).toBeUndefined()
})
it('resolves branch-zero sibling output deterministically for unsuffixed nested branch nodes', () => {
const state = new ExecutionState()
state.setBlockOutput('producer₍1₎', { value: 'branch-1' })
state.setBlockOutput('producer₍0₎', { value: 'branch-0' })
expect(state.getBlockOutput('producer', 'nested-condition')).toEqual({ value: 'branch-0' })
})
it('prefers stable branch-zero aliases when later batches reuse local branch ids', () => {
const state = new ExecutionState()
state.setBlockOutput('producer__obranch-0', { value: 'global-branch-0' })
state.setBlockOutput('producer₍0₎', { value: 'later-batch-local-0' })
expect(state.getBlockOutput('producer', 'after-parallel')).toEqual({
value: 'global-branch-0',
})
})
})
+176
View File
@@ -0,0 +1,176 @@
import type { BlockStateController } from '@/executor/execution/types'
import type { BlockState, NormalizedBlockOutput } from '@/executor/types'
import { SubflowNodeIdCodec } from '@/executor/utils/subflow-node-id-codec'
import {
buildOuterBranchScopedId,
extractOuterBranchIndex,
stripCloneSuffixes,
} from '@/executor/utils/subflow-utils'
function normalizeLookupId(id: string): string {
return SubflowNodeIdCodec.normalizeLookupId(id)
}
function extractBranchSuffix(id: string): string {
return SubflowNodeIdCodec.extractBranchSuffix(id)
}
function extractLoopSuffix(id: string): string {
return SubflowNodeIdCodec.extractLoopSuffix(id)
}
export interface LoopScope {
iteration: number
currentIterationOutputs: Map<string, NormalizedBlockOutput>
allIterationOutputs: NormalizedBlockOutput[][]
maxIterations?: number
item?: any
items?: any[]
condition?: string
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
skipFirstConditionCheck?: boolean
skippedAtStart?: boolean
/** Error message if loop validation failed (e.g., exceeded max iterations) */
validationError?: string
}
export interface ParallelScope {
parallelId: string
totalBranches: number
batchSize?: number
currentBatchStart?: number
currentBatchSize?: number
accumulatedOutputs?: Map<number, NormalizedBlockOutput[]>
branchOutputs: Map<number, NormalizedBlockOutput[]>
items?: any[]
/** Error message if parallel validation failed (e.g., exceeded max branches) */
validationError?: string
/** Whether the parallel has an empty distribution and should be skipped */
isEmpty?: boolean
}
export class ExecutionState implements BlockStateController {
private readonly blockStates: Map<string, BlockState>
private readonly executedBlocks: Set<string>
constructor(blockStates?: Map<string, BlockState>, executedBlocks?: Set<string>) {
this.blockStates = blockStates ?? new Map()
this.executedBlocks = executedBlocks ?? new Set()
}
getBlockStates(): ReadonlyMap<string, BlockState> {
return this.blockStates
}
getExecutedBlocks(): ReadonlySet<string> {
return this.executedBlocks
}
getBlockOutput(blockId: string, currentNodeId?: string): NormalizedBlockOutput | undefined {
const normalizedId = normalizeLookupId(blockId)
if (normalizedId !== blockId) {
return this.blockStates.get(blockId)?.output
}
if (currentNodeId) {
const scopedOutput = this.getScopedBlockOutput(blockId, currentNodeId)
if (scopedOutput !== undefined) {
return scopedOutput
}
if (extractOuterBranchIndex(currentNodeId) !== undefined) {
return undefined
}
}
const direct = this.blockStates.get(blockId)?.output
if (direct !== undefined) {
return direct
}
if (currentNodeId && extractBranchSuffix(currentNodeId) === '') {
const stableBranchZeroOutput = this.blockStates.get(
buildOuterBranchScopedId(blockId, 0)
)?.output
if (stableBranchZeroOutput !== undefined) {
return stableBranchZeroOutput
}
const branchZeroOutput = this.blockStates.get(
`${blockId}₍0₎${extractLoopSuffix(currentNodeId)}`
)?.output
if (branchZeroOutput !== undefined) {
return branchZeroOutput
}
}
for (const [storedId, state] of this.blockStates.entries()) {
if (normalizeLookupId(storedId) === blockId) {
return state.output
}
}
return undefined
}
private getScopedBlockOutput(
blockId: string,
currentNodeId: string
): NormalizedBlockOutput | undefined {
const currentBranchSuffix = extractBranchSuffix(currentNodeId)
const loopSuffix = extractLoopSuffix(currentNodeId)
const currentOuterBranchIndex = extractOuterBranchIndex(currentNodeId)
if (currentOuterBranchIndex !== undefined) {
for (const [storedId, state] of this.blockStates.entries()) {
if (stripCloneSuffixes(storedId) !== blockId) continue
if (extractOuterBranchIndex(storedId) !== currentOuterBranchIndex) continue
if (extractBranchSuffix(storedId) !== currentBranchSuffix) continue
if (extractLoopSuffix(storedId) !== loopSuffix) continue
return state.output
}
const siblingBranchOutput = this.blockStates.get(
`${blockId}${currentOuterBranchIndex}`
)?.output
if (siblingBranchOutput !== undefined) {
return siblingBranchOutput
}
} else {
const withSuffix = `${blockId}${currentBranchSuffix}${loopSuffix}`
const suffixedOutput = this.blockStates.get(withSuffix)?.output
if (suffixedOutput !== undefined) {
return suffixedOutput
}
}
return undefined
}
setBlockOutput(blockId: string, output: NormalizedBlockOutput, executionTime = 0): void {
this.blockStates.set(blockId, { output, executed: true, executionTime })
this.executedBlocks.add(blockId)
}
setBlockState(blockId: string, state: BlockState): void {
this.blockStates.set(blockId, state)
if (state.executed) {
this.executedBlocks.add(blockId)
} else {
this.executedBlocks.delete(blockId)
}
}
deleteBlockState(blockId: string): void {
this.blockStates.delete(blockId)
this.executedBlocks.delete(blockId)
}
unmarkExecuted(blockId: string): void {
this.executedBlocks.delete(blockId)
}
hasExecuted(blockId: string): boolean {
return this.executedBlocks.has(blockId)
}
}
+271
View File
@@ -0,0 +1,271 @@
import type { Edge } from 'reactflow'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import type { NodeMetadata } from '@/executor/dag/types'
import type {
BlockLog,
BlockState,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SubflowType } from '@/stores/workflows/workflow/types'
export interface ExecutionMetadata {
requestId: string
executionId: string
workflowId: string
workspaceId: string
userId: string
sessionUserId?: string
workflowUserId?: string
triggerType: string
triggerBlockId?: string
useDraftState: boolean
startTime: string
isClientSession?: boolean
enforceCredentialAccess?: boolean
pendingBlocks?: string[]
resumeFromSnapshot?: boolean
resumeTerminalNoop?: boolean
credentialAccountUserId?: string
workflowStateOverride?: {
blocks: Record<string, any>
edges: Edge[]
loops?: Record<string, any>
parallels?: Record<string, any>
deploymentVersionId?: string
}
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
callChain?: string[]
correlation?: AsyncExecutionCorrelation
executionMode?: 'sync' | 'stream' | 'async'
}
export interface SerializableExecutionState {
blockStates: Record<string, BlockState>
executedBlocks: string[]
blockLogs: BlockLog[]
decisions: {
router: Record<string, string>
condition: Record<string, string>
}
completedLoops: string[]
loopExecutions?: Record<string, any>
parallelExecutions?: Record<string, any>
parallelBlockMapping?: Record<string, any>
activeExecutionPath: string[]
pendingQueue?: string[]
remainingEdges?: Edge[]
resumeTerminalNoop?: boolean
dagIncomingEdges?: Record<string, string[]>
deactivatedEdges?: string[]
nodesWithActivatedEdge?: string[]
completedPauseContexts?: string[]
}
/**
* Represents the iteration state of an ancestor subflow in a nested chain.
* Used to propagate parent iteration context through SSE events for both
* loop-in-loop and parallel-in-parallel nesting hierarchies.
*/
export interface ParentIteration {
iterationCurrent: number
iterationTotal?: number
iterationType: SubflowType
iterationContainerId: string
}
export interface IterationContext {
iterationCurrent: number
iterationTotal?: number
iterationType: SubflowType
/**
* Block ID of the loop or parallel container owning this iteration.
* Optional because generic `<loop.index>` references may resolve before
* the container ID is known (e.g., via `context.loopScope` fallback).
* Always present on {@link ParentIteration} entries since those are built
* from fully resolved ancestor loops.
*/
iterationContainerId?: string
parentIterations?: ParentIteration[]
}
/**
* Metadata passed to block handlers that execute within subflow contexts
* (loops, parallels, child workflows). Extends the DAG node metadata with
* runtime identifiers needed for execution tracking.
*/
export interface WorkflowNodeMetadata
extends Pick<
NodeMetadata,
'subflowType' | 'subflowId' | 'branchIndex' | 'branchTotal' | 'originalBlockId' | 'isLoopNode'
> {
nodeId: string
loopId?: string
parallelId?: string
executionOrder?: number
}
export interface ChildWorkflowContext {
/** The workflow block's ID in the parent execution */
parentBlockId: string
/** Display name of the child workflow */
workflowName: string
/** Child workflow ID */
workflowId: string
/** Nesting depth (1 = first level child) */
depth: number
}
export interface ExecutionCallbacks {
onStream?: (streamingExec: StreamingExecution) => Promise<void>
onBlockStart?: (
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
onBlockComplete?: (
blockId: string,
blockName: string,
blockType: string,
output: any,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/** Fires immediately after instanceId is generated, before child execution begins. */
onChildWorkflowInstanceReady?: (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
}
/** In-flight block-output redaction policy (the resolved `blockOutputs` stage). */
export interface PiiBlockOutputRedaction {
enabled: boolean
/** Presidio entity types to mask. Empty = redact all detected PII. */
entityTypes: string[]
/** Language whose Presidio recognizers apply. */
language: string
}
export interface ContextExtensions {
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
stream?: boolean
selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }>
isDeployedContext?: boolean
enforceCredentialAccess?: boolean
isChildExecution?: boolean
resumeFromSnapshot?: boolean
resumePendingQueue?: string[]
remainingEdges?: Array<{
source: string
target: string
sourceHandle?: string
targetHandle?: string
}>
dagIncomingEdges?: Record<string, string[]>
snapshotState?: SerializableExecutionState
metadata?: ExecutionMetadata
/**
* AbortSignal for cancellation support.
* When aborted, the execution should stop gracefully.
*/
abortSignal?: AbortSignal
includeFileBase64?: boolean
base64MaxBytes?: number
/**
* When enabled, every block output is masked in-flight before downstream blocks
* consume it. Resolved from the org/workspace PII redaction policy's
* `blockOutputs` stage. Serializable, so it crosses into the trigger.dev worker.
*/
piiBlockOutputRedaction?: PiiBlockOutputRedaction
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
onBlockStart?: (
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
onBlockComplete?: (
blockId: string,
blockName: string,
blockType: string,
output: {
input?: any
output: NormalizedBlockOutput
executionTime: number
startedAt: string
executionOrder: number
endedAt: string
/** Per-invocation unique ID linking this workflow block execution to its child block events. */
childWorkflowInstanceId?: string
},
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/** Context identifying this execution as a child of a workflow block */
childWorkflowContext?: ChildWorkflowContext
/** Fires immediately after instanceId is generated, before child execution begins. */
onChildWorkflowInstanceReady?: (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/**
* Run-from-block configuration. When provided, executor runs in partial
* execution mode starting from the specified block.
*/
runFromBlockContext?: RunFromBlockContext
/**
* Stop execution after this block completes. Used for "run until block" feature.
*/
stopAfterBlockId?: string
/**
* Ordered list of workflow IDs in the current call chain, used for cycle detection.
* Each hop appends the current workflow ID before making outgoing requests.
*/
callChain?: string[]
}
export interface WorkflowInput {
[key: string]: unknown
}
interface BlockStateReader {
getBlockOutput(blockId: string, currentNodeId?: string): NormalizedBlockOutput | undefined
hasExecuted(blockId: string): boolean
}
export interface BlockStateWriter {
setBlockOutput(blockId: string, output: NormalizedBlockOutput, executionTime?: number): void
setBlockState(blockId: string, state: BlockState): void
deleteBlockState(blockId: string): void
unmarkExecuted(blockId: string): void
}
export type BlockStateController = BlockStateReader & BlockStateWriter