mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
* fix(delivery): stable provider idempotency tokens, and correctness fixes around them Six independent fixes found while investigating a Slack transport failure. None of them are that failure; all of them are live. **Money writes could be delivered twice.** Square (8 tools), Brex (5) and Outlook Calendar minted their provider idempotency token with `generateId()` at request-build time. That is stable inside the transport retry loop — `prepareToolRequest` runs once above it — but a BLOCK-level retry re-enters the handler and mints a fresh one, defeating the provider's dedupe. A builder ticking "retry" on a Square block turned a committed write into a second card charge, and a Brex one into a second money transfer. Tokens now come from `deriveDeliveryKey`, a pure function of execution + block + tool + invocation, so every retry layer derives the same value. Stripe joins them: all ~50 tools previously sent no `Idempotency-Key` at all. The comment on `brex/create_transfer.ts` claimed a fresh key per transfer *prevents* duplicate money movement. That is inverted — fresh per *attempt* is what permits it — and is corrected here. **`invocationId` is required, not optional.** Deriving from `executionId` alone would be worse than the bug: five loop iterations paying five invoices would share one token, the provider would honour the first and silently drop four real payments, and it would look like five successes. Also: - `INTERNAL_API_BASE_URL` is now ignored on Trigger.dev workers. It names a route that resolves only inside the app container, and several modules run in both runtimes — `guardrails/mask-client.ts` says so in its own TSDoc — so setting it produced `PII redaction failed: Unable to connect` on every worker-side redaction. Mirrored into `packages/testing`'s urls mock, which reimplements the function and would otherwise have diverged. - `engines.bun` raised to >=1.3.14. Measured on 1.2.15, which the old floor permitted: a fully-delivered POST is silently replayed and the caller sees 200. - CloudWatch `put-metric-data` pinned to `maxAttempts: 1`. The AWS SDK default of 3 already replayed it, and `PutMetricData` aggregates rather than overwrites, so a duplicate silently corrupts the customer's metric series and alarm thresholds. - `webhookIdempotency` given a bounded in-progress lease. An untimed run held a SEVEN DAY lease while concurrent duplicates polled it once a second. Verified: `tsc --noEmit` clean, 30,100 tests pass. * fix(ci): restore staging files the branch split had reverted, and format Three problems, all from assembling this branch by checking paths out of a WIP branch built on an older `staging`. Anything `staging` changed since that base came back as a revert. - Root `package.json` had lost the `opentype.js` / `@types/opentype.js` dependencies `staging` added, which desynced `bun.lock` and failed `bun audit`. It also carried a `check:outbound-delivery` script belonging to other work. Every `package.json` is now taken from `staging` with only the `engines.bun` line re-applied. - `executor/utils/block-data.test.ts` — a 77-line file `staging` added — was deleted outright. Restored. - `executor/handlers/generic/generic-handler.test.ts` had lost a test `staging` added. Restored, with only the one `blockId` assertion re-applied. Also formats `keyed-invocation-identity.test.ts` and sorts imports in `internal-api-base-url.test.ts`, which is what `lint:check` failed on. * fix(providers): thread the model's tool-call id into keyed tool execution `prepareToolExecution` accepted an `invocationId` but no provider supplied one, so a keyed tool invoked through an agent always hit the incomplete-context fallback and minted a fresh token — leaving Stripe, Square, Brex and Outlook Calendar writes able to double-deliver under the hosted-key retry layer even though the block path was fixed. The id is now a positional parameter rather than another optional field on `request`, so a provider that cannot supply one fails to compile instead of silently falling through. 22 of the 27 call sites already had the OpenAI-shaped `toolCall` in scope; `tsc` identified the other five, of which Anthropic (`toolUse.id`) and Bedrock (`toolUse.toolUseId`) name it differently. Gemini is left deliberately unthreaded and documented: its function-call parts carry no model-supplied identifier — the streaming loop has to synthesize a local one — and a positional index would not survive the model re-emitting the call. A token that only looks stable is worse than the loud fallback, which names the missing fields. * fix(executor): keep execution order monotonic across a resume `executionOrder` is not carried in the pause snapshot, so a resumed run restarted the counter at 0 and a loop or parallel body executing on both sides of a pause could reuse a pre-pause value. That was cosmetic while the number only ordered logs. It stops being cosmetic once identity is derived from it: a `keyed` tool takes its provider idempotency token from this value, so two distinct writes would present the same token and the provider would silently drop the second. Suppressing a real payment is worse than the duplicate the token exists to prevent, because it looks like success. The counter is now seeded from the highest `executionOrder` among the restored block logs rather than from a new snapshot field, so snapshots written before this change are repaired on resume instead of needing a migration. * fix(providers): thread the tool-call id through the streaming loops too The previous commit only reached call sites written as a single-line three-argument call. The streaming loops are formatted across lines, so openai-compat (Groq, DeepSeek and everything else routing through it), Anthropic and Bedrock still omitted the id and kept falling back to a fresh token. Both Gemini paths now pass `part.functionCall?.id` — the RAW model id, not the `ensureToolCallId` value used for stream events. That helper allocates an execution-local id when Gemini supplies none, and it is freshly allocated per attempt: passing it would complete the keyed context, silencing the "could not derive" warning, while leaving the token just as unstable. Gemini frequently omits the id, in which case this is `undefined` and the loud fallback stands. All 27 call sites are now covered. * fix(providers): make the tool-call id argument required, not optional The TSDoc claimed a provider that cannot supply an id would fail to compile, but the parameter was declared `toolCallId?: string` — so a new call site could omit it entirely, typecheck, and silently take the unstable-token path the positional parameter exists to close. The comment promised a guarantee the type did not enforce. It is now `string | undefined`: required in position, nullable in value. A provider with no model-supplied id must pass `undefined` explicitly and take the loud fallback, rather than being able to forget the argument. All 27 existing call sites already pass it, so this is enforcement only. Verified by deleting the argument at one site: `tsc` rejects it.
926 lines
29 KiB
TypeScript
926 lines
29 KiB
TypeScript
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, timeout } from '@trigger.dev/sdk'
|
|
import { eq } from 'drizzle-orm'
|
|
import {
|
|
refreshExecutionSlotExpiry,
|
|
releaseExecutionSlot,
|
|
} from '@/lib/billing/calculations/usage-reservation'
|
|
import {
|
|
assertBillingAttributionSnapshot,
|
|
type BillingAttributionSnapshot,
|
|
} from '@/lib/billing/core/billing-attribution'
|
|
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
|
|
import {
|
|
capExecutionTimeoutMs,
|
|
createTimeoutAbortController,
|
|
getAsyncExecutionTimeoutForBillingAttribution,
|
|
getExecutionDeadlineAt,
|
|
getTimeoutErrorMessage,
|
|
RESERVATION_TTL_BUFFER_MS,
|
|
} from '@/lib/core/execution-limits'
|
|
import {
|
|
IdempotencyService,
|
|
WEBHOOK_IN_PROGRESS_LEASE_SECONDS,
|
|
webhookIdempotency,
|
|
} from '@/lib/core/idempotency'
|
|
import {
|
|
type EnvironmentResolutionSnapshot,
|
|
getEffectiveEnvironmentSnapshot,
|
|
} from '@/lib/environment/utils'
|
|
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 { resolveOAuthAccountId } from '@/lib/oauth/credential-service'
|
|
import {
|
|
type WebhookAttachment,
|
|
WebhookAttachmentProcessor,
|
|
} from '@/lib/webhooks/attachment-processor'
|
|
import {
|
|
resolveWebhookRecordProviderConfig,
|
|
type WebhookEnvResolutionOptions,
|
|
} 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,
|
|
loadWorkflowDeploymentVersionState,
|
|
} from '@/lib/workflows/persistence/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 {
|
|
createIncompleteResolvedSecretTraceRegistry,
|
|
createResolvedSecretTraceRegistry,
|
|
} from '@/executor/utils/resolved-secret-trace-registry'
|
|
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
|
|
projectDiagnosticError: (
|
|
error: unknown,
|
|
details?: Record<string, unknown>
|
|
) => Record<string, unknown>
|
|
},
|
|
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}`,
|
|
context.projectDiagnosticError(error, { path: currentPath })
|
|
)
|
|
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
|
|
billingAttribution: BillingAttributionSnapshot
|
|
executionId?: string
|
|
requestId?: string
|
|
correlation?: AsyncExecutionCorrelation
|
|
provider: string
|
|
body: unknown
|
|
headers: Record<string, string>
|
|
/** Request URL query parameters; absent when the request had none or on legacy queued jobs. */
|
|
query?: Record<string, string>
|
|
/** HTTP method the delivery arrived with; absent on legacy queued jobs. */
|
|
method?: string
|
|
path: string
|
|
blockId?: string
|
|
/** Immutable deployment admitted by webhook ingress; absent on legacy queued jobs. */
|
|
deploymentVersionId?: 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
|
|
/** Trusted attempt budget resolved before the webhook enters the queue. */
|
|
executionTimeoutMs?: number
|
|
}
|
|
|
|
export async function executeWebhookJob(
|
|
payload: WebhookExecutionPayload,
|
|
externalAbortSignal?: AbortSignal
|
|
) {
|
|
const correlation = buildWebhookCorrelation(payload)
|
|
const executionId = correlation.executionId
|
|
const requestId = correlation.requestId
|
|
let payloadBillingAttribution: BillingAttributionSnapshot
|
|
try {
|
|
payloadBillingAttribution = assertBillingAttributionSnapshot(payload.billingAttribution)
|
|
if (
|
|
payloadBillingAttribution.actorUserId !== payload.userId ||
|
|
payloadBillingAttribution.workspaceId !== payload.workspaceId
|
|
) {
|
|
throw new Error('Webhook job billing attribution does not match its actor and workspace')
|
|
}
|
|
} catch (error) {
|
|
await releaseExecutionSlot(executionId)
|
|
throw error
|
|
}
|
|
const timeoutController = createTimeoutAbortController(
|
|
capExecutionTimeoutMs(
|
|
getAsyncExecutionTimeoutForBillingAttribution(payloadBillingAttribution),
|
|
payload.executionTimeoutMs
|
|
),
|
|
externalAbortSignal
|
|
)
|
|
|
|
try {
|
|
const executionDeadlineAt = getExecutionDeadlineAt(timeoutController.signal)?.getTime()
|
|
const admissionCompleted =
|
|
executionDeadlineAt === undefined
|
|
? true
|
|
: await refreshExecutionSlotExpiry(
|
|
executionId,
|
|
executionDeadlineAt + RESERVATION_TTL_BUFFER_MS
|
|
)
|
|
if (!admissionCompleted) {
|
|
logger.warn('Queued webhook reservation expired; repeating usage admission', {
|
|
workflowId: payload.workflowId,
|
|
executionId,
|
|
})
|
|
}
|
|
|
|
return await 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
|
|
)
|
|
|
|
let operationStarted = false
|
|
const runOperation = async () => {
|
|
operationStarted = true
|
|
return await executeWebhookJobInternal(
|
|
payload,
|
|
correlation,
|
|
timeoutController,
|
|
admissionCompleted
|
|
)
|
|
}
|
|
|
|
try {
|
|
const result = await webhookIdempotency.executeWithIdempotency(
|
|
payload.provider,
|
|
idempotencyKey,
|
|
runOperation,
|
|
undefined,
|
|
{
|
|
inProgressExpiresAt:
|
|
executionDeadlineAt === undefined
|
|
? Date.now() + WEBHOOK_IN_PROGRESS_LEASE_SECONDS * 1000
|
|
: executionDeadlineAt + RESERVATION_TTL_BUFFER_MS,
|
|
}
|
|
)
|
|
if (!operationStarted) {
|
|
await releaseExecutionSlot(executionId)
|
|
}
|
|
return result
|
|
} catch (error) {
|
|
await releaseExecutionSlot(executionId)
|
|
throw error
|
|
}
|
|
})
|
|
} finally {
|
|
timeoutController.cleanup()
|
|
}
|
|
}
|
|
|
|
export async function resolveWebhookExecutionProviderConfig<
|
|
T extends { id: string; providerConfig?: unknown },
|
|
>(
|
|
webhookRecord: T,
|
|
provider: string,
|
|
userId: string,
|
|
workspaceId?: string,
|
|
options?: WebhookEnvResolutionOptions & {
|
|
onEnvironmentSnapshot?: (snapshot: EnvironmentResolutionSnapshot) => void | Promise<void>
|
|
}
|
|
): Promise<T & { providerConfig: Record<string, unknown> }> {
|
|
try {
|
|
if (!options) {
|
|
return await resolveWebhookRecordProviderConfig(webhookRecord, userId, workspaceId)
|
|
}
|
|
|
|
const { onEnvironmentSnapshot, ...resolutionOptions } = options
|
|
if (onEnvironmentSnapshot && resolutionOptions.envVars === undefined) {
|
|
const snapshot = await getEffectiveEnvironmentSnapshot(userId, workspaceId)
|
|
await onEnvironmentSnapshot(snapshot)
|
|
resolutionOptions.envVars = {
|
|
...snapshot.personalDecrypted,
|
|
...snapshot.workspaceDecrypted,
|
|
}
|
|
}
|
|
|
|
return await resolveWebhookRecordProviderConfig(
|
|
webhookRecord,
|
|
userId,
|
|
workspaceId,
|
|
resolutionOptions
|
|
)
|
|
} 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,
|
|
timeoutController: ReturnType<typeof createTimeoutAbortController>,
|
|
admissionCompleted: boolean
|
|
) {
|
|
const { executionId, requestId } = correlation
|
|
const loggingSession = new LoggingSession(
|
|
payload.workflowId,
|
|
executionId,
|
|
payload.provider,
|
|
requestId
|
|
)
|
|
loggingSession.setExecutionDeadlineAt(getExecutionDeadlineAt(timeoutController.signal))
|
|
|
|
const preprocessResult = await preprocessExecution({
|
|
workflowId: payload.workflowId,
|
|
userId: payload.userId,
|
|
triggerType: 'webhook',
|
|
executionId,
|
|
requestId,
|
|
triggerData: { correlation },
|
|
checkRateLimit: false,
|
|
checkDeployment: false,
|
|
skipUsageLimits: admissionCompleted,
|
|
workspaceId: payload.workspaceId,
|
|
loggingSession,
|
|
billingAttribution: payload.billingAttribution,
|
|
executionType: 'async',
|
|
executionDeadlineAt: getExecutionDeadlineAt(timeoutController.signal)?.getTime(),
|
|
})
|
|
|
|
if (!preprocessResult.success) {
|
|
throw new Error(preprocessResult.error?.message || 'Preprocessing failed in background job')
|
|
}
|
|
|
|
const { actorUserId, billingAttribution, workflowRecord } = preprocessResult
|
|
if (!workflowRecord) {
|
|
throw new Error(`Workflow ${payload.workflowId} not found during preprocessing`)
|
|
}
|
|
if (!workflowRecord.isDeployed || workflowRecord.archivedAt) {
|
|
/**
|
|
* A queued delivery racing an undeploy/archive is an expected terminal
|
|
* condition, not a job fault: acknowledge and skip so workers do not
|
|
* record a failed job (or burn retries) for work that must never run.
|
|
*/
|
|
logger.info(`[${requestId}] Skipping webhook execution for undeployed workflow`, {
|
|
workflowId: payload.workflowId,
|
|
archived: Boolean(workflowRecord.archivedAt),
|
|
})
|
|
await releaseExecutionSlot(executionId)
|
|
return {
|
|
success: false,
|
|
skipped: true,
|
|
workflowId: payload.workflowId,
|
|
executionId,
|
|
output: {},
|
|
executedAt: new Date().toISOString(),
|
|
provider: payload.provider,
|
|
}
|
|
}
|
|
|
|
const workspaceId = workflowRecord.workspaceId
|
|
if (!workspaceId) {
|
|
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
|
|
}
|
|
|
|
const workflowVariables = (workflowRecord.variables as Record<string, unknown>) || {}
|
|
|
|
let deploymentVersionId: string | undefined
|
|
|
|
try {
|
|
const workflowStatePromise = payload.deploymentVersionId
|
|
? loadWorkflowDeploymentVersionState(
|
|
payload.workflowId,
|
|
payload.deploymentVersionId,
|
|
workspaceId
|
|
)
|
|
: loadDeployedWorkflowState(payload.workflowId, workspaceId)
|
|
const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([
|
|
workflowStatePromise,
|
|
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 secretScope = { userId: workflowRecord.userId, workspaceId }
|
|
let resolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(secretScope)
|
|
const resolvedWebhookRecord = await resolveWebhookExecutionProviderConfig(
|
|
webhookRecord,
|
|
payload.provider,
|
|
workflowRecord.userId,
|
|
workspaceId,
|
|
{
|
|
onEnvironmentSnapshot: async (secretEnvironment) => {
|
|
try {
|
|
resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({
|
|
personalEncrypted: secretEnvironment.personalEncrypted,
|
|
workspaceEncrypted: secretEnvironment.workspaceEncrypted,
|
|
personalDecrypted: secretEnvironment.personalDecrypted,
|
|
workspaceDecrypted: secretEnvironment.workspaceDecrypted,
|
|
decryptionFailures: secretEnvironment.decryptionFailures,
|
|
personalOwners: secretEnvironment.personalOwners,
|
|
scope: secretScope,
|
|
})
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[${requestId}] Failed to build webhook trace secret catalog`,
|
|
loggingSession.projectDiagnosticError(error)
|
|
)
|
|
resolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(secretScope)
|
|
}
|
|
loggingSession.setResolvedSecretTraceRegistry(resolvedSecretTraceRegistry)
|
|
},
|
|
onResolved: (name, value) => {
|
|
resolvedSecretTraceRegistry.recordResolved(name, value)
|
|
},
|
|
}
|
|
)
|
|
|
|
if (handler.formatInput) {
|
|
const result = await handler.formatInput({
|
|
webhook: resolvedWebhookRecord,
|
|
workflow: { id: payload.workflowId, userId: payload.userId },
|
|
body: payload.body,
|
|
headers: payload.headers,
|
|
query: payload.query ?? {},
|
|
method: payload.method ?? '',
|
|
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: actorUserId,
|
|
actorUserId,
|
|
billingAttribution,
|
|
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,
|
|
projectDiagnosticError: (error, details) =>
|
|
loggingSession.projectDiagnosticError(error, details),
|
|
})
|
|
safeAssign(input, processedInput as Record<string, unknown>)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`[${requestId}] Error processing trigger file outputs`,
|
|
loggingSession.projectDiagnosticError(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`,
|
|
loggingSession.projectDiagnosticError(error)
|
|
)
|
|
}
|
|
}
|
|
|
|
logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`)
|
|
|
|
const metadata: ExecutionMetadata = {
|
|
requestId,
|
|
executionId,
|
|
workflowId: payload.workflowId,
|
|
workspaceId,
|
|
userId: actorUserId!,
|
|
billingAttribution,
|
|
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,
|
|
trustedInitialResolvedSecretTraceProvenance:
|
|
resolvedSecretTraceRegistry.exportProvenanceForValue(triggerInput),
|
|
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`,
|
|
loggingSession.projectDiagnosticError(error, {
|
|
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)) {
|
|
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: actorUserId,
|
|
actorUserId,
|
|
billingAttribution,
|
|
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,
|
|
executionState: executionResult.executionState,
|
|
})
|
|
} catch (loggingError) {
|
|
logger.error(
|
|
`[${requestId}] Failed to complete logging session`,
|
|
loggingSession.projectDiagnosticError(loggingError)
|
|
)
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export const webhookExecution = task({
|
|
id: 'webhook-execution',
|
|
maxDuration: timeout.None,
|
|
machine: 'medium-1x',
|
|
retry: {
|
|
maxAttempts: 1,
|
|
},
|
|
queue: {
|
|
concurrencyLimit: WEBHOOK_EXECUTION_CONCURRENCY_LIMIT,
|
|
},
|
|
run: async (payload: WebhookExecutionPayload, { signal }: { signal: AbortSignal }) =>
|
|
executeWebhookJob(payload, signal),
|
|
})
|