Files
simstudioai--sim/apps/sim/app/api/workflows/[id]/execute/route.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1828 lines
64 KiB
TypeScript

import { db } from '@sim/db'
import { workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId, isValidUuid } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { executeWorkflowBodySchema } from '@/lib/api/contracts/workflows'
import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid'
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
import {
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
isWorkspaceApiExecutionEntitled,
} from '@/lib/billing/core/api-access'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import {
createTimeoutAbortController,
getTimeoutErrorMessage,
isTimeoutError,
} from '@/lib/core/execution-limits'
import { isCrossSiteSessionRequest } from '@/lib/core/security/same-origin'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import {
assertContentLengthWithinLimit,
isPayloadSizeLimitError,
PayloadSizeLimitError,
readStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
buildNextCallChain,
parseCallChain,
SIM_VIA_HEADER,
validateCallChain,
} from '@/lib/execution/call-chain'
import {
createExecutionEventWriter,
flushExecutionStreamReplayBuffer,
initializeExecutionStreamMeta,
type TerminalExecutionStreamStatus,
} from '@/lib/execution/event-buffer'
import { processInputFileFields } from '@/lib/execution/files'
import {
registerManualExecutionAborter,
unregisterManualExecutionAborter,
} from '@/lib/execution/manual-cancellation'
import { containsLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import {
MAX_MCP_WORKFLOW_RESPONSE_BYTES,
MCP_TOOL_BRIDGE_ACTOR_HEADER,
MCP_TOOL_BRIDGE_HEADER,
} from '@/lib/mcp/constants'
import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence'
import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
import {
PublicApiNotAllowedError,
validatePublicApiAllowed,
} from '@/ee/access-control/utils/permission-check'
import { normalizeName } from '@/executor/constants'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type {
ChildWorkflowContext,
ExecutionMetadata,
IterationContext,
SerializableExecutionState,
} from '@/executor/execution/types'
import type { BlockLog, NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
import { getExecutionErrorStatus, hasExecutionResult } from '@/executor/utils/errors'
import { Serializer } from '@/serializer'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
const logger = createLogger('WorkflowExecuteAPI')
const MAX_WORKFLOW_EXECUTE_BODY_BYTES = 10 * 1024 * 1024
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
async function compactRoutePayload<T>(
value: T,
context: {
workspaceId?: string
workflowId?: string
executionId?: string
userId?: string
preserveUserFileBase64?: boolean
preserveRoot?: boolean
rejectLargeValues?: boolean
rejectLargeValueLabel?: string
thresholdBytes?: number
}
): Promise<T> {
return compactExecutionPayload(value, { ...context, requireDurable: true })
}
async function compactWorkflowResponseOutput<T>(
value: T,
context: {
workspaceId?: string
workflowId?: string
executionId?: string
userId?: string
rejectLargeInlineOutput: boolean
}
): Promise<T> {
const compacted = await compactRoutePayload(value, {
workspaceId: context.workspaceId,
workflowId: context.workflowId,
executionId: context.executionId,
userId: context.userId,
preserveUserFileBase64: true,
preserveRoot: !context.rejectLargeInlineOutput,
rejectLargeValues: context.rejectLargeInlineOutput,
rejectLargeValueLabel: 'Workflow execution response',
thresholdBytes: context.rejectLargeInlineOutput ? MAX_MCP_WORKFLOW_RESPONSE_BYTES : undefined,
})
if (context.rejectLargeInlineOutput && containsLargeValueRef(compacted)) {
throw new PayloadSizeLimitError({
label: 'Workflow execution response',
maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES,
observedBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES + 1,
})
}
return compacted
}
async function readExecuteRequestBody(req: NextRequest): Promise<unknown> {
assertContentLengthWithinLimit(
req.headers,
MAX_WORKFLOW_EXECUTE_BODY_BYTES,
'Workflow execution request body'
)
const buffer = await readStreamToBufferWithLimit(req.body, {
maxBytes: MAX_WORKFLOW_EXECUTE_BODY_BYTES,
label: 'Workflow execution request body',
signal: req.signal,
})
if (buffer.byteLength === 0) return {}
return JSON.parse(buffer.toString('utf-8'))
}
function clientCancelledResponse(): NextResponse {
return NextResponse.json({ success: false, error: 'Client cancelled request' }, { status: 499 })
}
function payloadTooLargeResponse(message = 'Workflow execution response exceeds maximum size') {
return NextResponse.json(
{ success: false, error: message, code: 'workflow_response_too_large' },
{ status: 413 }
)
}
function resolveOutputIds(
selectedOutputs: string[] | undefined,
blocks: Record<string, any>
): string[] | undefined {
if (!selectedOutputs || selectedOutputs.length === 0) {
return selectedOutputs
}
return selectedOutputs.map((outputId) => {
const underscoreIndex = outputId.indexOf('_')
const dotIndex = outputId.indexOf('.')
if (underscoreIndex > 0) {
const maybeUuid = outputId.substring(0, underscoreIndex)
if (isValidUuid(maybeUuid)) {
return outputId
}
}
if (dotIndex > 0) {
const maybeUuid = outputId.substring(0, dotIndex)
if (isValidUuid(maybeUuid)) {
return `${outputId.substring(0, dotIndex)}_${outputId.substring(dotIndex + 1)}`
}
}
if (isValidUuid(outputId)) {
return outputId
}
if (dotIndex === -1) {
logger.warn(`Invalid output ID format (missing dot): ${outputId}`)
return outputId
}
const blockName = outputId.substring(0, dotIndex)
const path = outputId.substring(dotIndex + 1)
const normalizedBlockName = normalizeName(blockName)
const block = Object.values(blocks).find((b: any) => {
return normalizeName(b.name || '') === normalizedBlockName
})
if (!block) {
logger.warn(`Block not found for name: ${blockName} (from output ID: ${outputId})`)
return outputId
}
const resolvedId = `${block.id}_${path}`
logger.debug(`Resolved output ID: ${outputId} -> ${resolvedId}`)
return resolvedId
})
}
function bindRequestAbort(
requestSignal: AbortSignal,
timeoutController: ReturnType<typeof createTimeoutAbortController>
): { isRequestAborted: () => boolean; cleanup: () => void } {
let requestAborted = false
const abortFromRequest = () => {
requestAborted = true
timeoutController.abort()
}
if (requestSignal.aborted) {
abortFromRequest()
} else {
requestSignal.addEventListener('abort', abortFromRequest, { once: true })
}
return {
isRequestAborted: () => requestAborted || requestSignal.aborted,
cleanup: () => requestSignal.removeEventListener('abort', abortFromRequest),
}
}
type AsyncExecutionParams = {
requestId: string
workflowId: string
userId: string
workspaceId: string
input: any
triggerType: CoreTriggerType
executionId: string
callChain?: string[]
}
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } =
params
const asyncLogger = logger.withMetadata({
requestId,
workflowId,
workspaceId,
userId,
executionId,
})
const correlation = {
executionId,
requestId,
source: 'workflow' as const,
workflowId,
triggerType,
}
const payload: WorkflowExecutionPayload = {
workflowId,
userId,
workspaceId,
input,
triggerType,
executionId,
requestId,
correlation,
callChain,
executionMode: 'async',
}
try {
const jobQueue = await getJobQueue()
const jobId = await jobQueue.enqueue('workflow-execution', payload, {
metadata: { workflowId, workspaceId, userId, correlation },
})
asyncLogger.info('Queued async workflow execution', { jobId })
if (shouldExecuteInline()) {
void (async () => {
try {
await jobQueue.startJob(jobId)
const output = await executeWorkflowJob(payload)
await jobQueue.completeJob(jobId, output)
} catch (error) {
const errorMessage = toError(error).message
asyncLogger.error('Async workflow execution failed', {
jobId,
error: errorMessage,
})
// Release the reserved slot in case the job never reached
// executeWorkflowJob (e.g. startJob threw) and thus never finalized a
// LoggingSession to free it. Idempotent: a no-op when the job already
// finalized and released.
await releaseExecutionSlot(executionId)
try {
await jobQueue.markJobFailed(jobId, errorMessage)
} catch (markFailedError) {
asyncLogger.error('Failed to mark job as failed', {
jobId,
error: toError(markFailedError).message,
})
}
}
})()
}
return NextResponse.json(
{
success: true,
async: true,
jobId,
executionId,
message: 'Workflow execution queued',
statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`,
},
{ status: 202 }
)
} catch (error: any) {
asyncLogger.error('Failed to queue async execution', error)
// Enqueue failed: no background job will run or finalize a session, so
// release the admission slot reserved during preprocessing.
await releaseExecutionSlot(executionId)
return NextResponse.json({ error: 'Failed to queue async execution' }, { status: 500 })
}
}
/**
* POST /api/workflows/[id]/execute
*
* Unified server-side workflow execution endpoint.
* Supports both SSE streaming (for interactive/manual runs) and direct JSON responses (for background jobs).
*/
export const POST = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const isSessionRequest = req.headers.has('cookie') && !hasExternalApiCredentials(req.headers)
if (isSessionRequest) {
return handleExecutePost(req, params)
}
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
return await handleExecutePost(req, params)
} finally {
ticket.release()
}
}
)
async function handleExecutePost(
req: NextRequest,
params: Promise<{ id: string }>
): Promise<NextResponse | Response> {
const requestId = generateRequestId()
const { id: workflowId } = await params
let reqLogger = logger.withMetadata({ requestId, workflowId })
const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER))
const callChainError = validateCallChain(incomingCallChain)
if (callChainError) {
reqLogger.warn(`Call chain rejected: ${callChainError}`)
return NextResponse.json({ error: callChainError }, { status: 409 })
}
const callChain = buildNextCallChain(incomingCallChain, workflowId)
// Hoisted so the outer catch can release a reserved billing slot when a throw
// after preprocessExecution exits before the stream takes over its release.
let executionId = ''
try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
// CSRF guard: reject session-cookie execution that is provably cross-site
// (a different site driving the user's browser). same-origin and same-site
// are allowed so multi-subdomain deployments (e.g. www.<domain> calling
// <domain>) keep working. Scoped to session auth — API-key / public-API /
// internal-JWT callers don't use cookies. Not a defense against a non-browser
// client forging headers; that's covered by the credit/rate-limit gates.
if (auth.success && auth.authType === AuthType.SESSION && isCrossSiteSessionRequest(req)) {
reqLogger.warn('Rejected cross-site session-authenticated execute request')
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const isMcpBridgeRequest =
auth.authType === AuthType.INTERNAL_JWT && req.headers.get(MCP_TOOL_BRIDGE_HEADER) === 'true'
const useMcpBridgeAuthenticatedUserAsActor =
isMcpBridgeRequest && req.headers.get(MCP_TOOL_BRIDGE_ACTOR_HEADER) === 'authenticated-user'
let userId: string
let isPublicApiAccess = false
let gateWorkspaceId: string | undefined
if (!auth.success || !auth.userId) {
const hasExplicitCredentials =
req.headers.has('x-api-key') || req.headers.get('authorization')?.startsWith('Bearer ')
if (hasExplicitCredentials) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const [wf] = await db
.select({
isPublicApi: workflowTable.isPublicApi,
isDeployed: workflowTable.isDeployed,
userId: workflowTable.userId,
workspaceId: workflowTable.workspaceId,
})
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!wf?.isPublicApi || !wf.isDeployed || !wf.workspaceId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
await validatePublicApiAllowed(wf.userId, wf.workspaceId)
} catch (err) {
if (err instanceof PublicApiNotAllowedError) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
throw err
}
userId = wf.userId
isPublicApiAccess = true
gateWorkspaceId = wf.workspaceId
} else {
userId = auth.userId
}
// Programmatic execution (API key or public API) is gated on the workflow's
// workspace billed account — the same entity MCP/webhooks/chat gate on —
// so a paid workspace is never blocked because an individual is on free.
if (auth.authType === AuthType.API_KEY || isPublicApiAccess) {
if (!gateWorkspaceId) {
const [wfRow] = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
gateWorkspaceId = wfRow?.workspaceId ?? undefined
}
if (!(await isWorkspaceApiExecutionEntitled(gateWorkspaceId))) {
return NextResponse.json(
{ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE },
{ status: 402 }
)
}
}
let body: any = {}
try {
body = await readExecuteRequestBody(req)
} catch (error) {
if (isPayloadSizeLimitError(error)) {
reqLogger.warn('Workflow execution request body exceeded size limit', {
maxBytes: error.maxBytes,
observedBytes: error.observedBytes,
})
return NextResponse.json(
{ error: 'Workflow execution request body exceeds maximum size' },
{ status: 413 }
)
}
if (req.signal.aborted) {
return clientCancelledResponse()
}
reqLogger.warn('Failed to parse request body', { error: toError(error).message })
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
}
const validation = executeWorkflowBodySchema.safeParse(body)
if (!validation.success) {
reqLogger.warn('Invalid request body:', validation.error.issues)
return NextResponse.json(
{
error: 'Invalid request body',
details: validation.error.issues.map((e) => ({
path: e.path.join('.'),
message: e.message,
})),
},
{ status: 400 }
)
}
const defaultTriggerType =
isPublicApiAccess || auth.authType === AuthType.API_KEY ? 'api' : 'manual'
const {
selectedOutputs,
triggerType = defaultTriggerType,
stream: streamParam,
useDraftState,
input: validatedInput,
isClientSession = false,
includeFileBase64,
base64MaxBytes,
workflowStateOverride,
executionId: requestedExecutionId,
triggerBlockId: parsedTriggerBlockId,
startBlockId,
stopAfterBlockId,
runFromBlock: rawRunFromBlock,
parentWorkspaceId,
} = validation.data
const triggerBlockId = parsedTriggerBlockId ?? startBlockId
if (isPublicApiAccess && isClientSession) {
return NextResponse.json(
{ error: 'Public API callers cannot set isClientSession' },
{ status: 400 }
)
}
if (auth.authType === 'api_key') {
if (isClientSession) {
return NextResponse.json(
{ error: 'API key callers cannot set isClientSession' },
{ status: 400 }
)
}
if (workflowStateOverride) {
return NextResponse.json(
{ error: 'API key callers cannot provide workflowStateOverride' },
{ status: 400 }
)
}
if (useDraftState) {
return NextResponse.json(
{ error: 'API key callers cannot execute draft workflow state' },
{ status: 400 }
)
}
}
// Resolve runFromBlock snapshot from executionId if needed
let resolvedRunFromBlock:
| {
startBlockId: string
sourceSnapshot: SerializableExecutionState
sourceExecutionId?: string
}
| undefined
if (rawRunFromBlock) {
if (rawRunFromBlock.sourceSnapshot && auth.authType === 'api_key') {
return NextResponse.json(
{ error: 'API key callers cannot provide runFromBlock.sourceSnapshot' },
{ status: 400 }
)
}
if (rawRunFromBlock.executionId && (auth.authType === 'api_key' || isPublicApiAccess)) {
return NextResponse.json(
{ error: 'External callers cannot resume from stored execution snapshots' },
{ status: 400 }
)
}
if (rawRunFromBlock.sourceSnapshot && !isPublicApiAccess) {
// Public API callers cannot inject arbitrary block state via sourceSnapshot.
// They must use executionId to resume from a server-stored execution state.
resolvedRunFromBlock = {
startBlockId: rawRunFromBlock.startBlockId,
sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState,
}
} else if (rawRunFromBlock.executionId) {
const { getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId } =
await import('@/lib/workflows/executor/execution-state')
const sourceExecution =
rawRunFromBlock.executionId === 'latest'
? await getLatestExecutionStateWithExecutionId(workflowId)
: {
executionId: rawRunFromBlock.executionId,
state: await getExecutionStateForWorkflow(rawRunFromBlock.executionId, workflowId),
}
const snapshot = sourceExecution?.state
if (!snapshot) {
return NextResponse.json(
{
error: `No execution state found for ${rawRunFromBlock.executionId === 'latest' ? 'workflow' : `execution ${rawRunFromBlock.executionId}`}. Run the full workflow first.`,
},
{ status: 400 }
)
}
resolvedRunFromBlock = {
startBlockId: rawRunFromBlock.startBlockId,
sourceSnapshot: snapshot,
sourceExecutionId: sourceExecution.executionId,
}
} else {
return NextResponse.json(
{ error: 'runFromBlock requires either sourceSnapshot or executionId' },
{ status: 400 }
)
}
}
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
const input = isMcpBridgeRequest
? validatedInput
: isPublicApiAccess ||
auth.authType === AuthType.API_KEY ||
auth.authType === AuthType.INTERNAL_JWT
? (() => {
const {
selectedOutputs,
triggerType,
stream,
useDraftState,
includeFileBase64,
base64MaxBytes,
workflowStateOverride,
triggerBlockId: _triggerBlockId,
stopAfterBlockId: _stopAfterBlockId,
runFromBlock: _runFromBlock,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
parentWorkspaceId: _parentWorkspaceId,
...rest
} = body
return Object.keys(rest).length > 0 ? rest : validatedInput
})()
: validatedInput
// Public API callers must not inject arbitrary workflow state overrides (code injection risk).
// stopAfterBlockId and runFromBlock are safe — they control execution flow within the deployed state.
const sanitizedWorkflowStateOverride = isPublicApiAccess ? undefined : workflowStateOverride
// Public API callers always execute the deployed state, never the draft.
const shouldUseDraftState = isPublicApiAccess
? false
: (useDraftState ?? auth.authType === AuthType.SESSION)
const streamHeader = req.headers.get('X-Stream-Response') === 'true'
const enableSSE = streamHeader || streamParam === true
const executionModeHeader = req.headers.get('X-Execution-Mode')
const isAsyncMode = executionModeHeader === 'async'
const requiresWriteExecutionAccess = Boolean(
useDraftState || workflowStateOverride || rawRunFromBlock
)
if (req.signal.aborted) {
return clientCancelledResponse()
}
if (
isAsyncMode &&
(body.useDraftState !== undefined ||
body.workflowStateOverride !== undefined ||
body.runFromBlock !== undefined ||
body.triggerBlockId !== undefined ||
body.stopAfterBlockId !== undefined ||
body.selectedOutputs?.length ||
body.includeFileBase64 !== undefined ||
body.base64MaxBytes !== undefined)
) {
return NextResponse.json(
{ error: 'Async execution does not support draft or override execution controls' },
{ status: 400 }
)
}
executionId = isClientSession && requestedExecutionId ? requestedExecutionId : generateId()
reqLogger = reqLogger.withMetadata({ userId, executionId })
reqLogger.info('Starting server-side execution', {
hasInput: !!input,
triggerType,
authType: auth.authType,
streamParam,
streamHeader,
enableSSE,
isAsyncMode,
})
let loggingTriggerType: CoreTriggerType = 'manual'
if (CORE_TRIGGER_TYPES.includes(triggerType as CoreTriggerType)) {
loggingTriggerType = triggerType as CoreTriggerType
}
const loggingSession = new LoggingSession(
workflowId,
executionId,
loggingTriggerType,
requestId
)
// Client-side sessions and personal API keys bill/permission-check the
// authenticated user, not the workspace billed account.
const useAuthenticatedUserAsActor =
isClientSession ||
(auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal') ||
useMcpBridgeAuthenticatedUserAsActor
// Authorization fetches the full workflow record and checks workspace permissions.
// Run it first so we can pass the record to preprocessing (eliminates a duplicate DB query).
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: requiresWriteExecutionAccess ? 'write' : 'read',
})
if (!workflowAuthorization.allowed) {
return NextResponse.json(
{ error: workflowAuthorization.message || 'Access denied' },
{ status: workflowAuthorization.status }
)
}
/**
* Workflow-in-workflow invocations (e.g. the agent `workflow_executor`
* tool) declare the parent execution's workspace. Reject execution when
* the target workflow lives in a different workspace so a stale or
* foreign workflow id cannot silently execute with the parent's context.
* The error intentionally omits the target's workspace id.
*/
if (parentWorkspaceId && workflowAuthorization.workflow?.workspaceId !== parentWorkspaceId) {
reqLogger.warn('Blocked cross-workspace child workflow execution', {
parentWorkspaceId,
})
return NextResponse.json(
{
error: `Child workflow ${workflowId} belongs to a different workspace and cannot be executed`,
},
{ status: 403 }
)
}
if (req.signal.aborted) {
return clientCancelledResponse()
}
// Pass the pre-fetched workflow record to skip the redundant Step 1 DB query in preprocessing.
const preprocessResult = await preprocessExecution({
workflowId,
userId,
triggerType: loggingTriggerType,
executionId,
requestId,
checkDeployment: !shouldUseDraftState,
loggingSession,
useDraftState: shouldUseDraftState,
useAuthenticatedUserAsActor,
workflowRecord: workflowAuthorization.workflow ?? undefined,
})
if (!preprocessResult.success) {
return NextResponse.json(
{ error: preprocessResult.error!.message },
{ status: preprocessResult.error!.statusCode }
)
}
// Preprocessing reserved an admission slot (released when the LoggingSession
// finalizes). Any path that exits before execution starts must release it
// here, or the slot leaks until its TTL and wrongly throttles later runs.
if (req.signal.aborted) {
await releaseExecutionSlot(executionId)
return clientCancelledResponse()
}
const actorUserId = preprocessResult.actorUserId!
const workflow = preprocessResult.workflowRecord!
if (!workflow.workspaceId) {
reqLogger.error('Workflow has no workspaceId')
await releaseExecutionSlot(executionId)
return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 })
}
const workspaceId = workflow.workspaceId
reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId })
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workspaceId) {
await releaseExecutionSlot(executionId)
return NextResponse.json(
{ error: 'API key is not authorized for this workspace' },
{ status: 403 }
)
}
reqLogger.info('Preprocessing passed')
if (isAsyncMode) {
return handleAsyncExecution({
requestId,
workflowId,
userId: actorUserId,
workspaceId,
input,
triggerType: loggingTriggerType,
executionId,
callChain,
})
}
let cachedWorkflowData: {
blocks: Record<string, any>
edges: any[]
loops: Record<string, any>
parallels: Record<string, any>
deploymentVersionId?: string
variables?: Record<string, any>
} | null = null
let processedInput = input
try {
if (req.signal.aborted) {
await releaseExecutionSlot(executionId)
return clientCancelledResponse()
}
const workflowData = shouldUseDraftState
? await loadWorkflowFromNormalizedTables(workflowId)
: await loadDeployedWorkflowState(workflowId, workspaceId)
if (req.signal.aborted) {
await releaseExecutionSlot(executionId)
return clientCancelledResponse()
}
if (workflowData) {
const deployedVariables =
!shouldUseDraftState && 'variables' in workflowData
? (workflowData as any).variables
: undefined
cachedWorkflowData = {
blocks: workflowData.blocks,
edges: workflowData.edges,
loops: workflowData.loops || {},
parallels: workflowData.parallels || {},
deploymentVersionId:
!shouldUseDraftState && 'deploymentVersionId' in workflowData
? (workflowData.deploymentVersionId as string)
: undefined,
variables: deployedVariables,
}
// Custom blocks resolve only inside the org overlay; wrap this pre-execution
// serialize (used for input file-field discovery) the same way the core does.
const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId)
const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () =>
new Serializer().serializeWorkflow(
workflowData.blocks,
workflowData.edges,
workflowData.loops,
workflowData.parallels,
false
)
)
const executionContext = {
workspaceId,
workflowId,
executionId,
}
processedInput = await processInputFileFields(
input,
serializedWorkflow.blocks,
executionContext,
requestId,
actorUserId
)
}
} catch (fileError) {
reqLogger.error('Failed to process input file fields:', fileError)
await loggingSession.safeStart({
userId: actorUserId,
workspaceId,
variables: {},
})
await loggingSession.safeCompleteWithError({
error: {
message: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`,
stackTrace: fileError instanceof Error ? fileError.stack : undefined,
},
traceSpans: [],
})
return NextResponse.json(
{
error: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`,
},
{ status: 400 }
)
}
const effectiveWorkflowStateOverride =
// double-cast-allowed: workflowStateSchema is structurally a supertype of the executor's reactflow-typed override (edges[].style is Record<string, unknown> vs CSSProperties); validated bodies carry store-shaped values so the runtime shape matches
(sanitizedWorkflowStateOverride as unknown as ExecutionMetadata['workflowStateOverride']) ||
cachedWorkflowData ||
undefined
const largeValueExecutionIds = [executionId]
const largeValueKeys: string[] = []
const fileKeys: string[] = []
const allowLargeValueWorkflowScope = Boolean(
resolvedRunFromBlock?.sourceSnapshot && !resolvedRunFromBlock.sourceExecutionId
)
if (!enableSSE) {
reqLogger.info('Using non-SSE execution (direct JSON response)')
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
triggerBlockId,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope,
callChain,
executionMode: 'sync',
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
const timeoutController = createTimeoutAbortController(
preprocessResult.executionTimeout?.sync
)
const requestAbort = bindRequestAbort(req.signal, timeoutController)
const shouldRejectLargeInlineOutput = isMcpBridgeRequest
const workflowResponseCompaction = {
workspaceId,
workflowId,
executionId,
userId: actorUserId,
rejectLargeInlineOutput: shouldRejectLargeInlineOutput,
}
try {
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
processedInput,
executionVariables,
selectedOutputs
)
const result = await executeWorkflowCore({
snapshot,
callbacks: {},
loggingSession,
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
runFromBlock: resolvedRunFromBlock,
abortSignal: timeoutController.signal,
})
await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession })
if (
result.status === 'cancelled' &&
requestAbort.isRequestAborted() &&
!timeoutController.isTimedOut()
) {
reqLogger.info('Non-SSE execution cancelled by client disconnect')
await loggingSession.markAsFailed('Client cancelled request')
return clientCancelledResponse()
}
if (
result.status === 'cancelled' &&
timeoutController.isTimedOut() &&
timeoutController.timeoutMs
) {
const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs)
reqLogger.info('Non-SSE execution timed out', {
timeoutMs: timeoutController.timeoutMs,
})
await loggingSession.markAsFailed(timeoutErrorMessage)
const compactResultOutput = await compactWorkflowResponseOutput(
result.output,
workflowResponseCompaction
)
return NextResponse.json(
{
success: false,
output: compactResultOutput,
error: timeoutErrorMessage,
metadata: result.metadata
? {
duration: result.metadata.duration,
startTime: result.metadata.startTime,
endTime: result.metadata.endTime,
}
: undefined,
},
{ status: 408 }
)
}
const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys
const outputFileKeys = result.metadata?.fileKeys ?? fileKeys
const outputWithBase64 =
includeFileBase64 && !shouldRejectLargeInlineOutput
? ((await hydrateUserFilesWithBase64(result.output, {
requestId,
workspaceId,
workflowId,
executionId,
largeValueExecutionIds,
largeValueKeys: outputLargeValueKeys,
fileKeys: outputFileKeys,
allowLargeValueWorkflowScope,
userId: actorUserId,
maxBytes: base64MaxBytes,
preserveLargeValueMetadata: true,
})) as NormalizedBlockOutput)
: result.output
if (
!isMcpBridgeRequest &&
auth.authType !== AuthType.INTERNAL_JWT &&
workflowHasResponseBlock(result)
) {
const compactResponseBlockOutput = await compactWorkflowResponseOutput(
outputWithBase64,
workflowResponseCompaction
)
return await createHttpResponseFromBlock(
{ ...result, output: compactResponseBlockOutput },
{
workspaceId,
workflowId,
executionId,
largeValueExecutionIds,
largeValueKeys: outputLargeValueKeys,
fileKeys: outputFileKeys,
userId: actorUserId,
allowLargeValueWorkflowScope,
}
)
}
const compactOutput = await compactWorkflowResponseOutput(
outputWithBase64,
workflowResponseCompaction
)
const filteredResult = {
success: result.success,
executionId,
output: compactOutput,
error: result.error,
metadata: result.metadata
? {
duration: result.metadata.duration,
startTime: result.metadata.startTime,
endTime: result.metadata.endTime,
}
: undefined,
}
return NextResponse.json(filteredResult)
} catch (error: unknown) {
const errorMessage = getErrorMessage(error, 'Unknown error')
if (requestAbort.isRequestAborted() && !timeoutController.isTimedOut()) {
reqLogger.info('Non-SSE execution aborted after client disconnect')
return clientCancelledResponse()
}
if (
isPayloadSizeLimitError(error) &&
shouldRejectLargeInlineOutput &&
error.label === 'Workflow execution response'
) {
return payloadTooLargeResponse()
}
reqLogger.error(`Non-SSE execution failed: ${errorMessage}`)
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
const status = getExecutionErrorStatus(error)
let compactErrorOutput: NormalizedBlockOutput | undefined
if (executionResult && Object.hasOwn(executionResult, 'output')) {
try {
compactErrorOutput = await compactWorkflowResponseOutput(
executionResult.output,
workflowResponseCompaction
)
} catch (compactError) {
if (
isPayloadSizeLimitError(compactError) &&
shouldRejectLargeInlineOutput &&
compactError.label === 'Workflow execution response'
) {
return payloadTooLargeResponse()
}
throw compactError
}
}
return NextResponse.json(
{
success: false,
output: compactErrorOutput,
error: executionResult?.error || errorMessage || 'Execution failed',
metadata: executionResult?.metadata
? {
duration: executionResult.metadata.duration,
startTime: executionResult.metadata.startTime,
endTime: executionResult.metadata.endTime,
}
: undefined,
},
{ status }
)
} finally {
requestAbort.cleanup()
timeoutController.cleanup()
if (executionId) {
void cleanupExecutionBase64Cache(executionId).catch((error) => {
reqLogger.error('Failed to cleanup base64 cache', { error })
})
}
}
}
if (shouldUseDraftState) {
reqLogger.info('Using SSE console log streaming (manual execution)')
} else {
reqLogger.info('Using streaming API response')
const resolvedSelectedOutputs = resolveOutputIds(
selectedOutputs,
cachedWorkflowData?.blocks || {}
)
const streamVariables = cachedWorkflowData?.variables ?? (workflow as any).variables
const streamWorkflow = {
id: workflow.id,
userId: actorUserId,
workspaceId,
isDeployed: workflow.isDeployed,
variables: streamVariables,
}
const stream = await createStreamingResponse({
requestId,
streamConfig: {
selectedOutputs: resolvedSelectedOutputs,
isSecureMode: false,
workflowTriggerType: triggerType === 'chat' ? 'chat' : 'api',
includeFileBase64,
base64MaxBytes,
timeoutMs: preprocessResult.executionTimeout?.sync,
},
executionId,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
workspaceId,
workflowId,
userId: actorUserId,
allowLargeValueWorkflowScope,
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
executeWorkflow(
streamWorkflow,
requestId,
processedInput,
actorUserId,
{
enabled: true,
selectedOutputs: resolvedSelectedOutputs,
isSecureMode: false,
workflowTriggerType: triggerType === 'chat' ? 'chat' : 'api',
onStream,
onBlockComplete,
skipLoggingComplete: true,
includeFileBase64,
base64MaxBytes,
abortSignal,
executionMode: 'stream',
largeValueKeys,
fileKeys,
stopAfterBlockId,
runFromBlock: resolvedRunFromBlock,
},
executionId
),
})
return new NextResponse(stream, {
status: 200,
headers: SSE_HEADERS,
})
}
const encoder = new TextEncoder()
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
let isStreamClosed = false
let isManualAbortRegistered = false
const eventWriter = createExecutionEventWriter(executionId, {
workspaceId,
workflowId,
userId: actorUserId,
preserveUserFileBase64: includeFileBase64,
})
const metaInitialized = await initializeExecutionStreamMeta(executionId, {
userId: actorUserId,
workflowId,
})
if (!metaInitialized) {
timeoutController.cleanup()
await releaseExecutionSlot(executionId)
return NextResponse.json(
{ error: 'Run buffer temporarily unavailable' },
{ status: 503, headers: { 'X-Execution-Id': executionId } }
)
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
registerManualExecutionAborter(executionId, timeoutController.abort)
isManualAbortRegistered = true
let terminalEventPublished = false
const sendEvent = async (
event: ExecutionEvent,
terminalStatus?: TerminalExecutionStreamStatus
) => {
const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done'
let eventToSend = event
if (isBuffered) {
try {
const entry = terminalStatus
? await eventWriter.writeTerminal(event, terminalStatus)
: await eventWriter.write(event)
eventToSend = entry.event
eventToSend.eventId = entry.eventId
terminalEventPublished ||= Boolean(terminalStatus)
} catch (e) {
// The event buffer (Redis replay store) rejected this event — e.g. the flush
// batch exceeds the per-write byte cap for large block outputs. The buffer only
// backs reconnect/replay; the live SSE stream is the primary delivery. Fall
// through to enqueue the event live (below) instead of throwing, so terminal
// events still reach the active client and the UI doesn't hang on "running".
// Marking a terminal event delivered-live as published lets finalization close
// the stream cleanly instead of aborting it with controller.error().
reqLogger.warn('Event buffer write failed; delivering event over live stream only', {
eventType: event.type,
terminal: Boolean(terminalStatus),
error: toError(e).message,
})
terminalEventPublished ||= Boolean(terminalStatus)
}
}
if (!isStreamClosed) {
try {
controller.enqueue(encodeSSEEvent(eventToSend))
} catch {
isStreamClosed = true
}
}
}
try {
const startTime = new Date()
await sendEvent({
type: 'execution:started',
timestamp: startTime.toISOString(),
executionId,
workflowId,
data: {
startTime: startTime.toISOString(),
},
})
const onBlockStart = async (
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => {
reqLogger.info('onBlockStart called', { blockId, blockName, blockType })
await sendEvent({
type: 'block:started',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
blockId,
blockName,
blockType,
executionOrder,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
...(iterationContext.parentIterations?.length && {
parentIterations: iterationContext.parentIterations,
}),
}),
...(childWorkflowContext && {
childWorkflowBlockId: childWorkflowContext.parentBlockId,
childWorkflowName: childWorkflowContext.workflowName,
}),
},
})
}
const onBlockComplete = async (
blockId: string,
blockName: string,
blockType: string,
callbackData: any,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => {
const compactCallbackData = {
...callbackData,
input: await compactRoutePayload(callbackData.input, {
workspaceId,
workflowId,
executionId,
userId: actorUserId,
preserveUserFileBase64: includeFileBase64,
preserveRoot: true,
}),
output: await compactRoutePayload(callbackData.output, {
workspaceId,
workflowId,
executionId,
userId: actorUserId,
preserveUserFileBase64: includeFileBase64,
preserveRoot: true,
}),
}
const hasError = compactCallbackData.output?.error
const childWorkflowData = childWorkflowContext
? {
childWorkflowBlockId: childWorkflowContext.parentBlockId,
childWorkflowName: childWorkflowContext.workflowName,
}
: {}
const instanceData = callbackData.childWorkflowInstanceId
? { childWorkflowInstanceId: callbackData.childWorkflowInstanceId }
: {}
if (hasError) {
reqLogger.info('onBlockComplete (error) called', {
blockId,
blockName,
blockType,
error: compactCallbackData.output.error,
})
await sendEvent({
type: 'block:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
blockId,
blockName,
blockType,
input: compactCallbackData.input,
error: compactCallbackData.output.error,
durationMs: compactCallbackData.executionTime || 0,
startedAt: compactCallbackData.startedAt,
executionOrder: compactCallbackData.executionOrder,
endedAt: compactCallbackData.endedAt,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
...(iterationContext.parentIterations?.length && {
parentIterations: iterationContext.parentIterations,
}),
}),
...childWorkflowData,
...instanceData,
},
})
} else {
reqLogger.info('onBlockComplete called', {
blockId,
blockName,
blockType,
})
await sendEvent({
type: 'block:completed',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
blockId,
blockName,
blockType,
input: compactCallbackData.input,
output: compactCallbackData.output,
durationMs: compactCallbackData.executionTime || 0,
startedAt: compactCallbackData.startedAt,
executionOrder: compactCallbackData.executionOrder,
endedAt: compactCallbackData.endedAt,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
...(iterationContext.parentIterations?.length && {
parentIterations: iterationContext.parentIterations,
}),
}),
...childWorkflowData,
...instanceData,
},
})
}
}
const onStream = async (streamingExec: StreamingExecution) => {
const blockId = (streamingExec.execution as any).blockId
const reader = streamingExec.stream.getReader()
const decoder = new TextDecoder()
const cancelReader = () => {
void reader.cancel(timeoutController.signal.reason).catch(() => {})
}
try {
if (timeoutController.signal.aborted || isStreamClosed) return
timeoutController.signal.addEventListener('abort', cancelReader, { once: true })
while (true) {
if (timeoutController.signal.aborted || isStreamClosed) break
const { done, value } = await reader.read()
if (timeoutController.signal.aborted || isStreamClosed) break
if (done) break
const chunk = decoder.decode(value, { stream: true })
await sendEvent({
type: 'stream:chunk',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { blockId, chunk },
})
}
if (!timeoutController.signal.aborted && !isStreamClosed) {
await sendEvent({
type: 'stream:done',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { blockId },
})
}
} catch (error) {
if (!timeoutController.signal.aborted && !isStreamClosed) {
reqLogger.error('Error streaming block content:', error)
}
} finally {
timeoutController.signal.removeEventListener('abort', cancelReader)
try {
await reader.cancel().catch(() => {})
} catch {}
}
}
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
triggerBlockId,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope,
callChain,
executionMode: 'sync',
}
const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
processedInput,
sseExecutionVariables,
selectedOutputs
)
const onChildWorkflowInstanceReady = async (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => {
await sendEvent({
type: 'block:childWorkflowStarted',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
blockId,
childWorkflowInstanceId,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
...(iterationContext.parentIterations?.length && {
parentIterations: iterationContext.parentIterations,
}),
}),
...(childWorkflowContext && {
childWorkflowBlockId: childWorkflowContext.parentBlockId,
childWorkflowName: childWorkflowContext.workflowName,
}),
...(executionOrder !== undefined && { executionOrder }),
},
})
}
const result = await executeWorkflowCore({
snapshot,
callbacks: {
onBlockStart,
onBlockComplete,
onStream,
onChildWorkflowInstanceReady,
},
loggingSession,
abortSignal: timeoutController.signal,
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
runFromBlock: resolvedRunFromBlock,
})
await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession })
/**
* Compact block logs once and reuse across cancelled/timeout/paused/complete
* SSE events. Walks all block logs and durably serializes large values to
* object storage, so doing it twice would double the latency and storage
* load on the happy path.
*/
const compactedBlockLogs = await compactBlockLogs(result.logs, {
workspaceId,
workflowId,
executionId,
userId: actorUserId,
requireDurable: true,
})
if (result.status === 'cancelled') {
if (timeoutController.isTimedOut() && timeoutController.timeoutMs) {
const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs)
reqLogger.info('Workflow execution timed out', {
timeoutMs: timeoutController.timeoutMs,
})
await loggingSession.markAsFailed(timeoutErrorMessage)
finalMetaStatus = 'error'
await sendEvent(
{
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: timeoutErrorMessage,
duration: result.metadata?.duration || 0,
finalBlockLogs: compactedBlockLogs,
},
},
'error'
)
} else {
reqLogger.info('Workflow execution was cancelled')
finalMetaStatus = 'cancelled'
await sendEvent(
{
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
duration: result.metadata?.duration || 0,
finalBlockLogs: compactedBlockLogs,
},
},
'cancelled'
)
}
return
}
const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys
const outputFileKeys = result.metadata?.fileKeys ?? fileKeys
const sseOutput = includeFileBase64
? await hydrateUserFilesWithBase64(result.output, {
requestId,
workspaceId,
workflowId,
executionId,
largeValueExecutionIds,
largeValueKeys: outputLargeValueKeys,
fileKeys: outputFileKeys,
allowLargeValueWorkflowScope,
userId: actorUserId,
maxBytes: base64MaxBytes,
preserveLargeValueMetadata: true,
})
: result.output
const compactSseOutput = await compactRoutePayload(sseOutput, {
workspaceId,
workflowId,
executionId,
userId: actorUserId,
preserveUserFileBase64: true,
preserveRoot: true,
})
if (result.status === 'paused') {
finalMetaStatus = 'complete'
await sendEvent(
{
type: 'execution:paused',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
output: compactSseOutput,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
finalBlockLogs: compactedBlockLogs,
},
},
'complete'
)
} else {
finalMetaStatus = 'complete'
await sendEvent(
{
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
success: result.success,
output: compactSseOutput,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
finalBlockLogs: compactedBlockLogs,
},
},
'complete'
)
}
} catch (error: unknown) {
const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut()
const errorMessage = isTimeout
? getTimeoutErrorMessage(error, timeoutController.timeoutMs)
: getErrorMessage(error, 'Unknown error')
reqLogger.error(`SSE execution failed: ${errorMessage}`, { isTimeout })
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
let compactErrorLogs: BlockLog[] | undefined
try {
compactErrorLogs = executionResult?.logs
? await compactBlockLogs(executionResult.logs, {
workspaceId,
workflowId,
executionId,
userId: actorUserId,
requireDurable: true,
})
: undefined
} catch (compactionError) {
reqLogger.warn('Failed to compact SSE error logs, omitting oversized error details', {
error: toError(compactionError).message,
})
}
finalMetaStatus = 'error'
await sendEvent(
{
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: executionResult?.error || errorMessage,
duration: executionResult?.metadata?.duration || 0,
finalBlockLogs: compactErrorLogs,
},
},
'error'
)
} finally {
if (isManualAbortRegistered) {
unregisterManualExecutionAborter(executionId)
isManualAbortRegistered = false
}
if (finalMetaStatus && !terminalEventPublished) {
const replayBufferFlushed = await flushExecutionStreamReplayBuffer(
executionId,
eventWriter
)
reqLogger.error('Failed to publish terminal execution event durably', {
executionId,
status: finalMetaStatus,
replayBufferFlushed,
})
if (!isStreamClosed) {
controller.error(new Error('Run buffer terminal event publish failed'))
isStreamClosed = true
}
} else if (terminalEventPublished) {
await eventWriter.close().catch((closeError) => {
reqLogger.warn('Failed to close execution event writer after terminal publish', {
executionId,
error: getErrorMessage(closeError),
})
})
} else {
try {
await eventWriter.close()
} catch (closeError) {
reqLogger.warn('Failed to close event writer', {
error: toError(closeError).message,
})
}
}
timeoutController.cleanup()
if (executionId) {
await cleanupExecutionBase64Cache(executionId)
}
if (!isStreamClosed) {
try {
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
} catch {}
}
}
},
cancel() {
isStreamClosed = true
timeoutController.abort()
reqLogger.info('Client disconnected from SSE stream')
},
})
return new NextResponse(stream, {
headers: {
...SSE_HEADERS,
'X-Execution-Id': executionId,
},
})
} catch (error: any) {
reqLogger.error('Failed to start workflow execution:', error)
// Release a reserved billing slot if a throw exited before the stream took
// over its release (idempotent; no-op when never reserved).
if (executionId) await releaseExecutionSlot(executionId)
return NextResponse.json(
{ error: error.message || 'Failed to start workflow execution' },
{ status: 500 }
)
}
}