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
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:
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user