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
729 lines
23 KiB
TypeScript
729 lines
23 KiB
TypeScript
import { trace } from '@opentelemetry/api'
|
|
import { db } from '@sim/db'
|
|
import { account, webhook } from '@sim/db/schema'
|
|
import { createLogger, runWithRequestContext } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { isRecordLike } from '@sim/utils/object'
|
|
import { task } from '@trigger.dev/sdk'
|
|
import { eq } from 'drizzle-orm'
|
|
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
|
|
import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits'
|
|
import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency'
|
|
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
|
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
|
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
|
import {
|
|
type WebhookAttachment,
|
|
WebhookAttachmentProcessor,
|
|
} from '@/lib/webhooks/attachment-processor'
|
|
import { resolveWebhookRecordProviderConfig } from '@/lib/webhooks/env-resolver'
|
|
import { getProviderHandler } from '@/lib/webhooks/providers'
|
|
import {
|
|
executeWorkflowCore,
|
|
wasExecutionFinalizedByCore,
|
|
} from '@/lib/workflows/executor/execution-core'
|
|
import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence'
|
|
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
|
import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
|
import { WEBHOOK_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits'
|
|
import { getBlock } from '@/blocks'
|
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
|
import type { ExecutionMetadata } from '@/executor/execution/types'
|
|
import type { ExecutionResult } from '@/executor/types'
|
|
import { hasExecutionResult } from '@/executor/utils/errors'
|
|
import { safeAssign } from '@/tools/safe-assign'
|
|
import { getTrigger, isTriggerValid } from '@/triggers'
|
|
|
|
const logger = createLogger('TriggerWebhookExecution')
|
|
|
|
type WebhookAttachmentInput = Omit<WebhookAttachment, 'data'> & { data: unknown }
|
|
|
|
function isSerializedBuffer(value: unknown): value is { type: 'Buffer'; data: number[] } {
|
|
return isRecordLike(value) && value.type === 'Buffer' && Array.isArray(value.data)
|
|
}
|
|
|
|
function hasSupportedAttachmentData(value: unknown): boolean {
|
|
return (
|
|
Buffer.isBuffer(value) ||
|
|
typeof value === 'string' ||
|
|
value instanceof ArrayBuffer ||
|
|
ArrayBuffer.isView(value) ||
|
|
Array.isArray(value) ||
|
|
isSerializedBuffer(value)
|
|
)
|
|
}
|
|
|
|
function toAttachmentBuffer(data: unknown, name: string): Buffer {
|
|
if (Buffer.isBuffer(data)) {
|
|
return data
|
|
}
|
|
|
|
if (isSerializedBuffer(data)) {
|
|
return Buffer.from(data.data)
|
|
}
|
|
|
|
if (data instanceof ArrayBuffer) {
|
|
return Buffer.from(data)
|
|
}
|
|
|
|
if (ArrayBuffer.isView(data)) {
|
|
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
|
|
}
|
|
|
|
if (Array.isArray(data)) {
|
|
return Buffer.from(data)
|
|
}
|
|
|
|
if (typeof data === 'string') {
|
|
const trimmed = data.trim()
|
|
if (trimmed.startsWith('data:')) {
|
|
const [, base64Data] = trimmed.split(',')
|
|
return Buffer.from(base64Data ?? '', 'base64')
|
|
}
|
|
return Buffer.from(trimmed, 'base64')
|
|
}
|
|
|
|
throw new Error(`Attachment '${name}' has unsupported data format`)
|
|
}
|
|
|
|
function isWebhookAttachmentInput(value: unknown): value is WebhookAttachmentInput {
|
|
if (!isRecordLike(value)) {
|
|
return false
|
|
}
|
|
|
|
return (
|
|
typeof value.name === 'string' &&
|
|
typeof value.size === 'number' &&
|
|
hasSupportedAttachmentData(value.data) &&
|
|
(value.contentType === undefined || typeof value.contentType === 'string') &&
|
|
(value.mimeType === undefined || typeof value.mimeType === 'string')
|
|
)
|
|
}
|
|
|
|
function normalizeWebhookAttachment(value: unknown): WebhookAttachment | null {
|
|
if (!isWebhookAttachmentInput(value)) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
name: value.name,
|
|
data: toAttachmentBuffer(value.data, value.name),
|
|
contentType: value.contentType,
|
|
mimeType: value.mimeType,
|
|
size: value.size,
|
|
}
|
|
}
|
|
|
|
function normalizeWebhookAttachments(value: unknown): WebhookAttachment[] {
|
|
if (!Array.isArray(value)) {
|
|
return []
|
|
}
|
|
|
|
return value.flatMap((attachment) => {
|
|
const normalized = normalizeWebhookAttachment(attachment)
|
|
return normalized ? [normalized] : []
|
|
})
|
|
}
|
|
|
|
export function buildWebhookCorrelation(
|
|
payload: WebhookExecutionPayload
|
|
): AsyncExecutionCorrelation {
|
|
const executionId = payload.executionId || generateId()
|
|
const requestId = payload.requestId || payload.correlation?.requestId || executionId.slice(0, 8)
|
|
|
|
return {
|
|
executionId,
|
|
requestId,
|
|
source: 'webhook',
|
|
workflowId: payload.workflowId,
|
|
webhookId: payload.webhookId,
|
|
path: payload.path,
|
|
provider: payload.provider,
|
|
triggerType: payload.correlation?.triggerType || 'webhook',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process trigger outputs based on their schema definitions.
|
|
* Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage.
|
|
*/
|
|
async function processTriggerFileOutputs(
|
|
input: unknown,
|
|
triggerOutputs: Record<string, unknown>,
|
|
context: {
|
|
workspaceId: string
|
|
workflowId: string
|
|
executionId: string
|
|
requestId: string
|
|
userId?: string
|
|
},
|
|
path = ''
|
|
): Promise<unknown> {
|
|
if (!input || typeof input !== 'object') {
|
|
return input
|
|
}
|
|
|
|
const processed = (Array.isArray(input) ? [] : {}) as Record<string, unknown>
|
|
|
|
for (const [key, value] of Object.entries(input)) {
|
|
const currentPath = path ? `${path}.${key}` : key
|
|
const outputDef = triggerOutputs[key] as Record<string, unknown> | undefined
|
|
|
|
if (outputDef?.type === 'file[]' && Array.isArray(value)) {
|
|
try {
|
|
processed[key] = await WebhookAttachmentProcessor.processAttachments(
|
|
normalizeWebhookAttachments(value),
|
|
context
|
|
)
|
|
} catch (error) {
|
|
processed[key] = []
|
|
}
|
|
} else if (outputDef?.type === 'file' && value) {
|
|
const attachment = normalizeWebhookAttachment(value)
|
|
if (!attachment) {
|
|
processed[key] = value
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const [processedFile] = await WebhookAttachmentProcessor.processAttachments(
|
|
[attachment],
|
|
context
|
|
)
|
|
processed[key] = processedFile
|
|
} catch (error) {
|
|
logger.error(`[${context.requestId}] Error processing ${currentPath}:`, error)
|
|
processed[key] = value
|
|
}
|
|
} else if (
|
|
outputDef &&
|
|
typeof outputDef === 'object' &&
|
|
(outputDef.type === 'object' || outputDef.type === 'json') &&
|
|
outputDef.properties
|
|
) {
|
|
processed[key] = await processTriggerFileOutputs(
|
|
value,
|
|
outputDef.properties as Record<string, unknown>,
|
|
context,
|
|
currentPath
|
|
)
|
|
} else if (outputDef && typeof outputDef === 'object' && !outputDef.type) {
|
|
processed[key] = await processTriggerFileOutputs(
|
|
value,
|
|
outputDef as Record<string, unknown>,
|
|
context,
|
|
currentPath
|
|
)
|
|
} else {
|
|
processed[key] = value
|
|
}
|
|
}
|
|
|
|
return processed
|
|
}
|
|
|
|
export type WebhookExecutionPayload = {
|
|
webhookId: string
|
|
workflowId: string
|
|
userId: string
|
|
executionId?: string
|
|
requestId?: string
|
|
correlation?: AsyncExecutionCorrelation
|
|
provider: string
|
|
body: unknown
|
|
headers: Record<string, string>
|
|
path: string
|
|
blockId?: string
|
|
workspaceId?: string
|
|
credentialId?: string
|
|
/** Epoch ms when the webhook HTTP request was first received (for dispatch-latency metrics). */
|
|
webhookReceivedAt?: number
|
|
/** Epoch ms of the originating provider interaction (e.g. Slack x-slack-request-timestamp). */
|
|
triggerTimestampMs?: number
|
|
/**
|
|
* Billing actor resolved by the webhook route, set ONLY for in-process inline
|
|
* execution that runs microseconds after resolution. The background pass reuses
|
|
* it to skip the redundant billed-account lookup. Deliberately absent on queued
|
|
* (Trigger.dev) and persisted payloads — a deferred run could outlive a
|
|
* billed-account change, so it re-resolves the current actor instead.
|
|
*/
|
|
resolvedActorUserId?: string
|
|
}
|
|
|
|
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
|
|
const correlation = buildWebhookCorrelation(payload)
|
|
const executionId = correlation.executionId
|
|
const requestId = correlation.requestId
|
|
|
|
return runWithRequestContext({ requestId }, async () => {
|
|
logger.info(`[${requestId}] Starting webhook execution`, {
|
|
webhookId: payload.webhookId,
|
|
workflowId: payload.workflowId,
|
|
provider: payload.provider,
|
|
userId: payload.userId,
|
|
executionId,
|
|
})
|
|
|
|
const idempotencyKey = IdempotencyService.createWebhookIdempotencyKey(
|
|
payload.webhookId,
|
|
payload.headers,
|
|
payload.body,
|
|
payload.provider
|
|
)
|
|
|
|
const runOperation = async () => {
|
|
return await executeWebhookJobInternal(payload, correlation)
|
|
}
|
|
|
|
return await webhookIdempotency.executeWithIdempotency(
|
|
payload.provider,
|
|
idempotencyKey,
|
|
runOperation
|
|
)
|
|
})
|
|
}
|
|
|
|
export async function resolveWebhookExecutionProviderConfig<
|
|
T extends { id: string; providerConfig?: unknown },
|
|
>(
|
|
webhookRecord: T,
|
|
provider: string,
|
|
userId: string,
|
|
workspaceId?: string
|
|
): Promise<T & { providerConfig: Record<string, unknown> }> {
|
|
try {
|
|
return await resolveWebhookRecordProviderConfig(webhookRecord, userId, workspaceId)
|
|
} catch (error) {
|
|
const errorMessage = toError(error).message
|
|
throw new Error(
|
|
`Failed to resolve webhook provider config for ${provider} webhook ${webhookRecord.id}: ${errorMessage}`
|
|
)
|
|
}
|
|
}
|
|
|
|
async function resolveCredentialAccountUserId(credentialId: string): Promise<string | undefined> {
|
|
const resolved = await resolveOAuthAccountId(credentialId)
|
|
if (!resolved) {
|
|
return undefined
|
|
}
|
|
const [credentialRecord] = await db
|
|
.select({ userId: account.userId })
|
|
.from(account)
|
|
.where(eq(account.id, resolved.accountId))
|
|
.limit(1)
|
|
return credentialRecord?.userId
|
|
}
|
|
|
|
/**
|
|
* Handle execution result status (timeout, pause, resume).
|
|
* Shared between all provider paths to eliminate duplication.
|
|
*/
|
|
async function handleExecutionResult(
|
|
executionResult: ExecutionResult,
|
|
ctx: {
|
|
loggingSession: LoggingSession
|
|
timeoutController: ReturnType<typeof createTimeoutAbortController>
|
|
requestId: string
|
|
executionId: string
|
|
workflowId: string
|
|
}
|
|
) {
|
|
if (
|
|
executionResult.status === 'cancelled' &&
|
|
ctx.timeoutController.isTimedOut() &&
|
|
ctx.timeoutController.timeoutMs
|
|
) {
|
|
const timeoutErrorMessage = getTimeoutErrorMessage(null, ctx.timeoutController.timeoutMs)
|
|
logger.info(`[${ctx.requestId}] Webhook execution timed out`, {
|
|
timeoutMs: ctx.timeoutController.timeoutMs,
|
|
})
|
|
await ctx.loggingSession.markAsFailed(timeoutErrorMessage)
|
|
} else {
|
|
await handlePostExecutionPauseState({
|
|
result: executionResult,
|
|
workflowId: ctx.workflowId,
|
|
executionId: ctx.executionId,
|
|
loggingSession: ctx.loggingSession,
|
|
})
|
|
}
|
|
|
|
await ctx.loggingSession.waitForPostExecution()
|
|
}
|
|
|
|
async function executeWebhookJobInternal(
|
|
payload: WebhookExecutionPayload,
|
|
correlation: AsyncExecutionCorrelation
|
|
) {
|
|
const { executionId, requestId } = correlation
|
|
const loggingSession = new LoggingSession(
|
|
payload.workflowId,
|
|
executionId,
|
|
payload.provider,
|
|
requestId
|
|
)
|
|
|
|
const preprocessResult = await preprocessExecution({
|
|
workflowId: payload.workflowId,
|
|
userId: payload.userId,
|
|
triggerType: 'webhook',
|
|
executionId,
|
|
requestId,
|
|
triggerData: { correlation },
|
|
checkRateLimit: false,
|
|
checkDeployment: false,
|
|
skipUsageLimits: true,
|
|
workspaceId: payload.workspaceId,
|
|
loggingSession,
|
|
/**
|
|
* Reuse the route-resolved actor only for inline execution (set on the
|
|
* in-process payload). When absent — queued/Trigger.dev runs — preprocessing
|
|
* re-resolves the current billed account. Either way the ban and
|
|
* archived-workflow gates run fresh against the resolved actor.
|
|
*/
|
|
resolvedActorUserId: payload.resolvedActorUserId,
|
|
})
|
|
|
|
if (!preprocessResult.success) {
|
|
throw new Error(preprocessResult.error?.message || 'Preprocessing failed in background job')
|
|
}
|
|
|
|
const { workflowRecord, executionTimeout } = preprocessResult
|
|
if (!workflowRecord) {
|
|
throw new Error(`Workflow ${payload.workflowId} not found during preprocessing`)
|
|
}
|
|
|
|
const workspaceId = workflowRecord.workspaceId
|
|
if (!workspaceId) {
|
|
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
|
|
}
|
|
|
|
const workflowVariables = (workflowRecord.variables as Record<string, unknown>) || {}
|
|
const asyncTimeout = executionTimeout?.async ?? 120_000
|
|
const timeoutController = createTimeoutAbortController(asyncTimeout)
|
|
|
|
let deploymentVersionId: string | undefined
|
|
|
|
try {
|
|
const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([
|
|
loadDeployedWorkflowState(payload.workflowId, workspaceId),
|
|
db.select().from(webhook).where(eq(webhook.id, payload.webhookId)).limit(1),
|
|
payload.credentialId
|
|
? resolveCredentialAccountUserId(payload.credentialId)
|
|
: Promise.resolve(undefined),
|
|
])
|
|
const credentialAccountUserId = resolvedCredentialUserId
|
|
if (payload.credentialId && !credentialAccountUserId) {
|
|
logger.warn(
|
|
`[${requestId}] Failed to resolve credential account for credential ${payload.credentialId}`
|
|
)
|
|
}
|
|
|
|
if (!workflowData) {
|
|
throw new Error(
|
|
'Workflow state not found. The workflow may not be deployed or the deployment data may be corrupted.'
|
|
)
|
|
}
|
|
|
|
const { blocks, edges, loops, parallels } = workflowData
|
|
deploymentVersionId =
|
|
'deploymentVersionId' in workflowData
|
|
? (workflowData.deploymentVersionId as string)
|
|
: undefined
|
|
|
|
const handler = getProviderHandler(payload.provider)
|
|
|
|
let input: Record<string, unknown> | null = null
|
|
let skipMessage: string | undefined
|
|
|
|
const webhookRecord = webhookRows[0]
|
|
if (!webhookRecord) {
|
|
throw new Error(`Webhook record not found: ${payload.webhookId}`)
|
|
}
|
|
|
|
const resolvedWebhookRecord = await resolveWebhookExecutionProviderConfig(
|
|
webhookRecord,
|
|
payload.provider,
|
|
workflowRecord.userId,
|
|
workspaceId
|
|
)
|
|
|
|
if (handler.formatInput) {
|
|
const result = await handler.formatInput({
|
|
webhook: resolvedWebhookRecord,
|
|
workflow: { id: payload.workflowId, userId: payload.userId },
|
|
body: payload.body,
|
|
headers: payload.headers,
|
|
requestId,
|
|
})
|
|
input = result.input as Record<string, unknown> | null
|
|
skipMessage = result.skip?.message
|
|
} else {
|
|
input = payload.body as Record<string, unknown> | null
|
|
}
|
|
|
|
if (!input && handler.handleEmptyInput) {
|
|
const skipResult = handler.handleEmptyInput(requestId)
|
|
if (skipResult) {
|
|
skipMessage = skipResult.message
|
|
}
|
|
}
|
|
|
|
if (skipMessage) {
|
|
await loggingSession.safeStart({
|
|
userId: payload.userId,
|
|
workspaceId,
|
|
variables: {},
|
|
triggerData: {
|
|
isTest: false,
|
|
correlation,
|
|
},
|
|
deploymentVersionId,
|
|
})
|
|
|
|
await loggingSession.safeComplete({
|
|
endedAt: new Date().toISOString(),
|
|
totalDurationMs: 0,
|
|
finalOutput: { message: skipMessage },
|
|
traceSpans: [],
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
workflowId: payload.workflowId,
|
|
executionId,
|
|
output: { message: skipMessage },
|
|
executedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
if (input && payload.blockId && blocks[payload.blockId]) {
|
|
try {
|
|
const triggerBlock = blocks[payload.blockId]
|
|
const rawSelectedTriggerId = triggerBlock?.subBlocks?.selectedTriggerId?.value
|
|
const rawTriggerId = triggerBlock?.subBlocks?.triggerId?.value
|
|
|
|
let resolvedTriggerId = [rawSelectedTriggerId, rawTriggerId].find(
|
|
(candidate): candidate is string =>
|
|
typeof candidate === 'string' && isTriggerValid(candidate)
|
|
)
|
|
|
|
if (!resolvedTriggerId) {
|
|
const blockConfig = getBlock(triggerBlock.type)
|
|
if (blockConfig?.category === 'triggers' && isTriggerValid(triggerBlock.type)) {
|
|
resolvedTriggerId = triggerBlock.type
|
|
} else if (triggerBlock.triggerMode && blockConfig?.triggers?.enabled) {
|
|
const available = blockConfig.triggers?.available?.[0]
|
|
if (available && isTriggerValid(available)) {
|
|
resolvedTriggerId = available
|
|
}
|
|
}
|
|
}
|
|
|
|
if (resolvedTriggerId) {
|
|
const triggerConfig = getTrigger(resolvedTriggerId)
|
|
|
|
if (triggerConfig.outputs) {
|
|
const processedInput = await processTriggerFileOutputs(input, triggerConfig.outputs, {
|
|
workspaceId,
|
|
workflowId: payload.workflowId,
|
|
executionId,
|
|
requestId,
|
|
userId: payload.userId,
|
|
})
|
|
safeAssign(input, processedInput as Record<string, unknown>)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error processing trigger file outputs:`, error)
|
|
}
|
|
}
|
|
|
|
if (input && handler.processInputFiles && payload.blockId && blocks[payload.blockId]) {
|
|
try {
|
|
await handler.processInputFiles({
|
|
input,
|
|
blocks,
|
|
blockId: payload.blockId,
|
|
workspaceId,
|
|
workflowId: payload.workflowId,
|
|
executionId,
|
|
requestId,
|
|
userId: payload.userId,
|
|
})
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error processing provider-specific files:`, error)
|
|
}
|
|
}
|
|
|
|
logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`)
|
|
|
|
const metadata: ExecutionMetadata = {
|
|
requestId,
|
|
executionId,
|
|
workflowId: payload.workflowId,
|
|
workspaceId,
|
|
userId: payload.userId,
|
|
sessionUserId: undefined,
|
|
workflowUserId: workflowRecord.userId,
|
|
triggerType: payload.provider || 'webhook',
|
|
triggerBlockId: payload.blockId,
|
|
useDraftState: false,
|
|
startTime: new Date().toISOString(),
|
|
isClientSession: false,
|
|
credentialAccountUserId,
|
|
correlation,
|
|
workflowStateOverride: {
|
|
blocks,
|
|
edges,
|
|
loops: loops || {},
|
|
parallels: parallels || {},
|
|
deploymentVersionId,
|
|
},
|
|
}
|
|
|
|
const triggerInput = input || {}
|
|
|
|
/**
|
|
* Surface the pre-execution latency that per-block timings cannot see: the
|
|
* gap between webhook receipt and the first block running, and — for
|
|
* trigger_id-bound providers like Slack — the true age of the interaction
|
|
* against its 3s expiry window. Logged structured so it is queryable/alarmable.
|
|
*/
|
|
if (payload.webhookReceivedAt !== undefined || payload.triggerTimestampMs !== undefined) {
|
|
const now = Date.now()
|
|
logger.info(`[${requestId}] Webhook dispatch latency`, {
|
|
workflowId: payload.workflowId,
|
|
provider: payload.provider,
|
|
dispatchLatencyMs:
|
|
payload.webhookReceivedAt !== undefined ? now - payload.webhookReceivedAt : undefined,
|
|
triggerAgeMs:
|
|
payload.triggerTimestampMs !== undefined ? now - payload.triggerTimestampMs : undefined,
|
|
})
|
|
}
|
|
|
|
const snapshot = new ExecutionSnapshot(
|
|
metadata,
|
|
workflowRecord,
|
|
triggerInput,
|
|
workflowVariables,
|
|
[]
|
|
)
|
|
|
|
const executionResult = await executeWorkflowCore({
|
|
snapshot,
|
|
callbacks: {},
|
|
loggingSession,
|
|
includeFileBase64: false,
|
|
base64MaxBytes: undefined,
|
|
abortSignal: timeoutController.signal,
|
|
})
|
|
|
|
await handleExecutionResult(executionResult, {
|
|
loggingSession,
|
|
timeoutController,
|
|
requestId,
|
|
executionId,
|
|
workflowId: payload.workflowId,
|
|
})
|
|
|
|
logger.info(`[${requestId}] Webhook execution completed`, {
|
|
success: executionResult.success,
|
|
workflowId: payload.workflowId,
|
|
provider: payload.provider,
|
|
})
|
|
|
|
return {
|
|
success: executionResult.success,
|
|
workflowId: payload.workflowId,
|
|
executionId,
|
|
output: executionResult.output,
|
|
executedAt: new Date().toISOString(),
|
|
provider: payload.provider,
|
|
}
|
|
} catch (error: unknown) {
|
|
const errorMessage = toError(error).message
|
|
const errorStack = error instanceof Error ? error.stack : undefined
|
|
|
|
logger.error(`[${requestId}] Webhook execution failed`, {
|
|
error: errorMessage,
|
|
stack: errorStack,
|
|
workflowId: payload.workflowId,
|
|
provider: payload.provider,
|
|
})
|
|
|
|
// The finalized flag is set inside a fire-and-forget post-execution promise; await it so the
|
|
// signal is reliable and the failure is fully persisted before we decide fault vs error.
|
|
await loggingSession.waitForPostExecution()
|
|
|
|
// A failure inside workflow execution (block error, provider 4xx, missing required field, etc.)
|
|
// is finalized by core and already recorded in the execution logs. That is a user/workflow error,
|
|
// not a trigger.dev job fault — complete the run normally so we don't fire a false alert. Errors
|
|
// that were not finalized came from the webhook pipeline itself, so we re-throw to fault below.
|
|
if (wasExecutionFinalizedByCore(error, executionId)) {
|
|
// Record the exception on the run span so it stays visible in traces without
|
|
// marking the span as ERROR — that status is what faults the trigger.dev run.
|
|
trace.getActiveSpan()?.recordException(toError(error))
|
|
|
|
return {
|
|
success: false,
|
|
workflowId: payload.workflowId,
|
|
executionId,
|
|
output: hasExecutionResult(error) ? error.executionResult.output : {},
|
|
executedAt: new Date().toISOString(),
|
|
provider: payload.provider,
|
|
}
|
|
}
|
|
|
|
try {
|
|
await loggingSession.safeStart({
|
|
userId: payload.userId,
|
|
workspaceId,
|
|
variables: {},
|
|
triggerData: {
|
|
isTest: false,
|
|
correlation,
|
|
},
|
|
deploymentVersionId,
|
|
})
|
|
|
|
const executionResult = hasExecutionResult(error)
|
|
? error.executionResult
|
|
: {
|
|
success: false,
|
|
output: {},
|
|
logs: [],
|
|
}
|
|
const { traceSpans } = buildTraceSpans(executionResult)
|
|
|
|
await loggingSession.safeCompleteWithError({
|
|
endedAt: new Date().toISOString(),
|
|
totalDurationMs: 0,
|
|
error: {
|
|
message: errorMessage || 'Webhook execution failed',
|
|
stackTrace: errorStack,
|
|
},
|
|
traceSpans,
|
|
})
|
|
} catch (loggingError) {
|
|
logger.error(`[${requestId}] Failed to complete logging session`, loggingError)
|
|
}
|
|
|
|
throw error
|
|
} finally {
|
|
timeoutController.cleanup()
|
|
}
|
|
}
|
|
|
|
export const webhookExecution = task({
|
|
id: 'webhook-execution',
|
|
machine: 'medium-1x',
|
|
retry: {
|
|
maxAttempts: 1,
|
|
},
|
|
queue: {
|
|
concurrencyLimit: WEBHOOK_EXECUTION_CONCURRENCY_LIMIT,
|
|
},
|
|
run: async (payload: WebhookExecutionPayload) => executeWebhookJob(payload),
|
|
})
|