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

878 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from '@sim/db'
import { workflow as workflowTable } from '@sim/db/schema'
import { createLogger, runWithRequestContext } from '@sim/logger'
import { describeError, toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { backoffWithJitter } from '@sim/utils/retry'
import { task } from '@trigger.dev/sdk'
import { eq } from 'drizzle-orm'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
import { createTimeoutAbortController } from '@/lib/core/execution-limits'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { withCascadeLock } from '@/lib/table/cascade-lock'
import { getColumnId } from '@/lib/table/column-keys'
import { isExecCancelled } from '@/lib/table/deps'
import { appendTableEvent } from '@/lib/table/events'
import type {
RowData,
RowExecutionMetadata,
TableDefinition,
WorkflowGroup,
} from '@/lib/table/types'
import type { WorkflowGroupCellPayload } from '@/lib/table/workflow-columns'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
export type { WorkflowGroupCellPayload }
const logger = createLogger('TriggerWorkflowGroupCell')
/** Max rate-limit retry attempts per cell before giving up and writing a
* re-runnable error. With `backoffWithJitter` (base 500ms, max 30s) this is
* ~12 minutes of pacing — enough to ride out a transient burst without
* stalling the dispatcher window indefinitely. */
const RATE_LIMIT_MAX_ATTEMPTS = 6
/** Cell-task entrypoint. Holds a per-row cascade lock so only one worker
* advances a given row at a time; bails on contention. The held lock heart-
* beats every 10s so a crashed pod releases within ~30s.
*
* After the cascade finishes and the lock releases, re-checks for a runnable
* queued marker that may have landed between the cascade's final
* `pickNextEligibleGroupForRow` and the lock release (a window where a
* contender bails on the still-held lock but we're already done). If one
* appeared, re-acquire and drive it — this is the same task re-acquiring the
* lock, NOT a queue re-enqueue or a timed poll, and it loops only while a
* runnable group exists. */
export async function executeWorkflowGroupCellJob(
payload: WorkflowGroupCellPayload,
signal?: AbortSignal
) {
const { tableId, rowId, workspaceId } = payload
const { getTableById } = await import('@/lib/table/service')
const { getRowById } = await import('@/lib/table/rows/service')
const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns')
let currentPayload = payload
while (true) {
if (signal?.aborted) break
const outcome = await withCascadeLock(tableId, rowId, currentPayload.executionId, () =>
runRowCascadeLoop(currentPayload, signal)
)
if (outcome.status === 'contended') {
// Another worker owns the row's cascade; it drains the queued marker.
logger.info(
`Cascade lock held — bailing (table=${tableId} row=${rowId} executionId=${currentPayload.executionId})`
)
break
}
// Usage limit hit mid-cascade: the dispatch is halted and no cell was
// marked, so stop re-driving this row.
if (outcome.result === 'blocked') break
if (signal?.aborted) break
const freshTable = await getTableById(tableId)
if (!freshTable) break
const freshRow = await getRowById(tableId, rowId, workspaceId)
if (!freshRow) break
const next = pickNextEligibleGroupForRow(freshTable, freshRow)
if (!next) break
// Only re-drive a genuine queued marker (an explicit run request whose
// cell-task bailed during our release window). The inner cascade loop has
// already drained every auto-eligible group, so re-driving a non-marker
// group here would re-run forever — e.g. a group that completed with empty
// outputs stays auto-eligible (the inner loop excludes it via
// `excludeGroupId`, but this outer pass has no such anchor).
const nextExec = freshRow.executions?.[next.id]
const hasQueuedMarker = nextExec?.status === 'pending' && nextExec.executionId == null
if (!hasQueuedMarker) break
currentPayload = {
...currentPayload,
groupId: next.id,
workflowId: next.workflowId,
// Re-derive so a workflow group after an enrichment group doesn't keep a stale enrichmentId.
enrichmentId: next.enrichmentId,
executionId: generateId(),
}
}
}
/** Re-fetches the table schema each iteration so groups added DURING the
* cascade become visible to the eligibility check. The resume worker must
* already hold the row's cascade lock before calling. */
export async function runRowCascadeLoop(
payload: WorkflowGroupCellPayload,
signal?: AbortSignal
): Promise<'blocked' | undefined> {
const { tableId, rowId, workspaceId } = payload
const { getTableById } = await import('@/lib/table/service')
const { getRowById } = await import('@/lib/table/rows/service')
const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns')
let currentGroupId = payload.groupId
let currentWorkflowId = payload.workflowId
// Fresh executionId per iteration: SQL guard rejects writes whose id ≠
// row.executions[gid].executionId, so we need a new claim per group.
let currentExecutionId = payload.executionId
while (true) {
if (signal?.aborted) break
const freshTable = await getTableById(tableId)
if (!freshTable) {
logger.warn(`Table ${tableId} vanished mid-cascade`)
break
}
const currentGroup = freshTable.schema.workflowGroups?.find((g) => g.id === currentGroupId)
if (!currentGroup) {
logger.warn(`Group ${currentGroupId} no longer exists on table ${tableId}`)
break
}
const result = await runWorkflowAndWriteTerminal(
{
...payload,
groupId: currentGroupId,
workflowId: currentWorkflowId,
executionId: currentExecutionId,
},
signal,
freshTable,
currentGroup
)
if (result === 'paused') break
// Hard stop (e.g. usage limit): the dispatch was halted and no cell was
// marked. Propagate so the outer re-drive loop stops too — otherwise it
// would re-pick the still-pending queued marker and spin.
if (result === 'blocked') return 'blocked'
const freshRow = await getRowById(tableId, rowId, workspaceId)
if (!freshRow) break
const next = pickNextEligibleGroupForRow(freshTable, freshRow, currentGroupId)
if (!next) break
currentGroupId = next.id
currentWorkflowId = next.workflowId
currentExecutionId = generateId()
}
return undefined
}
/** Returns `'paused'` to signal the cascade loop must exit (resume worker
* takes over) and `'blocked'` for a hard stop (usage limit — dispatch halted,
* cell left unmarked). `'completed' | 'error'` keep the loop running. */
async function runWorkflowAndWriteTerminal(
payload: WorkflowGroupCellPayload,
signal: AbortSignal | undefined,
table: TableDefinition,
group: WorkflowGroup
): Promise<'completed' | 'error' | 'paused' | 'blocked'> {
const { tableId, tableName, rowId, groupId, workflowId, workspaceId, executionId, dispatchId } =
payload
// Read from the live `group`, not the payload: in a cascade the payload is the
// first group's snapshot, so a downstream group with a different version must
// use its own setting (same reason `workflowId` is re-derived per iteration).
const deploymentMode = group.deploymentMode
const requestId = `wfgrp-${executionId}`
return runWithRequestContext({ requestId }, async () => {
const { getRowById } = await import('@/lib/table/rows/service')
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
const { loadWorkflowFromNormalizedTables, loadDeployedWorkflowState } = await import(
'@/lib/workflows/persistence/utils'
)
const { writeWorkflowGroupState, markWorkflowGroupPickedUp, buildOutputsByBlockId } =
await import('@/lib/table/cell-write')
const { stashCellContextForResume } = await import('@/lib/table/workflow-columns')
const cellCtx = { tableId, rowId, workspaceId, groupId, executionId, requestId }
const writeState = (executionState: RowExecutionMetadata, dataPatch?: RowData) =>
writeWorkflowGroupState(cellCtx, { executionState, dataPatch })
/** Pre-execution cancellation guard: a cell cancelled while it sat in the
* queue (e.g. trigger.dev concurrency backlog) must not run once it
* dequeues. Reads the already-loaded row's exec — no extra query. */
const cancelledBeforeRun = (exec: RowExecutionMetadata | undefined): boolean => {
if (!isExecCancelled(exec)) return false
logger.info(
`Skipping cell — cancelled before execution (table=${tableId} row=${rowId} group=${groupId})`
)
return true
}
// Enrichment groups call a registry function directly instead of running a
// workflow, reusing the same pickup → run → terminal-write status flow. The
// `enrichmentId` guard ensures only true registry enrichments take this path
// — a group typed 'enrichment' without a registry id falls through to the
// workflow path rather than erroring.
if (group.type === 'enrichment' && group.enrichmentId) {
const { getEnrichment } = await import('@/enrichments/registry')
const { runEnrichment, skippedEnrichmentDetail } = await import('@/enrichments/run')
const enrichment = getEnrichment(group.enrichmentId)
// `tableRowExecutions.workflowId` is an opaque id for status; use the
// enrichment id for enrichment cells.
const statusId = group.enrichmentId ?? ''
if (!enrichment) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error: `Unknown enrichment "${group.enrichmentId ?? ''}"`,
})
return 'error'
}
const row = await getRowById(tableId, rowId, workspaceId)
if (!row) {
logger.warn(`Row ${rowId} vanished before enrichment`)
return 'error'
}
if (cancelledBeforeRun(row.executions?.[groupId])) return 'error'
// Resolve the billing/enforcement actor: the user who triggered the run,
// else the workspace billed account (automated/auto-fire). Enrichment must
// never run unattributed — if neither resolves, fail the cell rather than
// running free and ungated.
const enrichmentActorUserId =
payload.triggeredByUserId ?? (await getWorkspaceBilledAccountUserId(workspaceId))
if (!enrichmentActorUserId) {
logger.error(
`No billing actor for enrichment — failing cell (table=${tableId} row=${rowId} group=${groupId})`
)
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error: 'Unable to resolve a billing account for this enrichment.',
})
return 'error'
}
// Gate per-member + pooled usage before incurring hosted-key cost. Mirrors
// the workflow-group gate below: clear the cell's pre-stamp and signal the
// client to upgrade rather than marking the cell errored.
const usage = await checkActorUsageLimits(enrichmentActorUserId, workspaceId)
if (usage.isExceeded) {
logger.warn(
`Usage limit reached — halting enrichment (table=${tableId} row=${rowId} group=${groupId})`
)
const { updateRow } = await import('@/lib/table/rows/service')
await updateRow(
{ tableId, rowId, data: {}, workspaceId, executionsPatch: { [groupId]: null } },
table,
requestId
).catch((err) =>
logger.warn(`Failed to clear cell pre-stamp on usage limit`, {
error: toError(err).message,
})
)
let shouldEmit = true
if (dispatchId) {
const { completeDispatchIfActive } = await import('@/lib/table/dispatcher')
shouldEmit = await completeDispatchIfActive(dispatchId)
}
if (shouldEmit) {
await appendTableEvent({
kind: 'usageLimitReached',
tableId,
...(dispatchId ? { dispatchId } : {}),
message: usage.message ?? 'Usage limit exceeded. Please upgrade your plan to continue.',
})
}
return 'blocked'
}
const pickedUp = await markWorkflowGroupPickedUp(cellCtx, {
workflowId: statusId,
jobId: null,
})
if (pickedUp === 'skipped') return 'error'
// Map table columns → enrichment input ids (skip this group's own outputs).
const ownOutputColumns = new Set(group.outputs.map((o) => o.columnName))
const enrichInputs: Record<string, unknown> = {}
for (const m of group.inputMappings ?? []) {
if (ownOutputColumns.has(m.columnName)) continue
enrichInputs[m.inputName] = row.data[m.columnName]
}
// Skip (don't error) rows missing a required input — common when a table
// is partially filled. Clear any prior output values so a stale result
// doesn't linger (and doesn't mark the group `completed`-and-filled, which
// would block the auto cascade from re-enriching once inputs return).
const isEmpty = (v: unknown) => v === undefined || v === null || v === ''
const missingRequired = enrichment.inputs.some(
(i) => i.required && isEmpty(enrichInputs[i.id])
)
if (missingRequired) {
const clearPatch: RowData = {}
for (const out of group.outputs) {
if (!isEmpty(row.data[out.columnName])) clearPatch[out.columnName] = ''
}
await writeState(
{
status: 'completed',
executionId,
jobId: null,
workflowId: statusId,
error: null,
enrichmentDetails: skippedEnrichmentDetail(enrichment),
},
clearPatch
)
return 'completed'
}
try {
if (signal?.aborted) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error: 'Cancelled',
enrichmentDetails: skippedEnrichmentDetail(enrichment, { aborted: true }),
})
return 'error'
}
const { result, cost, error, detail } = await runEnrichment(enrichment, enrichInputs, {
tableId,
rowId,
workspaceId,
signal,
})
// An abort during the cascade must not be recorded as a completed cell.
if (signal?.aborted) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error: 'Cancelled',
enrichmentDetails: detail,
})
return 'error'
}
// Every provider that ran errored (auth / rate-limit / outage) — surface
// it rather than writing a blank cell that looks like "no data found".
if (error) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error,
enrichmentDetails: detail,
})
return 'error'
}
// Bill the run's actor (triggerer, else workspace billed account) for any
// hosted-key cost the providers incurred. Billing failures must not error
// an otherwise-successful cell.
if (cost > 0) {
try {
const { recordUsage } = await import('@/lib/billing/core/usage-log')
await recordUsage({
userId: enrichmentActorUserId,
workspaceId,
executionId,
entries: [
{
category: 'fixed',
source: 'enrichment',
description: enrichment.name,
cost,
sourceReference: `enrichment:${tableId}:${rowId}:${enrichment.id}`,
metadata: { enrichmentId: enrichment.id, tableId, rowId },
},
],
})
} catch (billingErr) {
logger.error('Failed to record enrichment usage', {
enrichmentId: enrichment.id,
cost,
error: toError(billingErr).message,
})
}
}
// Write every output column: the result value when present, else clear
// it. A partial/empty result must blank the columns it didn't fill so a
// re-run that finds less than before doesn't leave stale values.
const dataPatch: RowData = {}
for (const out of group.outputs) {
if (!out.outputId) continue
const value = result[out.outputId]
dataPatch[out.columnName] =
value === undefined || value === null ? '' : (value as RowData[string])
}
await writeState(
{
status: 'completed',
executionId,
jobId: null,
workflowId: statusId,
error: null,
enrichmentDetails: detail,
},
dataPatch
)
return 'completed'
} catch (err) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error: toError(err).message,
})
return 'error'
}
}
const blockErrors: Record<string, string> = {}
let writeChain: Promise<void> = Promise.resolve()
let terminalWritten = false
try {
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: 'Workflow not found',
})
return 'error'
}
// `deployed` groups run the workflow's latest active deployment; `live`
// (default) runs the editable draft. A `deployed` group whose workflow
// has never been deployed fails the cell — no silent fallback to draft.
let normalizedData: Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>>
if (deploymentMode === 'deployed') {
try {
normalizedData = await loadDeployedWorkflowState(workflowId, workspaceId)
} catch (err) {
// Surface the real reason (missing deployment vs. transient DB/migration
// failure) rather than always claiming the workflow isn't deployed.
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: toError(err).message,
})
return 'error'
}
} else {
normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
}
const startBlock = normalizedData
? Object.values(normalizedData.blocks).find((b) => b?.type === 'start_trigger')
: undefined
if (!startBlock) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: 'Workflow is missing a Start trigger',
})
return 'error'
}
const row = await getRowById(tableId, rowId, workspaceId)
if (!row) {
logger.warn(`Row ${rowId} vanished before execution`)
return 'error'
}
if (cancelledBeforeRun(row.executions?.[groupId])) return 'error'
// Billing / usage / timeout gate — route table cells through the same
// preprocessing every other trigger uses. Keep running draft
// (checkDeployment: false). Rate limiting is paced separately below so a
// retry doesn't re-run the (stable) billing/usage/subscription lookups.
// Failures are surfaced via cell state / SSE / dispatch halt, so suppress
// preprocessing's own execution-log writes.
// Attribute the run to the member who triggered it (manual run, row edit, or
// auto-fire from their own write) so the gate + cost land on their per-member
// meter — mirroring the enrichment branch. Falls back to the workspace billed
// account for genuinely actor-less runs.
const preprocess = await preprocessExecution({
workflowId,
executionId,
requestId,
workspaceId,
workflowRecord,
userId: payload.triggeredByUserId ?? workflowRecord.userId,
useAuthenticatedUserAsActor: Boolean(payload.triggeredByUserId),
triggerType: 'workflow',
checkDeployment: false,
checkRateLimit: false,
skipConcurrencyReservation: true,
logPreprocessingErrors: false,
})
if (!preprocess.success) {
// Usage/quota exhausted: retrying won't help. Halt the dispatch without
// marking any cell, and signal the client to upgrade.
if (preprocess.error?.statusCode === 402) {
logger.warn(
`Usage limit reached — halting dispatch (table=${tableId} row=${rowId} group=${groupId})`
)
// Don't leave the cell stuck on its `pending` pre-stamp. Clear this
// cell's exec so it reverts to un-run (no error/cancelled badge —
// matching "don't mark"; re-runnable after upgrade). Each blocked
// cell clears its own.
const { updateRow } = await import('@/lib/table/rows/service')
await updateRow(
{ tableId, rowId, data: {}, workspaceId, executionsPatch: { [groupId]: null } },
table,
requestId
).catch((err) =>
logger.warn(`Failed to clear cell pre-stamp on usage limit`, {
error: toError(err).message,
})
)
// With up to 20 concurrent cells all hitting the limit at once, only
// the cell that transitions the dispatch active→complete emits the
// event — otherwise the user sees a toast per in-flight cell. Cells
// with no owning dispatch (auto-fire) always emit.
let shouldEmit = true
if (dispatchId) {
const { completeDispatchIfActive } = await import('@/lib/table/dispatcher')
shouldEmit = await completeDispatchIfActive(dispatchId)
}
if (shouldEmit) {
await appendTableEvent({
kind: 'usageLimitReached',
tableId,
...(dispatchId ? { dispatchId } : {}),
message:
preprocess.error?.message ??
'Usage limit exceeded. Please upgrade your plan to continue.',
})
}
return 'blocked'
}
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: preprocess.error?.message ?? 'Workflow could not start',
})
return 'error'
}
const actorUserId = preprocess.actorUserId ?? workflowRecord.userId
const asyncTimeoutMs = preprocess.executionTimeout?.async
// Rate-limit pacing: tables count against the async counter (background
// jobs). On a hit, wait & retry so the row still runs rather than being
// skipped — only this cheap check repeats. The waiting cell holds its
// concurrency slot, pacing the whole dispatch to the user's rate limit.
const rateLimiter = new RateLimiter()
for (let attempt = 1; ; attempt++) {
if (signal?.aborted) return 'error'
const rl = await rateLimiter.checkRateLimitWithSubscription(
actorUserId,
preprocess.userSubscription ?? null,
'workflow',
true
)
if (rl.allowed) break
if (attempt >= RATE_LIMIT_MAX_ATTEMPTS) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: 'Rate limit exceeded — please retry later',
})
return 'error'
}
// Exponential backoff WITH jitter — pass null, not the bucket's
// resetAt. That reset time is shared across all waiters, and
// backoffWithJitter clamps a non-null hint to a fixed value with no
// jitter, so honoring it would wake all ~20 concurrent cells in
// lockstep and stampede the bucket. Jittered backoff spreads retries.
const waitMs = backoffWithJitter(attempt, null)
logger.info(
`Rate limited — waiting ${Math.round(waitMs)}ms before retry ${attempt + 1} (table=${tableId} row=${rowId} group=${groupId})`
)
await sleep(waitMs)
// Stop All can land mid-wait. On the trigger.dev backend `signal` never
// fires (cancelByKey is a no-op there), so re-check the DB tombstone and
// release this concurrency slot promptly instead of sleeping out the
// full retry budget.
const refreshed = await getRowById(tableId, rowId, workspaceId)
if (!refreshed || cancelledBeforeRun(refreshed.executions?.[groupId])) return 'error'
}
// SQL guard also rejects if a stop click stamped `cancelled` between this
// check and pickup.
const pickedUp = await markWorkflowGroupPickedUp(cellCtx, {
workflowId,
jobId: null,
})
if (pickedUp === 'skipped') return 'error'
// Output columns produced by THIS group are skipped on input — they're
// populated by the run we're starting. Other group's outputs ARE
// included (they're plain primitives in `row.data` thanks to the
// flattened schema).
// `inputRow` is name-keyed: the workflow author references columns by name
// in the Start block and downstream blocks, while stored `row.data` is
// id-keyed. Translate, skipping this group's own output columns.
const ownOutputColumnIds = new Set(group.outputs.map((o) => o.columnName))
const inputRow: Record<string, unknown> = {}
for (const col of table.schema.columns) {
const id = getColumnId(col)
if (ownOutputColumnIds.has(id)) continue
inputRow[col.name] = row.data[id]
}
const headers = table.schema.columns
.filter((c) => !ownOutputColumnIds.has(getColumnId(c)))
.map((c) => c.name)
// When the group has explicit input mappings, feed the workflow's
// Start-block fields from the mapped columns (`inputName ← row[columnId]`).
// Otherwise fall back to spreading every non-output column by name, so a
// Start field still resolves when it matches a column name. `row`/`rawRow`
// always carry the full (name-keyed) row for downstream reference.
const inputMappings = group.inputMappings ?? []
const mappedInputs: Record<string, unknown> = {}
for (const m of inputMappings) {
mappedInputs[m.inputName] = row.data[m.columnName]
}
const input = {
...(inputMappings.length > 0 ? mappedInputs : inputRow),
row: inputRow,
rawRow: inputRow,
previousRow: null,
changedColumns: [],
rowId,
headers,
tableId,
tableName,
timestamp: new Date().toISOString(),
}
const { pluckByPath } = await import('@/lib/table/pluck')
const outputsByBlockId = buildOutputsByBlockId(group)
const accumulatedData: RowData = {}
const runningBlockIds = new Set<string>()
const schedulePartialWrite = () => {
if (terminalWritten) return
const dataSnapshot: RowData = { ...accumulatedData }
const blockErrorsSnapshot = { ...blockErrors }
const runningSnapshot = Array.from(runningBlockIds)
writeChain = writeChain
.then(async () => {
if (signal?.aborted) return
if (terminalWritten) return
await writeState(
{
status: 'running',
executionId,
jobId: null,
workflowId,
error: null,
runningBlockIds: runningSnapshot,
blockErrors: blockErrorsSnapshot,
},
dataSnapshot
)
})
.catch((err) => {
logger.warn(
`Per-block partial write failed (table=${tableId} row=${rowId} group=${groupId})`,
{ cause: describeError(err), retryable: isRetryableInfrastructureError(err) }
)
})
}
const onBlockStart = async (blockId: string): Promise<void> => {
if (!outputsByBlockId.has(blockId)) return
runningBlockIds.add(blockId)
schedulePartialWrite()
}
const onBlockComplete = async (blockId: string, output: unknown): Promise<void> => {
const outputs = outputsByBlockId.get(blockId)
if (!outputs) return
const blockResult =
output && typeof output === 'object' && 'output' in (output as object)
? (output as { output: unknown }).output
: output
const blockErrorMessage =
blockResult &&
typeof blockResult === 'object' &&
typeof (blockResult as { error?: unknown }).error === 'string'
? (blockResult as { error: string }).error
: null
if (blockErrorMessage) {
blockErrors[blockId] = blockErrorMessage
} else {
for (const out of outputs) {
const plucked = pluckByPath(blockResult, out.path)
if (plucked === undefined) continue
accumulatedData[out.columnName] = plucked as RowData[string]
}
}
runningBlockIds.delete(blockId)
schedulePartialWrite()
}
// Enforce the per-plan execution timeout (from preprocessing), combined
// with the existing cancel signal so either a timeout or a Stop aborts.
const timeoutController = createTimeoutAbortController(asyncTimeoutMs)
const abortSignal = signal
? AbortSignal.any([signal, timeoutController.signal])
: timeoutController.signal
let result: Awaited<ReturnType<typeof executeWorkflow>>
try {
result = await executeWorkflow(
{
id: workflowRecord.id,
// Workflow owner — drives personal env-var resolution + ownership.
userId: workflowRecord.userId,
workspaceId: workflowRecord.workspaceId,
variables: (workflowRecord.variables as Record<string, unknown> | null) ?? {},
},
requestId,
input,
// Billing/usage/rate actor — the workspace billed account.
actorUserId,
{
enabled: true,
executionMode: 'sync',
workflowTriggerType: 'table',
triggerBlockId: startBlock.id,
// `deployed` groups execute the latest active deployment; everything
// else runs the editable draft (the table default). Matches the
// state loaded above for start-block / output-block resolution.
useDraftState: deploymentMode !== 'deployed',
abortSignal,
onBlockStart,
onBlockComplete,
},
executionId
)
} finally {
timeoutController.cleanup()
}
terminalWritten = true
await writeChain.catch(() => {})
if (result.status === 'paused') {
await writeState(
{
status: 'pending',
executionId,
jobId: `paused-${executionId}`,
workflowId,
error: null,
runningBlockIds: [],
blockErrors,
},
accumulatedData
)
await stashCellContextForResume({
executionId,
tableId,
tableName,
rowId,
groupId,
workflowId,
workspaceId,
})
return 'paused'
}
await writeState(
{
status: result.success ? 'completed' : 'error',
executionId,
jobId: null,
workflowId,
error: result.success ? null : (result.error ?? 'Workflow execution failed'),
runningBlockIds: [],
blockErrors,
},
accumulatedData
)
return result.success ? 'completed' : 'error'
} catch (err) {
const message = toError(err).message
logger.error(
`Workflow group cell execution failed (table=${tableId} row=${rowId} group=${groupId})`,
{
error: message,
executionId,
cause: describeError(err),
retryable: isRetryableInfrastructureError(err),
}
)
terminalWritten = true
await writeChain.catch(() => {})
try {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: message,
runningBlockIds: [],
blockErrors,
})
} catch (writeErr) {
logger.error('Also failed to write error state', {
error: toError(writeErr).message,
cause: describeError(writeErr),
retryable: isRetryableInfrastructureError(writeErr),
})
}
return 'error'
}
})
}
export const workflowGroupCellTask = task({
id: 'workflow-group-cell',
machine: 'medium-1x',
retry: { maxAttempts: 1 },
// Combined with `concurrencyKey: tableId`, caps each table's sub-queue to
// 20 in-flight cell jobs while letting different tables run in parallel.
queue: {
name: 'workflow-group-cell',
concurrencyLimit: 20,
},
run: (payload: WorkflowGroupCellPayload, { signal }) =>
executeWorkflowGroupCellJob(payload, signal),
})