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

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
@@ -0,0 +1,877 @@
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),
})