chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+821
View File
@@ -0,0 +1,821 @@
import type { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { getActivelyBannedUserIds } from '@/lib/auth/ban'
import {
checkOrgMemberUsageLimit,
checkServerSideUsageLimits,
} from '@/lib/billing/calculations/usage-monitor'
import { reserveExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
describeRetryableInfrastructureError,
isRetryableInfrastructureError,
} from '@/lib/core/errors/retryable-infrastructure'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { LoggingSession, type SessionStartParams } from '@/lib/logs/execution/logging-session'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
const logger = createLogger('ExecutionPreprocessing')
const BILLING_ERROR_MESSAGES = {
BILLING_REQUIRED:
'Unable to resolve billing account. This workflow cannot execute without a valid billing account.',
BILLING_ERROR_GENERIC: 'Error resolving billing account',
} as const
export interface PreprocessExecutionOptions {
// Required fields
workflowId: string
userId: string // The authenticated user ID
triggerType: CoreTriggerType
executionId: string
requestId: string
// Optional checks configuration
checkRateLimit?: boolean // Default: false for manual/chat, true for others
checkDeployment?: boolean // Default: true for non-manual triggers
skipUsageLimits?: boolean // Default: false (only use for test mode)
/**
* Skip the atomic in-flight concurrency reservation while still enforcing the
* usage-cost cap. Default: false. Set by surfaces that already bound and pace
* their own fan-out (e.g. table-cell dispatch, which is row-bounded, async
* rate-limited, and surfaces a graceful "wait/upgrade" state) so the
* reservation's 429 can't surface as a hard error there.
*/
skipConcurrencyReservation?: boolean
logPreprocessingErrors?: boolean // Default: true. When false, skip writing workflow_execution_logs error rows (caller surfaces failures itself, e.g. table cells)
// Context information
workspaceId?: string // If known, used for billing resolution
loggingSession?: LoggingSession // If provided, will be used for error logging
triggerData?: SessionStartParams['triggerData']
isResumeContext?: boolean // Deprecated: no billing fallback is allowed
useAuthenticatedUserAsActor?: boolean // If true, use the authenticated userId as actorUserId (for client-side executions and personal API keys)
/** @deprecated No longer used - background/async executions always use deployed state */
useDraftState?: boolean
/** Pre-fetched workflow row for caller context; preprocessing still re-checks active state. */
workflowRecord?: WorkflowRecord
/**
* Billing actor already resolved by an upstream gate earlier in the same
* request (e.g. the webhook route's preprocessing pass, whose result is carried
* as the job's userId). When provided, the redundant workspace billed-account
* lookup is skipped. The ban, deployment, usage, and rate-limit gates still run
* against this actor — only the resolution is reused, never a gate.
*/
resolvedActorUserId?: string
}
/**
* Result of preprocessing checks
*/
export interface PreprocessExecutionResult {
success: boolean
error?: {
message: string
statusCode: number
logCreated: boolean
retryable?: boolean
cause?: Record<string, unknown>
}
actorUserId?: string
workflowRecord?: WorkflowRecord
userSubscription?: SubscriptionInfo | null
rateLimitInfo?: {
allowed: boolean
remaining: number
resetAt: Date
}
executionTimeout?: {
sync: number
async: number
}
}
type WorkflowRecord = typeof workflow.$inferSelect
type SubscriptionInfo = HighestPrioritySubscription
export async function preprocessExecution(
options: PreprocessExecutionOptions
): Promise<PreprocessExecutionResult> {
const {
workflowId,
userId,
triggerType,
executionId,
requestId,
checkRateLimit = triggerType !== 'manual' && triggerType !== 'chat',
checkDeployment = triggerType !== 'manual',
skipUsageLimits = false,
skipConcurrencyReservation = false,
logPreprocessingErrors = true,
workspaceId: providedWorkspaceId,
loggingSession: providedLoggingSession,
triggerData,
isResumeContext: _isResumeContext = false,
useAuthenticatedUserAsActor = false,
workflowRecord: prefetchedWorkflowRecord,
resolvedActorUserId,
} = options
// When `logPreprocessingErrors` is false the caller surfaces failures itself
// (e.g. table cells use cell state / SSE), so skip the execution-log writes.
const recordPreprocessingError: typeof logPreprocessingError = (args) =>
logPreprocessingErrors ? logPreprocessingError(args) : Promise.resolve()
logger.info(`[${requestId}] Starting execution preprocessing`, {
workflowId,
userId,
triggerType,
executionId,
})
// ========== STEP 1: Validate Workflow Exists ==========
if (prefetchedWorkflowRecord && prefetchedWorkflowRecord.id !== workflowId) {
logger.error(`[${requestId}] Prefetched workflow record ID mismatch`, {
expected: workflowId,
received: prefetchedWorkflowRecord.id,
})
throw new Error(
`Prefetched workflow record ID mismatch: expected ${workflowId}, got ${prefetchedWorkflowRecord.id}`
)
}
let workflowRecord: WorkflowRecord | null = prefetchedWorkflowRecord ?? null
if (!workflowRecord) {
try {
workflowRecord = await getActiveWorkflowRecord(workflowId)
if (!workflowRecord) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: 'unknown',
workspaceId: '',
errorMessage:
'Workflow not found. The workflow may have been deleted or is no longer accessible.',
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message: 'Workflow not found',
statusCode: 404,
logCreated: true,
},
}
}
} catch (error) {
logger.error(`[${requestId}] Error fetching workflow`, { error, workflowId })
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: userId || 'unknown',
workspaceId: providedWorkspaceId || '',
errorMessage: 'Internal error while fetching workflow',
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message: 'Internal error while fetching workflow',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
}
}
} else if (workflowRecord.archivedAt) {
logger.warn(`[${requestId}] Prefetched workflow is archived: ${workflowId}`)
return {
success: false,
error: {
message: 'Workflow not found',
statusCode: 404,
logCreated: false,
},
}
} else {
const activeWorkflow = await getActiveWorkflowRecord(workflowId)
if (!activeWorkflow) {
logger.warn(`[${requestId}] Workflow archived before execution started: ${workflowId}`)
return {
success: false,
error: {
message: 'Workflow not found',
statusCode: 404,
logCreated: false,
},
}
}
workflowRecord = activeWorkflow
}
const workspaceId = workflowRecord.workspaceId || providedWorkspaceId || ''
if (!workspaceId) {
logger.warn(`[${requestId}] Workflow ${workflowId} has no workspaceId; execution blocked`)
return {
success: false,
error: {
message:
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot execute.',
statusCode: 403,
logCreated: false,
},
}
}
// ========== STEP 2: Check Deployment Status ==========
// If workflow is not deployed and deployment is required, reject without logging.
// No log entry or cost should be created for calls to undeployed workflows
// since the workflow was never intended to run.
if (checkDeployment && !workflowRecord.isDeployed) {
logger.warn(`[${requestId}] Workflow not deployed: ${workflowId}`)
return {
success: false,
error: {
message: 'Workflow is not deployed',
statusCode: 403,
logCreated: false,
},
}
}
// ========== STEP 3: Resolve Billing Actor ==========
let actorUserId: string | null = null
try {
// For client-side executions and personal API keys, the authenticated
// user is the billing and permission actor — not the workspace owner.
if (useAuthenticatedUserAsActor && userId) {
actorUserId = userId
logger.info(`[${requestId}] Using authenticated user as actor: ${actorUserId}`)
}
/**
* Reuse an actor already resolved upstream this request (e.g. the webhook
* route's preprocessing) to skip the redundant workspace billed-account
* lookup. Gates below still run against this actor.
*/
if (!actorUserId && resolvedActorUserId) {
actorUserId = resolvedActorUserId
logger.info(`[${requestId}] Using pre-resolved billing actor: ${actorUserId}`)
}
if (!actorUserId && workspaceId) {
actorUserId = await getWorkspaceBilledAccountUserId(workspaceId)
if (actorUserId) {
logger.info(`[${requestId}] Using workspace billed account: ${actorUserId}`)
}
}
if (!actorUserId) {
const fallbackUserId = userId || 'unknown'
logger.warn(`[${requestId}] ${BILLING_ERROR_MESSAGES.BILLING_REQUIRED}`, {
workflowId,
workspaceId,
})
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: fallbackUserId,
workspaceId,
errorMessage: BILLING_ERROR_MESSAGES.BILLING_REQUIRED,
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message: 'Unable to resolve billing account',
statusCode: 500,
logCreated: true,
},
}
}
} catch (error) {
logger.error(`[${requestId}] Error resolving billing actor`, { error, workflowId })
const fallbackUserId = userId || 'unknown'
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: fallbackUserId,
workspaceId,
errorMessage: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC,
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message: 'Error resolving billing account',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
}
}
// ========== STEPS 3.56: Preflight Gates ==========
// Read-only gates (ban, subscription, usage) run concurrently; the stateful
// rate-limit gate runs after they pass. Precedence: ban 403 → usage 402 → rate 429.
/**
* A failing gate's deferred outcome: the response to return, plus an optional
* error-log write to flush before returning. Evaluated in precedence order.
*/
interface GateFailure {
response: PreprocessExecutionResult
recordError?: Parameters<typeof recordPreprocessingError>[0]
}
/** Usage figures captured by STEP 5 and reused by the STEP 7 reservation. */
interface UsageSnapshot {
currentUsage: number
limit: number
}
const banCheck = (async (): Promise<GateFailure | null> => {
// Blocks executions when the billing actor, the workflow owner, or the
// caller-provided userId (chat deployer, authenticated caller) has an
// active ban or a blocked email domain. The owner comes from the workflow
// record so schedules — which pass the 'unknown' sentinel — are covered.
const banCandidateIds = [actorUserId]
if (userId && userId !== 'unknown' && userId !== actorUserId) {
banCandidateIds.push(userId)
}
if (workflowRecord.userId && !banCandidateIds.includes(workflowRecord.userId)) {
banCandidateIds.push(workflowRecord.userId)
}
try {
const bannedUserIds = await getActivelyBannedUserIds(banCandidateIds)
if (bannedUserIds.length > 0) {
logger.warn(`[${requestId}] Execution blocked: banned account`, {
workflowId,
bannedUserIds,
triggerType,
})
return {
response: {
success: false,
error: {
message: 'Account suspended',
statusCode: 403,
logCreated: true,
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: 'This account has been suspended. Workflow executions are blocked.',
loggingSession: providedLoggingSession,
triggerData,
},
}
}
return null
} catch (error) {
logger.error(`[${requestId}] Error checking account ban status`, { error, actorUserId })
return {
response: {
success: false,
error: {
message: 'Unable to verify account status. Execution blocked for security.',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: 'Unable to verify account status. Execution blocked for security.',
loggingSession: providedLoggingSession,
triggerData,
},
}
}
})()
// ========== STEP 4: Get Subscription ==========
const subscriptionFetch = getHighestPrioritySubscription(actorUserId)
const [banFailure, userSubscription] = await Promise.all([banCheck, subscriptionFetch])
/**
* STEP 5: usage + per-member org usage gate. Returns the failure outcome (or
* `null` on pass/skip) plus the usage snapshot reused by the STEP 7 admission
* reservation. The snapshot is returned rather than written to an outer
* variable so concurrent gate tasks share no mutable state.
*/
const usageCheckTask = (async (): Promise<{
failure: GateFailure | null
snapshot: UsageSnapshot | null
}> => {
if (skipUsageLimits) return { failure: null, snapshot: null }
let snapshot: UsageSnapshot | null = null
try {
const usageCheck = await checkServerSideUsageLimits(actorUserId, userSubscription)
snapshot = { currentUsage: usageCheck.currentUsage, limit: usageCheck.limit }
if (usageCheck.isExceeded) {
logger.warn(
`[${requestId}] User ${actorUserId} has exceeded usage limits. Blocking execution.`,
{
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId,
triggerType,
}
)
return {
failure: {
response: {
success: false,
error: {
message:
usageCheck.message ||
'Usage limit exceeded. Please upgrade your plan to continue.',
statusCode: 402,
logCreated: true,
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage:
usageCheck.message ||
`Usage limit exceeded: $${usageCheck.currentUsage?.toFixed(2)} used of $${usageCheck.limit?.toFixed(2)} limit. Please upgrade your plan to continue.`,
loggingSession: providedLoggingSession,
triggerData,
},
},
snapshot,
}
}
// Per-member org-workspace cap (hosted-only). Independent, additive gate:
// blocks an individual member's executions in org-owned workspaces once
// their personal credit limit for the org is reached, even if the pooled
// org limit still has room.
const memberUsageCheck = await checkOrgMemberUsageLimit(actorUserId, workspaceId)
if (memberUsageCheck.isExceeded) {
const memberLimitMessage =
memberUsageCheck.message ||
'Member usage limit exceeded for this organization. Ask an organization admin to raise your credit limit to continue.'
logger.warn(
`[${requestId}] User ${actorUserId} exceeded their per-member org usage limit. Blocking execution.`,
{
currentUsage: memberUsageCheck.currentUsage,
limit: memberUsageCheck.limit,
workflowId,
triggerType,
}
)
return {
failure: {
response: {
success: false,
error: {
message: memberLimitMessage,
statusCode: 402,
logCreated: true,
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: memberLimitMessage,
loggingSession: providedLoggingSession,
triggerData,
},
},
snapshot,
}
}
return { failure: null, snapshot }
} catch (error) {
logger.error(`[${requestId}] Error checking usage limits`, {
error,
actorUserId,
})
return {
failure: {
response: {
success: false,
error: {
message: 'Unable to determine usage limits. Execution blocked for security.',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage:
'Unable to determine usage limits. Execution blocked for security. Please contact support.',
loggingSession: providedLoggingSession,
triggerData,
},
},
snapshot,
}
}
})()
// ========== STEP 6: Check Rate Limits ==========
let rateLimitInfo: { allowed: boolean; remaining: number; resetAt: Date } | undefined
/**
* STEP 6: rate-limit gate. Unlike the other gates this one is NOT read-only —
* `checkRateLimitWithSubscription` consumes a token — so it is invoked
* sequentially only after the ban and usage gates pass, matching the original
* order. Running it eagerly or in parallel would debit rate-limit quota for
* requests that ban or usage rejects. Returns the failure outcome, or `null`
* on pass/skip; on a non-error outcome it populates `rateLimitInfo`.
*/
const runRateLimitGate = async (): Promise<GateFailure | null> => {
if (!checkRateLimit) return null
try {
const rateLimiter = new RateLimiter()
const info = await rateLimiter.checkRateLimitWithSubscription(
actorUserId,
userSubscription,
triggerType,
false // not async
)
rateLimitInfo = info
if (!info.allowed) {
logger.warn(`[${requestId}] Rate limit exceeded for user ${actorUserId}`, {
triggerType,
remaining: info.remaining,
resetAt: info.resetAt,
})
return {
response: {
success: false,
error: {
message: `Rate limit exceeded. Please try again later.`,
statusCode: 429,
logCreated: true,
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: `Rate limit exceeded. ${info.remaining} requests remaining. Resets at ${info.resetAt.toISOString()}.`,
loggingSession: providedLoggingSession,
triggerData,
},
}
}
return null
} catch (error) {
logger.error(`[${requestId}] Error checking rate limits`, { error, actorUserId })
return {
response: {
success: false,
error: {
message: 'Error checking rate limits',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: 'Error checking rate limits. Execution blocked for safety.',
loggingSession: providedLoggingSession,
triggerData,
},
}
}
}
const usageResult = await usageCheckTask
const usageSnapshot = usageResult.snapshot
const readGateFailure = banFailure ?? usageResult.failure
if (readGateFailure) {
if (readGateFailure.recordError) {
await recordPreprocessingError(readGateFailure.recordError)
}
return readGateFailure.response
}
const rateLimitFailure = await runRateLimitGate()
if (rateLimitFailure) {
if (rateLimitFailure.recordError) {
await recordPreprocessingError(rateLimitFailure.recordError)
}
return rateLimitFailure.response
}
/**
* STEP 7: Atomic admission reservation. Cost is only recorded once an
* execution finishes, so without this a burst of concurrent executions all
* observe the same pre-burst usage and all pass the gate above. Reserving
* bounds in-flight (un-costed) executions per billing entity. Done last so an
* earlier rejection never leaves a slot held; the slot is released at
* execution completion (see {@link LoggingSession}).
*/
if (!skipUsageLimits && !skipConcurrencyReservation && usageSnapshot) {
try {
const { reserved } = await reserveExecutionSlot({
userId: actorUserId,
executionId,
subscription: userSubscription,
currentUsage: usageSnapshot.currentUsage,
limit: usageSnapshot.limit,
})
if (!reserved) {
logger.warn(`[${requestId}] Admission reservation full for user ${actorUserId}`, {
workflowId,
triggerType,
})
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage:
'Too many concurrent executions in flight for this account. Please wait for in-progress runs to finish and try again.',
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message:
'Too many concurrent executions in flight. Please wait for in-progress runs to finish and try again.',
statusCode: 429,
logCreated: true,
retryable: true,
},
}
}
} catch (error) {
logger.error(`[${requestId}] Unexpected error reserving admission slot`, {
error,
actorUserId,
})
}
}
// ========== SUCCESS: All Checks Passed ==========
logger.info(`[${requestId}] All preprocessing checks passed`, {
workflowId,
actorUserId,
triggerType,
})
const plan = userSubscription?.plan as SubscriptionPlan | undefined
return {
success: true,
actorUserId,
workflowRecord,
userSubscription,
rateLimitInfo,
executionTimeout: {
sync: getExecutionTimeout(plan, 'sync'),
async: getExecutionTimeout(plan, 'async'),
},
}
}
/**
* Helper function to log preprocessing errors to the database
*
* This ensures users can see why their workflow execution was blocked.
*/
async function logPreprocessingError(params: {
workflowId: string
executionId: string
triggerType: string
requestId: string
userId: string
workspaceId: string
errorMessage: string
loggingSession?: LoggingSession
triggerData?: SessionStartParams['triggerData']
}): Promise<void> {
const {
workflowId,
executionId,
triggerType,
requestId,
userId,
workspaceId,
errorMessage,
loggingSession,
triggerData,
} = params
if (!workspaceId) {
logger.warn(`[${requestId}] Cannot log preprocessing error: no workspaceId available`, {
workflowId,
executionId,
errorMessage,
})
return
}
try {
const session =
loggingSession || new LoggingSession(workflowId, executionId, triggerType, requestId)
await session.safeStart({
userId,
workspaceId,
variables: {},
triggerData,
})
await session.safeCompleteWithError({
error: {
message: errorMessage,
stackTrace: undefined,
},
traceSpans: [],
skipCost: true, // Preprocessing errors should not charge - no execution occurred
})
} catch (error) {
logger.error(`[${requestId}] Failed to log preprocessing error`, {
error,
workflowId,
executionId,
})
// Don't throw - error logging should not block the error response
}
}