d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
932 lines
29 KiB
TypeScript
932 lines
29 KiB
TypeScript
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 })
|
|
}
|
|
}
|
|
}
|
|
}
|