Files
simstudioai--sim/apps/sim/executor/handlers/workflow/workflow-handler.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

921 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain'
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { type CustomBlockOutput, isCustomBlockType } from '@/blocks/custom/build-config'
import type { BlockOutput } from '@/blocks/types'
import { Executor } from '@/executor'
import { BlockType, DEFAULTS, HTTP } from '@/executor/constants'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import type { WorkflowNodeMetadata } from '@/executor/execution/types'
import type {
BlockHandler,
ExecutionContext,
ExecutionResult,
StreamingExecution,
} from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { getIterationContext } from '@/executor/utils/iteration-context'
import { parseJSON } from '@/executor/utils/json'
import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
import { Serializer } from '@/serializer'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('WorkflowBlockHandler')
/** Read a dot-path (e.g. `content.text`) out of a block output object. */
function getValueAtPath(source: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[key]
return undefined
}, source)
}
/**
* Remap a custom block's resolved input mapping from source-field ids to the
* child workflow's current field names. The consumer's sub-block values are keyed
* by the stable field id (so renames don't cook them); the child is addressed by
* name. Legacy fields without an id are keyed by name and pass through unchanged.
* Keys that match no current field are dropped.
*/
/**
* A consumer left a publisher-required custom block input empty. The message is
* consumer-safe (it names only the block's own input labels), so the catch's
* custom-block sanitizer rethrows it verbatim instead of the generic failure.
*/
export class CustomBlockMissingInputsError extends Error {
constructor(message: string) {
super(message)
this.name = 'CustomBlockMissingInputsError'
}
}
/**
* Names of publisher-required custom block inputs the consumer left empty, checked
* against the child's LIVE deployed Start fields — a required override whose field
* was removed is inert, and a field added after publish has no override, so schema
* drift can never block a run. `childWorkflowInput` is the post-remap mapping
* (keyed by field name). Same empty semantics as the serializer's required check.
*/
export function findMissingRequiredCustomBlockInputs(
requiredInputIds: string[],
childBlocks: Record<string, unknown>,
childWorkflowInput: Record<string, unknown>
): string[] {
if (requiredInputIds.length === 0) return []
const requiredIds = new Set(requiredInputIds)
return extractInputFieldsFromBlocks(childBlocks)
.filter((field) => requiredIds.has(field.id ?? field.name))
.filter((field) => {
const value = childWorkflowInput[field.name]
return value === undefined || value === null || value === ''
})
.map((field) => field.name)
}
export function remapCustomBlockInputKeys(
mapping: Record<string, unknown>,
childBlocks: Record<string, unknown>
): Record<string, unknown> {
const fields = extractInputFieldsFromBlocks(childBlocks)
const remapped: Record<string, unknown> = {}
for (const field of fields) {
const key =
field.id && field.id in mapping ? field.id : field.name in mapping ? field.name : null
if (key === null) continue
let value = mapping[key]
// object/array inputs are authored in a JSON code editor, so their value is a
// JSON *string*. Decode it against the child's real Start field type so the
// child receives the actual object/array (or primitive) — not the string
// re-encoded by the mapping's `JSON.stringify` (`"Theodore"` → `\"Theodore\"`).
if ((field.type === 'object' || field.type === 'array') && typeof value === 'string') {
try {
value = JSON.parse(value)
} catch {
// Not valid JSON — pass the raw string through unchanged.
}
}
remapped[field.name] = value
}
return remapped
}
/**
* Canonical hosted-key spend of a child run: the model/tool cost the way the
* parent bills it (recursing nested/iteration spans and de-duping model
* breakdowns), minus the base execution charge the parent applies once itself.
* A naive top-level `cost.total` sum undercounts when spend sits on nested children.
*/
function aggregateChildCost(childTraceSpans: TraceSpan[]): number {
if (childTraceSpans.length === 0) return 0
const summary = calculateCostSummary(childTraceSpans)
return Math.max(0, summary.totalCost - summary.baseExecutionCharge)
}
/**
* A single cost-only span so a FAILED custom block still bills the hosted-key spend
* its child already consumed (`block-executor` bills `error.childTraceSpans`),
* without exposing any of the source workflow's internal spans. Empty when free.
*/
function buildCostCarrierSpans(childCost: number, blockName: string, type: string): TraceSpan[] {
if (childCost <= 0) return []
const now = new Date().toISOString()
return [
{
id: generateId(),
name: blockName,
type,
duration: 0,
startTime: now,
endTime: now,
status: 'error',
cost: { total: childCost },
},
]
}
type WorkflowTraceSpan = TraceSpan & {
metadata?: Record<string, unknown>
children?: WorkflowTraceSpan[]
output?: (Record<string, unknown> & { childTraceSpans?: WorkflowTraceSpan[] }) | null
}
/**
* Handler for workflow blocks that execute other workflows inline.
* Creates sub-execution contexts and manages data flow between parent and child workflows.
*/
export class WorkflowBlockHandler implements BlockHandler {
private serializer = new Serializer()
canHandle(block: SerializedBlock): boolean {
const id = block.metadata?.id
return id === BlockType.WORKFLOW || id === BlockType.WORKFLOW_INPUT || isCustomBlockType(id)
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution> {
return this.executeCore(ctx, block, inputs)
}
async executeWithNode(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata: WorkflowNodeMetadata
): Promise<BlockOutput | StreamingExecution> {
return this.executeCore(ctx, block, inputs, nodeMetadata)
}
private async executeCore(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata?: WorkflowNodeMetadata
): Promise<BlockOutput | StreamingExecution> {
logger.info(`Executing workflow block: ${block.id}`)
const blockTypeId = block.metadata?.id
const isCustomBlock = isCustomBlockType(blockTypeId)
// Custom (deploy-as-block) blocks are an invocation boundary: resolve the bound
// workflow + authority from the DB (never trust the serialized value) and run the
// source workflow's LATEST deployment under its OWNER's authority — the same
// identity a normal deployed API/schedule/webhook run uses — so a cross-workspace
// consumer needs no permission on the source workflow. Owner deletion cascade-
// deletes the workflow → the custom_block row, so the block never orphans.
let workflowId = inputs.workflowId
let loadUserId = ctx.userId
let exposedOutputs: CustomBlockOutput[] = []
let requiredInputIds: string[] = []
if (isCustomBlock) {
const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId)
if (!authority) {
throw new Error('This custom block is no longer available')
}
workflowId = authority.workflowId
loadUserId = authority.ownerUserId
exposedOutputs = authority.exposedOutputs
requiredInputIds = authority.requiredInputIds
}
if (!workflowId) {
throw new Error('No workflow selected for execution')
}
// Always run the latest deployment for custom blocks, even from a draft-context parent run.
const useDeployed = isCustomBlock || ctx.isDeployedContext
let childWorkflowName = workflowId
// Unique ID per invocation — used to correlate child block events with this specific
// workflow block execution, preventing cross-iteration child mixing in loop contexts.
const instanceId = generateId()
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
}
// A custom block runs the source's latest deployment; if the source has been
// undeployed there's nothing to run. Check + throw a clear, consumer-safe
// reason BEFORE the try so the catch's generic sanitizer doesn't mask it (the
// message names no source internals). The block still renders (its schema comes
// from stored curated inputs), so this is the only failure mode to surface.
if (isCustomBlock) {
const deployed = await this.checkChildDeployment(workflowId, loadUserId)
if (!deployed) {
throw new Error('This blocks workflow is not deployed. Redeploy it to use this block.')
}
}
let childWorkflowSnapshotId: string | undefined
try {
if (useDeployed && !isCustomBlock) {
const hasActiveDeployment = await this.checkChildDeployment(workflowId, loadUserId)
if (!hasActiveDeployment) {
throw new Error(
`Child workflow is not deployed. Please deploy the workflow before invoking it.`
)
}
}
const childWorkflow = useDeployed
? await this.loadChildWorkflowDeployed(workflowId, loadUserId)
: await this.loadChildWorkflow(workflowId, ctx.userId)
if (!childWorkflow) {
throw new Error(`Child workflow ${workflowId} not found`)
}
// Custom blocks are org-scoped and deliberately cross-workspace: the source
// workflow lives in the publisher's workspace, not the consumer's. Their
// boundary is the org overlay + `getCustomBlockAuthority`, so the
// same-workspace assert (which guards regular workflow blocks) must be
// skipped or every custom-block invocation from another workspace throws.
if (!isCustomBlock) {
this.assertChildWorkflowInWorkspace(workflowId, childWorkflow.workspaceId, ctx.workspaceId)
}
childWorkflowName = childWorkflow.name || 'Unknown Workflow'
logger.info(
`Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}`
)
let childWorkflowInput: Record<string, any> = {}
if (inputs.inputMapping !== undefined && inputs.inputMapping !== null) {
const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping)
if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
// Custom blocks key their mapping by the source field's stable id so a
// rename never orphans the consumer's value; remap id → current name
// before the child (which is addressed by name) receives it.
const remapped = isCustomBlock
? remapCustomBlockInputKeys(
normalized as Record<string, unknown>,
childWorkflow.rawBlocks || {}
)
: (normalized as Record<string, unknown>)
const cleanedMapping = await lazyCleanupInputMapping(
ctx.workflowId || 'unknown',
block.id,
remapped,
childWorkflow.rawBlocks || {}
)
childWorkflowInput = cleanedMapping as Record<string, any>
} else {
childWorkflowInput = {}
}
} else if (inputs.input !== undefined) {
childWorkflowInput = inputs.input
}
if (isCustomBlock) {
const missing = findMissingRequiredCustomBlockInputs(
requiredInputIds,
childWorkflow.rawBlocks || {},
childWorkflowInput
)
if (missing.length > 0) {
throw new CustomBlockMissingInputsError(
`${block.metadata?.name || 'Custom block'} is missing required fields: ${missing.join(', ')}`
)
}
}
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
workflowId,
childWorkflow.workflowState
)
childWorkflowSnapshotId = childSnapshotResult.snapshot.id
const childDepth = (ctx.childWorkflowContext?.depth ?? 0) + 1
const shouldPropagateCallbacks = childDepth <= DEFAULTS.MAX_SSE_CHILD_DEPTH
if (!shouldPropagateCallbacks) {
logger.info('Dropping SSE callbacks beyond max child depth', {
childDepth,
maxDepth: DEFAULTS.MAX_SSE_CHILD_DEPTH,
childWorkflowName,
})
}
if (shouldPropagateCallbacks) {
const effectiveBlockId = nodeMetadata
? (nodeMetadata.originalBlockId ?? nodeMetadata.nodeId)
: block.id
const iterationContext = nodeMetadata ? getIterationContext(ctx, nodeMetadata) : undefined
await ctx.onChildWorkflowInstanceReady?.(
effectiveBlockId,
instanceId,
iterationContext,
nodeMetadata?.executionOrder,
ctx.childWorkflowContext
)
}
// A custom block is an invocation boundary: the child runs under the SOURCE
// workflow owner's identity, workspace, and environment — not the consumer's —
// so it resolves credentials/integrations/env exactly as published and the
// consumer needs no access to any of them. (Billing still lands on the
// consumer's org, aggregated onto the block above.) Regular workflow blocks
// keep running in the parent's context.
let childUserId = ctx.userId
let childWorkspaceId = ctx.workspaceId
let childEnvVarValues = ctx.environmentVariables
if (isCustomBlock) {
if (!loadUserId) {
throw new Error('Custom block source workflow has no owner')
}
if (!childWorkflow.workspaceId) {
throw new Error('Custom block source workflow has no workspace')
}
childUserId = loadUserId
childWorkspaceId = childWorkflow.workspaceId
const ownerEnv = await getPersonalAndWorkspaceEnv(loadUserId, childWorkflow.workspaceId)
childEnvVarValues = { ...ownerEnv.personalDecrypted, ...ownerEnv.workspaceDecrypted }
}
const subExecutor = new Executor({
workflow: childWorkflow.serializedState,
workflowInput: childWorkflowInput,
envVarValues: childEnvVarValues,
workflowVariables: childWorkflow.variables || {},
contextExtensions: {
isChildExecution: true,
// Custom blocks always run the source's latest deployment, so the child
// context must be deployed too — otherwise its metadata treats the
// deployed graph as draft. `useDeployed` folds in the custom-block case.
isDeployedContext: useDeployed,
enforceCredentialAccess: ctx.enforceCredentialAccess,
workspaceId: childWorkspaceId,
userId: childUserId,
executionId: ctx.executionId,
abortSignal: ctx.abortSignal,
// Propagate in-flight block-output redaction into child workflows so
// nested blocks mask outputs too (recurses: each child forwards it).
piiBlockOutputRedaction: ctx.piiBlockOutputRedaction,
callChain: childCallChain,
...(shouldPropagateCallbacks && {
onBlockStart: ctx.onBlockStart,
onBlockComplete: ctx.onBlockComplete,
onStream: ctx.onStream,
onChildWorkflowInstanceReady: ctx.onChildWorkflowInstanceReady,
childWorkflowContext: {
parentBlockId: instanceId,
workflowName: childWorkflowName,
workflowId,
depth: childDepth,
},
}),
},
})
const startTime = performance.now()
const result = await subExecutor.execute(workflowId)
const executionResult = this.toExecutionResult(result)
const duration = performance.now() - startTime
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`, {
success: executionResult.success,
hasLogs: (executionResult.logs?.length ?? 0) > 0,
})
const childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
const mappedResult = this.mapChildOutputToParent(
executionResult,
workflowId,
childWorkflowName,
duration,
instanceId,
childTraceSpans,
childWorkflowSnapshotId
)
// Custom blocks expose only curated outputs — never the child workflow id,
// name, or trace spans. `mapChildOutputToParent` above still runs so failures
// surface identically; we just reshape the successful output.
if (isCustomBlock) {
// The child's spans are stripped for privacy, but they're the only carrier
// of the run's cost into billing — so roll their aggregate cost onto the
// block itself. Custom blocks are org-scoped, so this bills the same org the
// source workflow would bill if run directly, exactly as if it ran the key.
const childCost = aggregateChildCost(childTraceSpans)
return this.projectCustomBlockOutput(executionResult, exposedOutputs, childCost)
}
return mappedResult
} catch (error: unknown) {
logger.error(`Error executing child workflow ${workflowId}:`, error)
// Custom blocks are an invocation boundary: on failure the consumer must not
// see the source workflow's name, nested error text (which names internal
// blocks), trace spans, or execution result — the success path hides all of
// these too. The real error is logged above for the publisher/ops; the
// consumer gets only a generic failure attributed to the block they placed.
// But a child that failed AFTER consuming hosted keys still owes that spend,
// so capture the child's spans server-side, distill to the aggregate cost, and
// carry only that (no internals) so `block-executor` still bills it.
if (isCustomBlock) {
// Missing-required-inputs is the consumer's own mistake and its message
// names only the block's input labels — surface it instead of the generic
// failure so they can actually fix it. The child never ran: no spend.
if (error instanceof CustomBlockMissingInputsError) {
throw new ChildWorkflowError({
message: error.message,
childWorkflowName: block.metadata?.name || 'Custom block',
childWorkflowInstanceId: instanceId,
})
}
let failedChildSpans: WorkflowTraceSpan[] = []
if (hasExecutionResult(error) && error.executionResult.logs) {
failedChildSpans = this.captureChildWorkflowLogs(
error.executionResult,
childWorkflowName,
ctx
)
} else if (ChildWorkflowError.isChildWorkflowError(error)) {
failedChildSpans = error.childTraceSpans
}
const blockName = block.metadata?.name || 'Custom block'
throw new ChildWorkflowError({
message: 'Custom block execution failed',
childWorkflowName: blockName,
childTraceSpans: buildCostCarrierSpans(
aggregateChildCost(failedChildSpans),
blockName,
block.metadata?.id ?? 'custom_block'
),
childWorkflowInstanceId: instanceId,
})
}
let childTraceSpans: WorkflowTraceSpan[] = []
let executionResult: ExecutionResult | undefined
if (hasExecutionResult(error) && error.executionResult.logs) {
executionResult = error.executionResult
logger.info(`Extracting child trace spans from error.executionResult`, {
hasLogs: (executionResult.logs?.length ?? 0) > 0,
logCount: executionResult.logs?.length ?? 0,
})
childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
logger.info(`Captured ${childTraceSpans.length} child trace spans from failed execution`)
} else if (ChildWorkflowError.isChildWorkflowError(error)) {
childTraceSpans = error.childTraceSpans
}
// Build a cleaner error message for nested workflow errors
const errorMessage = this.buildNestedWorkflowErrorMessage(childWorkflowName, error)
throw new ChildWorkflowError({
message: errorMessage,
childWorkflowName,
childTraceSpans,
executionResult,
childWorkflowSnapshotId,
childWorkflowInstanceId: instanceId,
cause: error instanceof Error ? error : undefined,
})
}
}
/**
* Builds a cleaner error message for nested workflow errors.
* Parses nested error messages to extract workflow chain and root error.
*/
private buildNestedWorkflowErrorMessage(childWorkflowName: string, error: unknown): string {
const originalError = getErrorMessage(error, 'Unknown error')
// Extract any nested workflow names from the error message
const { chain, rootError } = this.parseNestedWorkflowError(originalError)
// Add current workflow to the beginning of the chain
chain.unshift(childWorkflowName)
// If we have a chain (nested workflows), format nicely
if (chain.length > 1) {
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
}
// Single workflow failure
return `"${childWorkflowName}" failed: ${rootError}`
}
/**
* Parses a potentially nested workflow error message to extract:
* - The chain of workflow names
* - The actual root error message (preserving the block name prefix for the failing block)
*
* Handles formats like:
* - "workflow-name" failed: error
* - Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error
*/
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
const chain: string[] = []
const remaining = message
// First, check if it's already in chain format
const chainMatch = remaining.match(/^Workflow chain: (.+?) \| (.+)$/)
if (chainMatch) {
const chainPart = chainMatch[1]
const errorPart = chainMatch[2]
chain.push(...chainPart.split(' → ').map((s) => s.trim()))
return { chain, rootError: errorPart }
}
// Extract workflow names from patterns like:
// - "workflow-name" failed:
// - Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null
let lastIndex = 0
match = workflowPattern.exec(remaining)
while (match !== null) {
chain.push(match[1])
lastIndex = match.index + match[0].length
match = workflowPattern.exec(remaining)
}
// The root error is everything after the last match
// Keep the block name prefix (e.g., Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
return { chain, rootError: rootError.trim() || 'Unknown error' }
}
/**
* Ensures the child workflow belongs to the same workspace as the executing
* context before any child execution starts. Blocks silent cross-workspace
* execution (e.g. a manual workflow id still pointing at the source
* workspace after a fork), which would otherwise run the foreign workflow
* with the parent workspace's environment and billing. Fails closed when the
* executing context carries no workspace id: every server execution path
* populates it via execution-core, so a missing value indicates a context
* that must not silently bypass the check. The error message intentionally
* omits the foreign workspace id.
*/
private assertChildWorkflowInWorkspace(
childWorkflowId: string,
childWorkspaceId: string | null | undefined,
parentWorkspaceId: string | undefined
): void {
if (!parentWorkspaceId) {
throw new Error(
`Cannot execute child workflow ${childWorkflowId}: executing context has no workspace`
)
}
if (childWorkspaceId !== parentWorkspaceId) {
throw new Error(
`Child workflow ${childWorkflowId} belongs to a different workspace and cannot be executed`
)
}
}
private async loadChildWorkflow(workflowId: string, userId?: string) {
const headers = await buildAuthHeaders(userId)
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
const response = await fetch(url.toString(), { headers })
if (!response.ok) {
await response.text().catch(() => {})
if (response.status === HTTP.STATUS.NOT_FOUND) {
logger.warn(`Child workflow ${workflowId} not found`)
return null
}
throw new Error(`Failed to fetch workflow: ${response.status} ${response.statusText}`)
}
const { data: workflowData } = await response.json()
if (!workflowData) {
throw new Error(`Child workflow ${workflowId} returned empty data`)
}
logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`)
const workflowState = workflowData.state
if (!workflowState || !workflowState.blocks) {
throw new Error(`Child workflow ${workflowId} has invalid state`)
}
const serializedWorkflow = this.serializer.serializeWorkflow(
workflowState.blocks,
workflowState.edges || [],
workflowState.loops || {},
workflowState.parallels || {},
true
)
const workflowVariables = (workflowData.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...workflowState,
variables: workflowVariables,
metadata: {
...(workflowState.metadata || {}),
name: workflowData.name || DEFAULTS.WORKFLOW_NAME,
},
}
if (Object.keys(workflowVariables).length > 0) {
logger.info(
`Loaded ${Object.keys(workflowVariables).length} variables for child workflow: ${workflowId}`
)
}
return {
name: workflowData.name,
workspaceId: (workflowData.workspaceId ?? null) as string | null,
serializedState: serializedWorkflow,
variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: workflowState.blocks,
}
}
private async checkChildDeployment(workflowId: string, userId?: string): Promise<boolean> {
try {
const headers = await buildAuthHeaders(userId)
const url = buildAPIUrl(`/api/workflows/${workflowId}/deployed`)
const response = await fetch(url.toString(), {
headers,
cache: 'no-store',
})
if (!response.ok) return false
const json = await response.json()
return !!json?.data?.deployedState || !!json?.deployedState
} catch (e) {
logger.error(`Failed to check child deployment for ${workflowId}:`, e)
return false
}
}
private async loadChildWorkflowDeployed(workflowId: string, userId?: string) {
const headers = await buildAuthHeaders(userId)
const deployedUrl = buildAPIUrl(`/api/workflows/${workflowId}/deployed`)
const deployedRes = await fetch(deployedUrl.toString(), {
headers,
cache: 'no-store',
})
if (!deployedRes.ok) {
if (deployedRes.status === HTTP.STATUS.NOT_FOUND) {
return null
}
throw new Error(
`Failed to fetch deployed workflow: ${deployedRes.status} ${deployedRes.statusText}`
)
}
const deployedJson = await deployedRes.json()
const deployedState = deployedJson?.data?.deployedState || deployedJson?.deployedState
if (!deployedState || !deployedState.blocks) {
throw new Error(`Deployed state missing or invalid for child workflow ${workflowId}`)
}
const metaUrl = buildAPIUrl(`/api/workflows/${workflowId}`)
const metaRes = await fetch(metaUrl.toString(), {
headers,
cache: 'no-store',
})
if (!metaRes.ok) {
throw new Error(`Failed to fetch workflow metadata: ${metaRes.status} ${metaRes.statusText}`)
}
const metaJson = await metaRes.json()
const wfData = metaJson?.data
const serializedWorkflow = this.serializer.serializeWorkflow(
deployedState.blocks,
deployedState.edges || [],
deployedState.loops || {},
deployedState.parallels || {},
true
)
const workflowVariables = (wfData?.variables as Record<string, any>) || {}
const childName = wfData?.name || DEFAULTS.WORKFLOW_NAME
const workflowStateWithVariables = {
...deployedState,
variables: workflowVariables,
metadata: {
...(deployedState.metadata || {}),
name: childName,
},
}
return {
name: childName,
workspaceId: (wfData?.workspaceId ?? null) as string | null,
serializedState: serializedWorkflow,
variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: deployedState.blocks,
}
}
/**
* Captures and transforms child workflow logs into trace spans
*/
private captureChildWorkflowLogs(
childResult: ExecutionResult,
childWorkflowName: string,
parentContext: ExecutionContext
): WorkflowTraceSpan[] {
try {
if (!childResult.logs || !Array.isArray(childResult.logs)) {
return []
}
const { traceSpans } = buildTraceSpans(childResult)
if (!traceSpans || traceSpans.length === 0) {
return []
}
const processedSpans = this.processChildWorkflowSpans(traceSpans)
if (processedSpans.length === 0) {
return []
}
const transformedSpans = processedSpans.map((span) =>
this.transformSpanForChildWorkflow(span, childWorkflowName)
)
return transformedSpans
} catch (error) {
logger.error(`Error capturing child workflow logs for ${childWorkflowName}:`, error)
return []
}
}
private transformSpanForChildWorkflow(
span: WorkflowTraceSpan,
childWorkflowName: string
): WorkflowTraceSpan {
const metadata: Record<string, unknown> = {
...(span.metadata ?? {}),
isFromChildWorkflow: true,
childWorkflowName,
}
const transformedChildren = Array.isArray(span.children)
? span.children.map((childSpan) =>
this.transformSpanForChildWorkflow(childSpan, childWorkflowName)
)
: undefined
return {
...span,
metadata,
...(transformedChildren ? { children: transformedChildren } : {}),
}
}
private processChildWorkflowSpans(spans: TraceSpan[]): WorkflowTraceSpan[] {
const processed: WorkflowTraceSpan[] = []
spans.forEach((span) => {
if (this.isSyntheticWorkflowWrapper(span)) {
if (span.children && Array.isArray(span.children)) {
processed.push(...this.processChildWorkflowSpans(span.children))
}
return
}
const workflowSpan: WorkflowTraceSpan = {
...span,
}
if (Array.isArray(workflowSpan.children)) {
workflowSpan.children = this.processChildWorkflowSpans(workflowSpan.children as TraceSpan[])
}
processed.push(workflowSpan)
})
return processed
}
private toExecutionResult(result: ExecutionResult | StreamingExecution): ExecutionResult {
return 'execution' in result ? result.execution : result
}
private isSyntheticWorkflowWrapper(span: TraceSpan | undefined): boolean {
if (!span || span.type !== 'workflow') return false
return !span.blockId
}
/**
* Shape a custom block's successful output. With curated `exposedOutputs`, each
* maps a child block output (blockId + dot-path, read from the child's per-block
* logs) to a named top-level field. With none, exposes the child's whole
* `result`. Never leaks child workflow id/name/trace spans.
*/
private projectCustomBlockOutput(
executionResult: ExecutionResult,
exposedOutputs: CustomBlockOutput[],
childCost: number
): BlockOutput {
// Aggregate child cost only (never the child's spans/model breakdown) so the
// run is billed while the source workflow's internals stay hidden.
const cost = childCost > 0 ? { cost: { total: childCost } } : {}
if (exposedOutputs.length === 0) {
return { success: true, result: executionResult.output ?? {}, ...cost }
}
const logs = executionResult.logs ?? []
const output: Record<string, unknown> = { success: true, ...cost }
for (const { blockId, path, name } of exposedOutputs) {
const log =
[...logs].reverse().find((l) => l.blockId === blockId && l.success) ??
[...logs].reverse().find((l) => l.blockId === blockId)
output[name] = log ? getValueAtPath(log.output, path) : undefined
}
return output as BlockOutput
}
private mapChildOutputToParent(
childResult: ExecutionResult,
childWorkflowId: string,
childWorkflowName: string,
duration: number,
instanceId: string,
childTraceSpans?: WorkflowTraceSpan[],
childWorkflowSnapshotId?: string
): BlockOutput {
const success = childResult.success !== false
const result = childResult.output || {}
if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`)
throw new ChildWorkflowError({
message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
childWorkflowName,
childTraceSpans: childTraceSpans || [],
childWorkflowSnapshotId,
childWorkflowInstanceId: instanceId,
})
}
const output: BlockOutput = {
success: true,
childWorkflowName,
childWorkflowId,
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
result,
childTraceSpans: childTraceSpans || [],
_childWorkflowInstanceId: instanceId,
}
return output
}
}