mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
* fix(delivery): stable provider idempotency tokens, and correctness fixes around them Six independent fixes found while investigating a Slack transport failure. None of them are that failure; all of them are live. **Money writes could be delivered twice.** Square (8 tools), Brex (5) and Outlook Calendar minted their provider idempotency token with `generateId()` at request-build time. That is stable inside the transport retry loop — `prepareToolRequest` runs once above it — but a BLOCK-level retry re-enters the handler and mints a fresh one, defeating the provider's dedupe. A builder ticking "retry" on a Square block turned a committed write into a second card charge, and a Brex one into a second money transfer. Tokens now come from `deriveDeliveryKey`, a pure function of execution + block + tool + invocation, so every retry layer derives the same value. Stripe joins them: all ~50 tools previously sent no `Idempotency-Key` at all. The comment on `brex/create_transfer.ts` claimed a fresh key per transfer *prevents* duplicate money movement. That is inverted — fresh per *attempt* is what permits it — and is corrected here. **`invocationId` is required, not optional.** Deriving from `executionId` alone would be worse than the bug: five loop iterations paying five invoices would share one token, the provider would honour the first and silently drop four real payments, and it would look like five successes. Also: - `INTERNAL_API_BASE_URL` is now ignored on Trigger.dev workers. It names a route that resolves only inside the app container, and several modules run in both runtimes — `guardrails/mask-client.ts` says so in its own TSDoc — so setting it produced `PII redaction failed: Unable to connect` on every worker-side redaction. Mirrored into `packages/testing`'s urls mock, which reimplements the function and would otherwise have diverged. - `engines.bun` raised to >=1.3.14. Measured on 1.2.15, which the old floor permitted: a fully-delivered POST is silently replayed and the caller sees 200. - CloudWatch `put-metric-data` pinned to `maxAttempts: 1`. The AWS SDK default of 3 already replayed it, and `PutMetricData` aggregates rather than overwrites, so a duplicate silently corrupts the customer's metric series and alarm thresholds. - `webhookIdempotency` given a bounded in-progress lease. An untimed run held a SEVEN DAY lease while concurrent duplicates polled it once a second. Verified: `tsc --noEmit` clean, 30,100 tests pass. * fix(ci): restore staging files the branch split had reverted, and format Three problems, all from assembling this branch by checking paths out of a WIP branch built on an older `staging`. Anything `staging` changed since that base came back as a revert. - Root `package.json` had lost the `opentype.js` / `@types/opentype.js` dependencies `staging` added, which desynced `bun.lock` and failed `bun audit`. It also carried a `check:outbound-delivery` script belonging to other work. Every `package.json` is now taken from `staging` with only the `engines.bun` line re-applied. - `executor/utils/block-data.test.ts` — a 77-line file `staging` added — was deleted outright. Restored. - `executor/handlers/generic/generic-handler.test.ts` had lost a test `staging` added. Restored, with only the one `blockId` assertion re-applied. Also formats `keyed-invocation-identity.test.ts` and sorts imports in `internal-api-base-url.test.ts`, which is what `lint:check` failed on. * fix(providers): thread the model's tool-call id into keyed tool execution `prepareToolExecution` accepted an `invocationId` but no provider supplied one, so a keyed tool invoked through an agent always hit the incomplete-context fallback and minted a fresh token — leaving Stripe, Square, Brex and Outlook Calendar writes able to double-deliver under the hosted-key retry layer even though the block path was fixed. The id is now a positional parameter rather than another optional field on `request`, so a provider that cannot supply one fails to compile instead of silently falling through. 22 of the 27 call sites already had the OpenAI-shaped `toolCall` in scope; `tsc` identified the other five, of which Anthropic (`toolUse.id`) and Bedrock (`toolUse.toolUseId`) name it differently. Gemini is left deliberately unthreaded and documented: its function-call parts carry no model-supplied identifier — the streaming loop has to synthesize a local one — and a positional index would not survive the model re-emitting the call. A token that only looks stable is worse than the loud fallback, which names the missing fields. * fix(executor): keep execution order monotonic across a resume `executionOrder` is not carried in the pause snapshot, so a resumed run restarted the counter at 0 and a loop or parallel body executing on both sides of a pause could reuse a pre-pause value. That was cosmetic while the number only ordered logs. It stops being cosmetic once identity is derived from it: a `keyed` tool takes its provider idempotency token from this value, so two distinct writes would present the same token and the provider would silently drop the second. Suppressing a real payment is worse than the duplicate the token exists to prevent, because it looks like success. The counter is now seeded from the highest `executionOrder` among the restored block logs rather than from a new snapshot field, so snapshots written before this change are repaired on resume instead of needing a migration. * fix(providers): thread the tool-call id through the streaming loops too The previous commit only reached call sites written as a single-line three-argument call. The streaming loops are formatted across lines, so openai-compat (Groq, DeepSeek and everything else routing through it), Anthropic and Bedrock still omitted the id and kept falling back to a fresh token. Both Gemini paths now pass `part.functionCall?.id` — the RAW model id, not the `ensureToolCallId` value used for stream events. That helper allocates an execution-local id when Gemini supplies none, and it is freshly allocated per attempt: passing it would complete the keyed context, silencing the "could not derive" warning, while leaving the token just as unstable. Gemini frequently omits the id, in which case this is `undefined` and the loud fallback stands. All 27 call sites are now covered. * fix(providers): make the tool-call id argument required, not optional The TSDoc claimed a provider that cannot supply an id would fail to compile, but the parameter was declared `toolCallId?: string` — so a new call site could omit it entirely, typecheck, and silently take the unstable-token path the positional parameter exists to close. The comment promised a guarantee the type did not enforce. It is now `string | undefined`: required in position, nullable in value. A provider with no model-supplied id must pass `undefined` explicitly and take the loud fallback, rather than being able to forget the argument. All 27 existing call sites already pass it, so this is enforcement only. Verified by deleting the argument at one site: `tsc` rejects it.
1280 lines
44 KiB
TypeScript
1280 lines
44 KiB
TypeScript
import { createLogger, type Logger } from '@sim/logger'
|
|
import { sleep } from '@sim/utils/helpers'
|
|
import { isRecordLike } from '@sim/utils/object'
|
|
import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types'
|
|
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,
|
|
CHILD_EXECUTION_ID_OUTPUT_KEY,
|
|
DEFAULTS,
|
|
EDGE,
|
|
isSentinelBlockType,
|
|
isWorkflowBlockType,
|
|
} from '@/executor/constants'
|
|
import type { DAGNode } from '@/executor/dag/builder'
|
|
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
|
|
import { isRetryableBlockError, resolveBlockRetryPolicy } from '@/executor/execution/block-retry'
|
|
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 { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
|
|
import type {
|
|
ResolvedSecretTraceProvenanceV1,
|
|
ResolvedSecretTraceRegistry,
|
|
} from '@/executor/utils/resolved-secret-trace-registry'
|
|
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 { createAgentStreamPump } from '@/providers/stream-pump'
|
|
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 parentResolvedSecretTraceRegistry = ctx.resolvedSecretTraceRegistry
|
|
const blockResolvedSecretTraceRegistry = parentResolvedSecretTraceRegistry?.forkForInputPaths(
|
|
[]
|
|
)
|
|
const inputDisplayRegistry = blockResolvedSecretTraceRegistry
|
|
const blockCtx = blockResolvedSecretTraceRegistry
|
|
? { ...ctx, resolvedSecretTraceRegistry: blockResolvedSecretTraceRegistry }
|
|
: ctx
|
|
let registryCommitted = false
|
|
const commitBlockRegistry = () => {
|
|
const settledBlockRegistry = blockCtx.resolvedSecretTraceRegistry
|
|
if (registryCommitted || !parentResolvedSecretTraceRegistry || !settledBlockRegistry) {
|
|
return
|
|
}
|
|
registryCommitted = true
|
|
if (settledBlockRegistry.isComplete()) {
|
|
parentResolvedSecretTraceRegistry.mergeToolCallRegistry(settledBlockRegistry)
|
|
}
|
|
}
|
|
|
|
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(blockCtx, node.id, block, node, startedAt)
|
|
blockCtx.blockLogs.push(blockLog)
|
|
blockStartPromise = this.fireBlockStartCallback(
|
|
blockCtx,
|
|
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(
|
|
blockCtx,
|
|
node,
|
|
block,
|
|
nodeMetadata
|
|
)
|
|
}
|
|
|
|
try {
|
|
if (!isSentinel && blockType) {
|
|
await validateBlockType(blockCtx.userId, blockCtx.workspaceId, blockType, blockCtx)
|
|
}
|
|
|
|
if (block.metadata?.id === BlockType.FUNCTION) {
|
|
const {
|
|
resolvedInputs: fnInputs,
|
|
displayInputs,
|
|
contextVariables,
|
|
} = await this.resolver.resolveInputsForFunctionBlock(
|
|
blockCtx,
|
|
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(
|
|
blockCtx,
|
|
node.id,
|
|
block.config.params,
|
|
block
|
|
)
|
|
inputsForLog = resolvedInputs
|
|
}
|
|
|
|
if (blockLog) {
|
|
blockLog.input = this.projectInputsForDisplay(inputsForLog, block, inputDisplayRegistry)
|
|
}
|
|
} catch (error) {
|
|
cleanupSelfReference?.()
|
|
try {
|
|
return await this.handleBlockError(
|
|
error,
|
|
blockCtx,
|
|
node,
|
|
block,
|
|
blockStartPromise,
|
|
startTime,
|
|
blockLog,
|
|
inputsForLog,
|
|
inputDisplayRegistry,
|
|
isSentinel,
|
|
'input_resolution'
|
|
)
|
|
} finally {
|
|
commitBlockRegistry()
|
|
}
|
|
}
|
|
cleanupSelfReference?.()
|
|
|
|
let streamingPartialOutput: Record<string, any> | undefined
|
|
try {
|
|
/**
|
|
* Only the handler call is retried. A streaming handler returns before any
|
|
* token is drained, so a replay cannot duplicate output the client has
|
|
* already seen.
|
|
*/
|
|
const output = await this.runHandlerWithRetry(blockCtx, block, blockLog, () =>
|
|
handler.executeWithNode
|
|
? handler.executeWithNode(blockCtx, block, resolvedInputs, nodeMetadata)
|
|
: handler.execute(blockCtx, block, resolvedInputs, nodeMetadata)
|
|
)
|
|
|
|
const isStreamingExecution =
|
|
output && typeof output === 'object' && 'stream' in output && 'execution' in output
|
|
|
|
let normalizedOutput: NormalizedBlockOutput
|
|
if (isStreamingExecution) {
|
|
const streamingExec = output as StreamingExecution
|
|
|
|
// Always drain via the agent stream pump (tokens/cost/timing callbacks),
|
|
// even with no `onStream`. When block-output redaction is on we do not
|
|
// live-forward chunks; content is masked before persist and the masked
|
|
// final output reaches the client via block-complete.
|
|
try {
|
|
await this.handleStreamingExecution(
|
|
blockCtx,
|
|
node,
|
|
block,
|
|
streamingExec,
|
|
resolvedInputs,
|
|
normalizeStringArray(blockCtx.selectedOutputs)
|
|
)
|
|
} catch (streamError) {
|
|
const resultRegistry = blockCtx.resolvedSecretTraceRegistry
|
|
const diagnosticRegistry = streamingExec.diagnosticResolvedSecretTraceRegistry
|
|
const errorRegistry = diagnosticRegistry
|
|
? diagnosticRegistry.forkForToolCall()
|
|
: resultRegistry?.forkForToolCall()
|
|
if (errorRegistry && resultRegistry && resultRegistry !== diagnosticRegistry) {
|
|
errorRegistry.mergeToolCallRegistry(resultRegistry)
|
|
}
|
|
blockCtx.errorResolvedSecretTraceRegistry = errorRegistry
|
|
blockCtx.resolvedSecretTraceRegistry = resultRegistry?.forkForPropagatedEntries()
|
|
// Timeout / drain failures may still have projected answer text — keep it
|
|
// for the failed block output so logs match what the client already saw.
|
|
streamingPartialOutput = streamingExec.execution?.output
|
|
throw streamError
|
|
}
|
|
|
|
normalizedOutput = this.normalizeOutput(
|
|
streamingExec.execution.output ?? streamingExec.execution
|
|
)
|
|
} else {
|
|
normalizedOutput = this.normalizeOutput(output)
|
|
}
|
|
|
|
if (blockCtx.includeFileBase64 === true && containsUserFileWithMetadata(normalizedOutput)) {
|
|
normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, {
|
|
requestId: blockCtx.metadata.requestId,
|
|
workspaceId: blockCtx.workspaceId,
|
|
workflowId: blockCtx.workflowId,
|
|
executionId: blockCtx.executionId,
|
|
largeValueExecutionIds: blockCtx.largeValueExecutionIds,
|
|
largeValueKeys: blockCtx.largeValueKeys,
|
|
fileKeys: blockCtx.fileKeys,
|
|
allowLargeValueWorkflowScope: blockCtx.allowLargeValueWorkflowScope,
|
|
userId: blockCtx.userId,
|
|
maxBytes: blockCtx.base64MaxBytes,
|
|
preserveLargeValueMetadata: true,
|
|
})) as NormalizedBlockOutput
|
|
}
|
|
|
|
if (blockCtx.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: blockCtx.piiBlockOutputRedaction.entityTypes,
|
|
language: blockCtx.piiBlockOutputRedaction.language,
|
|
customPatterns: blockCtx.piiBlockOutputRedaction.customPatterns,
|
|
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: blockCtx.workspaceId,
|
|
workflowId: blockCtx.workflowId,
|
|
executionId: blockCtx.executionId,
|
|
userId: blockCtx.userId,
|
|
},
|
|
})
|
|
normalizedOutput = await redactObjectStrings(normalizedOutput, redactionOptions)
|
|
}
|
|
|
|
normalizedOutput = (await compactExecutionPayload(normalizedOutput, {
|
|
workspaceId: blockCtx.workspaceId,
|
|
workflowId: blockCtx.workflowId,
|
|
executionId: blockCtx.executionId,
|
|
userId: blockCtx.userId,
|
|
preserveUserFileBase64: blockCtx.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 childExecutionId = normalizedOutput[CHILD_EXECUTION_ID_OUTPUT_KEY]
|
|
if (typeof childExecutionId === 'string' && childExecutionId) {
|
|
blockLog.childExecution = { executionId: childExecutionId }
|
|
}
|
|
}
|
|
|
|
const {
|
|
childTraceSpans: _traces,
|
|
[CHILD_EXECUTION_ID_OUTPUT_KEY]: _childExecutionId,
|
|
...outputForState
|
|
} = normalizedOutput
|
|
const stateOutput = outputForState as NormalizedBlockOutput
|
|
const settledBlockRegistry = blockCtx.resolvedSecretTraceRegistry
|
|
const stateProvenance = settledBlockRegistry?.exportCommittedProvenanceForValue(stateOutput)
|
|
this.setNodeOutput(node, stateOutput, duration, stateProvenance)
|
|
|
|
if (!isSentinel && blockLog) {
|
|
const childWorkflowInstanceId =
|
|
typeof normalizedOutput._childWorkflowInstanceId === 'string'
|
|
? normalizedOutput._childWorkflowInstanceId
|
|
: undefined
|
|
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
|
|
block,
|
|
})
|
|
const displayInput = this.projectInputsForDisplay(inputsForLog, block, inputDisplayRegistry)
|
|
blockLog.input = displayInput
|
|
const displayProvenance = settledBlockRegistry?.exportCommittedProvenanceForValue({
|
|
input: displayInput,
|
|
output: displayOutput,
|
|
})
|
|
this.setBlockLogDisplayProvenance(blockLog, displayProvenance)
|
|
this.fireBlockCompleteCallback(
|
|
blockStartPromise,
|
|
blockCtx,
|
|
node,
|
|
block,
|
|
displayInput,
|
|
displayOutput,
|
|
duration,
|
|
blockLog.startedAt,
|
|
blockLog.executionOrder,
|
|
blockLog.endedAt,
|
|
childWorkflowInstanceId,
|
|
stateProvenance,
|
|
displayProvenance
|
|
)
|
|
}
|
|
|
|
commitBlockRegistry()
|
|
return stateOutput
|
|
} catch (error) {
|
|
try {
|
|
return await this.handleBlockError(
|
|
error,
|
|
blockCtx,
|
|
node,
|
|
block,
|
|
blockStartPromise,
|
|
startTime,
|
|
blockLog,
|
|
inputsForLog,
|
|
inputDisplayRegistry,
|
|
isSentinel,
|
|
'execution',
|
|
streamingPartialOutput
|
|
)
|
|
} finally {
|
|
commitBlockRegistry()
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
|
): void {
|
|
this.state.setBlockOutput(node.id, output, duration, resolvedSecretTraceProvenance)
|
|
|
|
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,
|
|
resolvedSecretTraceProvenance
|
|
)
|
|
}
|
|
this.state.setBlockOutput(
|
|
buildOuterBranchScopedId(originalBlockId, branchIndex),
|
|
output,
|
|
duration,
|
|
resolvedSecretTraceProvenance
|
|
)
|
|
}
|
|
}
|
|
|
|
private setBlockLogDisplayProvenance(
|
|
blockLog: BlockLog,
|
|
provenance: ResolvedSecretTraceProvenanceV1 | undefined
|
|
): void {
|
|
if (!provenance) return
|
|
Object.defineProperty(blockLog, 'displayResolvedSecretTraceProvenance', {
|
|
value: provenance,
|
|
enumerable: true,
|
|
configurable: true,
|
|
writable: true,
|
|
})
|
|
}
|
|
|
|
private findHandler(block: SerializedBlock): BlockHandler | undefined {
|
|
return this.blockHandlers.find((h) => h.canHandle(block))
|
|
}
|
|
|
|
/**
|
|
* Runs the block handler, replaying it on failure while tries remain.
|
|
*
|
|
* Rethrows the final try's error so the caller's catch — and with it the error
|
|
* port — behaves exactly as it does for a block that never retried. Retrying
|
|
* only ever delays the existing outcome; it never changes it.
|
|
*/
|
|
private async runHandlerWithRetry<T>(
|
|
ctx: ExecutionContext,
|
|
block: SerializedBlock,
|
|
blockLog: BlockLog | undefined,
|
|
invoke: () => Promise<T>
|
|
): Promise<T> {
|
|
const policy = resolveBlockRetryPolicy(block)
|
|
if (!policy) return invoke()
|
|
|
|
let tries = 0
|
|
try {
|
|
for (;;) {
|
|
tries++
|
|
try {
|
|
return await invoke()
|
|
} catch (error) {
|
|
const isFinalTry = tries >= policy.maxTries
|
|
if (isFinalTry || ctx.abortSignal?.aborted || !isRetryableBlockError(error)) throw error
|
|
|
|
this.execLogger.warn('Block failed; retrying', {
|
|
blockId: block.id,
|
|
blockType: block.metadata?.id,
|
|
tries,
|
|
maxTries: policy.maxTries,
|
|
waitBetweenTriesMs: policy.waitBetweenTriesMs,
|
|
error: normalizeError(error),
|
|
})
|
|
|
|
if (policy.waitBetweenTriesMs > 0) await sleep(policy.waitBetweenTriesMs)
|
|
|
|
/** `sleep` is not abort-aware, so a run stopped mid-wait must not start another try. */
|
|
if (ctx.abortSignal?.aborted) throw error
|
|
}
|
|
}
|
|
} finally {
|
|
if (blockLog && tries > 1) blockLog.tries = tries
|
|
}
|
|
}
|
|
|
|
private async handleBlockError(
|
|
error: unknown,
|
|
ctx: ExecutionContext,
|
|
node: DAGNode,
|
|
block: SerializedBlock,
|
|
blockStartPromise: Promise<void> | undefined,
|
|
startTime: number,
|
|
blockLog: BlockLog | undefined,
|
|
inputsForLog: Record<string, any>,
|
|
inputDisplayRegistry: ResolvedSecretTraceRegistry | undefined,
|
|
isSentinel: boolean,
|
|
phase: 'input_resolution' | 'execution',
|
|
streamingPartialOutput?: Record<string, any>
|
|
): 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) ?? {})
|
|
|
|
// Routine user Stop on Agent streams: don't paint a failed agent block
|
|
// (workflow is already cancelled). Timeouts abort with reason `'timeout'`.
|
|
// Non-agent blocks (HTTP, Function, etc.) still fail normally on AbortError
|
|
// so logs don't show a green empty success.
|
|
const isAbort =
|
|
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
(error instanceof Error && error.name === 'AbortError')
|
|
const isTimeout = isTimeoutAbortReason(ctx.abortSignal?.reason)
|
|
const isAgentBlock = block.metadata?.id === BlockType.AGENT
|
|
if (isAbort && !isTimeout && ctx.abortSignal?.aborted && isAgentBlock) {
|
|
const softOutput: NormalizedBlockOutput = {
|
|
content: '',
|
|
}
|
|
const softOutputProvenance =
|
|
ctx.resolvedSecretTraceRegistry?.exportCommittedProvenanceForValue(softOutput)
|
|
|
|
this.setNodeOutput(node, softOutput, duration, softOutputProvenance)
|
|
|
|
if (blockLog) {
|
|
blockLog.endedAt = endedAt
|
|
blockLog.durationMs = duration
|
|
blockLog.success = true
|
|
blockLog.error = undefined
|
|
blockLog.input = this.projectInputsForDisplay(input, block, inputDisplayRegistry)
|
|
blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block })
|
|
}
|
|
|
|
this.execLogger.info('Block stream aborted by client; soft-completing', {
|
|
blockId: node.id,
|
|
blockType: block.metadata?.id,
|
|
})
|
|
|
|
if (!isSentinel && blockLog) {
|
|
const displayInput = this.projectInputsForDisplay(input, block, inputDisplayRegistry)
|
|
const displayOutput = filterOutputForLog(block.metadata?.id || '', softOutput, { block })
|
|
const displayProvenance =
|
|
ctx.resolvedSecretTraceRegistry?.exportCommittedProvenanceForValue({
|
|
input: displayInput,
|
|
output: displayOutput,
|
|
})
|
|
this.setBlockLogDisplayProvenance(blockLog, displayProvenance)
|
|
this.fireBlockCompleteCallback(
|
|
blockStartPromise,
|
|
ctx,
|
|
node,
|
|
block,
|
|
displayInput,
|
|
displayOutput,
|
|
duration,
|
|
blockLog.startedAt,
|
|
blockLog.executionOrder,
|
|
blockLog.endedAt,
|
|
undefined,
|
|
softOutputProvenance,
|
|
displayProvenance
|
|
)
|
|
}
|
|
|
|
return softOutput
|
|
}
|
|
|
|
const errorOutput: NormalizedBlockOutput = {
|
|
error: errorMessage,
|
|
}
|
|
|
|
// Keep any answer text already drained before timeout/failure so logs match
|
|
// what was projected to the client.
|
|
const partialContent = streamingPartialOutput?.content
|
|
if (typeof partialContent === 'string' && partialContent) {
|
|
errorOutput.content = partialContent
|
|
}
|
|
|
|
// Only real workflow blocks surface a child workflow name. A custom block's
|
|
// source workflow is never named to its consumer — and before the handler
|
|
// resolves the real name this field still holds the source workflow id, so
|
|
// an early throw (e.g. the call-chain depth limit) would leak it outright.
|
|
if (ChildWorkflowError.isChildWorkflowError(error)) {
|
|
if (isWorkflowBlockType(block.metadata?.id)) {
|
|
errorOutput.childWorkflowName = error.childWorkflowName
|
|
if (error.childWorkflowSnapshotId) {
|
|
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
|
|
}
|
|
}
|
|
// A custom block's consumer gets a machine-readable failure class and an
|
|
// opaque handle to the failed run — enough to branch on and to quote in a
|
|
// support request, without naming anything inside the source workflow.
|
|
if (error.consumerFacing) {
|
|
errorOutput.errorType = error.consumerFacing.errorType
|
|
if (error.consumerFacing.ref) {
|
|
errorOutput.errorRef = error.consumerFacing.ref
|
|
}
|
|
}
|
|
}
|
|
|
|
const errorRegistry = ctx.errorResolvedSecretTraceRegistry ?? ctx.resolvedSecretTraceRegistry
|
|
const errorOutputProvenance = errorRegistry?.exportCommittedProvenanceForValue(errorOutput)
|
|
this.setNodeOutput(node, errorOutput, duration, errorOutputProvenance)
|
|
|
|
if (blockLog) {
|
|
blockLog.endedAt = endedAt
|
|
blockLog.durationMs = duration
|
|
blockLog.success = false
|
|
blockLog.error = errorMessage
|
|
blockLog.input = this.projectInputsForDisplay(input, block, inputDisplayRegistry)
|
|
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
|
|
|
|
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) {
|
|
blockLog.childTraceSpans = error.childTraceSpans
|
|
}
|
|
// A failed custom block still has its own child run to join at read time.
|
|
if (ChildWorkflowError.isChildWorkflowError(error) && error.childExecutionId) {
|
|
blockLog.childExecution = { executionId: error.childExecutionId }
|
|
}
|
|
}
|
|
|
|
const diagnosticRegistry = ctx.errorResolvedSecretTraceRegistry
|
|
? ctx.errorResolvedSecretTraceRegistry
|
|
: inputDisplayRegistry?.forkForToolCall()
|
|
if (
|
|
!ctx.errorResolvedSecretTraceRegistry &&
|
|
diagnosticRegistry &&
|
|
ctx.resolvedSecretTraceRegistry &&
|
|
ctx.resolvedSecretTraceRegistry !== inputDisplayRegistry
|
|
) {
|
|
diagnosticRegistry.mergeToolCallRegistry(ctx.resolvedSecretTraceRegistry)
|
|
}
|
|
const errorDiagnostic = projectResolvedSecretDiagnosticError(
|
|
error,
|
|
diagnosticRegistry ?? ctx.resolvedSecretTraceRegistry
|
|
)
|
|
|
|
this.execLogger.error(
|
|
phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed',
|
|
{
|
|
blockId: node.id,
|
|
blockType: block.metadata?.id,
|
|
...errorDiagnostic,
|
|
}
|
|
)
|
|
|
|
if (!isSentinel && blockLog) {
|
|
const childWorkflowInstanceId = ChildWorkflowError.isChildWorkflowError(error)
|
|
? error.childWorkflowInstanceId
|
|
: undefined
|
|
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
|
|
const displayInput = this.projectInputsForDisplay(input, block, inputDisplayRegistry)
|
|
const displayProvenance = errorRegistry?.exportCommittedProvenanceForValue({
|
|
input: displayInput,
|
|
output: displayOutput,
|
|
})
|
|
this.setBlockLogDisplayProvenance(blockLog, displayProvenance)
|
|
this.fireBlockCompleteCallback(
|
|
blockStartPromise,
|
|
ctx,
|
|
node,
|
|
block,
|
|
displayInput,
|
|
displayOutput,
|
|
duration,
|
|
blockLog.startedAt,
|
|
blockLog.executionOrder,
|
|
blockLog.endedAt,
|
|
childWorkflowInstanceId,
|
|
errorOutputProvenance,
|
|
displayProvenance
|
|
)
|
|
}
|
|
|
|
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,
|
|
...errorDiagnostic,
|
|
})
|
|
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 }
|
|
}
|
|
|
|
/** Builds the log-facing input copy from resolver-recorded projections only. */
|
|
private projectInputsForDisplay(
|
|
inputs: Record<string, any>,
|
|
block: SerializedBlock | undefined,
|
|
registry: ResolvedSecretTraceRegistry | undefined
|
|
): Record<string, any> {
|
|
const projection = registry?.projectResolvedInputSelection(inputs)
|
|
if (projection && !projection.complete) return {}
|
|
return this.sanitizeInputsForLog(projection?.value ?? inputs, block)
|
|
}
|
|
|
|
/**
|
|
* 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>,
|
|
block?: SerializedBlock
|
|
): Record<string, any> {
|
|
const blockType = block?.metadata?.id
|
|
const privateInputIds = new Set(block?.privateInputIds ?? [])
|
|
// 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 = isRecordLike(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 ||
|
|
privateInputIds.has(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,
|
|
...projectResolvedSecretDiagnosticError(error, ctx.resolvedSecretTraceRegistry),
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1,
|
|
displayResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
|
): 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,
|
|
...(resolvedSecretTraceProvenance ? { resolvedSecretTraceProvenance } : {}),
|
|
...(displayResolvedSecretTraceProvenance ? { displayResolvedSecretTraceProvenance } : {}),
|
|
executionTime: duration,
|
|
startedAt,
|
|
executionOrder,
|
|
endedAt,
|
|
childWorkflowInstanceId,
|
|
},
|
|
iterationContext,
|
|
ctx.childWorkflowContext
|
|
)
|
|
})().catch((error) => {
|
|
this.execLogger.warn('Block completion callback failed', {
|
|
blockId,
|
|
blockType,
|
|
...projectResolvedSecretDiagnosticError(error, ctx.resolvedSecretTraceRegistry),
|
|
})
|
|
})
|
|
}
|
|
|
|
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[]
|
|
): Promise<void> {
|
|
const blockId = node.id
|
|
const piiEnabled = Boolean(ctx.piiBlockOutputRedaction?.enabled)
|
|
// Live-forward only when a client stream exists and PII redaction is off.
|
|
const forwardToClient = Boolean(ctx.onStream) && !piiEnabled
|
|
const projectStreamDiagnosticError = (error: unknown): Record<string, unknown> => {
|
|
const sourceRegistry = streamingExec.diagnosticResolvedSecretTraceRegistry
|
|
const resultRegistry = ctx.resolvedSecretTraceRegistry
|
|
if (!sourceRegistry || sourceRegistry === resultRegistry) {
|
|
return projectResolvedSecretDiagnosticError(error, resultRegistry)
|
|
}
|
|
const diagnosticRegistry = sourceRegistry.forkForToolCall()
|
|
if (resultRegistry) diagnosticRegistry.mergeToolCallRegistry(resultRegistry)
|
|
return projectResolvedSecretDiagnosticError(error, diagnosticRegistry)
|
|
}
|
|
|
|
const responseFormat =
|
|
resolvedInputs?.responseFormat ??
|
|
(block.config?.params as Record<string, any> | undefined)?.responseFormat ??
|
|
(block.config as Record<string, any> | undefined)?.responseFormat
|
|
|
|
const streamFormat = streamingExec.streamFormat ?? 'text'
|
|
const pump = createAgentStreamPump({
|
|
source: streamingExec.stream,
|
|
streamFormat,
|
|
// No live consumer → sink-mode so we never buffer into an unread text stream.
|
|
sinkMode: !forwardToClient,
|
|
abortSignal: ctx.abortSignal,
|
|
})
|
|
|
|
let onStreamPromise: Promise<void> | undefined
|
|
let processedClientStream: ReadableStream<Uint8Array> | undefined
|
|
|
|
if (forwardToClient && ctx.onStream && pump.textStream) {
|
|
const {
|
|
diagnosticResolvedSecretTraceRegistry: _diagnosticRegistry,
|
|
...streamingExecutionForConsumer
|
|
} = streamingExec
|
|
processedClientStream = streamingResponseFormatProcessor.processStream(
|
|
pump.textStream,
|
|
blockId,
|
|
selectedOutputs,
|
|
responseFormat
|
|
)
|
|
|
|
// Start onStream without awaiting so a sync `subscribe(sink)` can run before
|
|
// the first provider pull, then read the projected text stream concurrently
|
|
// with `pump.run()`.
|
|
onStreamPromise = ctx
|
|
.onStream({
|
|
...streamingExecutionForConsumer,
|
|
stream: processedClientStream,
|
|
streamFormat: 'text',
|
|
subscribe: pump.subscribe,
|
|
// processStream returns the input stream identity when no
|
|
// response-format extraction applies.
|
|
clientStreamTransformed: processedClientStream !== pump.textStream,
|
|
displayResolvedSecretTraceProvenance:
|
|
ctx.resolvedSecretTraceRegistry?.exportCommittedProvenanceForValue(resolvedInputs),
|
|
})
|
|
.catch(async (error) => {
|
|
this.execLogger.error('Error in onStream callback', {
|
|
blockId,
|
|
...projectStreamDiagnosticError(error),
|
|
})
|
|
await processedClientStream?.cancel().catch(() => {})
|
|
})
|
|
}
|
|
|
|
let pumpResult
|
|
try {
|
|
pumpResult = await pump.run()
|
|
} catch (error) {
|
|
this.execLogger.error('Error reading stream for block', {
|
|
blockId,
|
|
...projectStreamDiagnosticError(error),
|
|
})
|
|
if (onStreamPromise) {
|
|
await onStreamPromise.catch(() => {})
|
|
}
|
|
throw error instanceof Error ? error : new Error(String(error))
|
|
}
|
|
|
|
if (onStreamPromise) {
|
|
await onStreamPromise
|
|
}
|
|
|
|
// Timeout still fails the block, but keep any drained answer text so logs
|
|
// match what was already projected to the client before the deadline.
|
|
// User Stop soft-completes below so logs don't show a scary red agent block
|
|
// for a routine cancel (workflow status remains `cancelled` via abort).
|
|
if (pumpResult.cancelled && pumpResult.cancelReason === 'timeout') {
|
|
const truncated = pumpResult.answerText
|
|
if (truncated && streamingExec.execution?.output) {
|
|
streamingExec.execution.output.content = truncated
|
|
}
|
|
this.execLogger.warn('Stream timed out; persisting drained answer before failing block', {
|
|
blockId,
|
|
hasContent: Boolean(truncated),
|
|
})
|
|
throw new DOMException('Provider request timed out', 'AbortError')
|
|
}
|
|
|
|
// Provider onComplete may have attached thinking to timing segments during drain.
|
|
// Under PII redaction, never retain raw thinking in traces.
|
|
if (piiEnabled) {
|
|
stripThinkingContentFromOutput(streamingExec.execution?.output)
|
|
}
|
|
|
|
// User/unknown cancel: persist truncated answer when present, then return.
|
|
if (pumpResult.cancelled) {
|
|
const truncated = pumpResult.answerText
|
|
if (truncated && streamingExec.execution?.output) {
|
|
streamingExec.execution.output.content = truncated
|
|
}
|
|
this.execLogger.info('Stream cancelled by client; soft-completing agent block', {
|
|
blockId,
|
|
cancelReason: pumpResult.cancelReason,
|
|
hasContent: Boolean(truncated),
|
|
})
|
|
return
|
|
}
|
|
|
|
// If the pump did not fully drain (should be rare when not cancelled), skip
|
|
// persistence of potentially truncated answer text.
|
|
if (!pumpResult.fullyDrained) {
|
|
this.execLogger.warn(
|
|
'Stream consumer exited before source drained; skipping content persistence',
|
|
{ blockId }
|
|
)
|
|
return
|
|
}
|
|
|
|
let fullContent = pumpResult.answerText
|
|
if (!fullContent) {
|
|
return
|
|
}
|
|
|
|
if (piiEnabled && ctx.piiBlockOutputRedaction) {
|
|
// Mask before writing to `execution.output` or `onFullContent`.
|
|
fullContent = await redactObjectStrings(fullContent, {
|
|
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
|
|
language: ctx.piiBlockOutputRedaction.language,
|
|
customPatterns: ctx.piiBlockOutputRedaction.customPatterns,
|
|
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,
|
|
...projectStreamDiagnosticError(error),
|
|
})
|
|
}
|
|
}
|
|
if (!parsedForFormat) {
|
|
executionOutput.content = fullContent
|
|
}
|
|
}
|
|
|
|
if (streamingExec.onFullContent) {
|
|
try {
|
|
await streamingExec.onFullContent(fullContent)
|
|
} catch (error) {
|
|
this.execLogger.error('onFullContent callback failed', {
|
|
blockId,
|
|
...projectStreamDiagnosticError(error),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Removes retained thinking from provider timing segments (PII safe default). */
|
|
function stripThinkingContentFromOutput(output: unknown): void {
|
|
if (!output || typeof output !== 'object') return
|
|
const providerTiming = (output as { providerTiming?: { timeSegments?: unknown } }).providerTiming
|
|
const segments = providerTiming?.timeSegments
|
|
if (!Array.isArray(segments)) return
|
|
for (const segment of segments) {
|
|
if (segment && typeof segment === 'object' && 'thinkingContent' in segment) {
|
|
;(segment as { thinkingContent?: string }).thinkingContent = undefined
|
|
}
|
|
}
|
|
}
|