Files
simstudioai--sim/apps/sim/lib/table/workflow-columns.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

1141 lines
44 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.
/**
* Server-side scheduler for workflow-group auto-execution. The cascade is
* driven entirely by the eligibility predicate: each row-write fires the
* scheduler, which considers any newly-eligible (row × group) pair (deps
* just filled, upstream group just `completed`) and enqueues per-cell jobs.
*/
import { db } from '@sim/db'
import {
pausedExecutions,
tableRowExecutions,
userTableRows as userTableRowsTable,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm'
import type { EnqueueOptions } from '@/lib/core/async-jobs/types'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import { buildCancelledExecution } from '@/lib/table/cell-write'
import type {
Filter,
RowData,
RowExecutionMetadata,
RowExecutions,
TableDefinition,
TableRow,
TableSchema,
WorkflowGroup,
} from '@/lib/table/types'
const logger = createLogger('WorkflowGroupScheduler')
import { getColumnId } from '@/lib/table/column-keys'
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from '@/lib/table/deps'
import type { DispatchLimit, DispatchMode } from '@/lib/table/dispatcher'
import { buildFilterClause } from '@/lib/table/sql'
export {
getUnmetGroupDeps,
optimisticallyScheduleNewlyEligibleGroups,
} from '@/lib/table/deps'
/**
* Per-(row, group) eligibility for both the auto-fire reactor and manual
* runs. Manual runs bypass the `autoRun === false` skip, and additionally
* bypass the dep check for `autoRun === false` groups (those are user-model
* "no deps, manual only").
*
* "Completed" status is treated as stale when any output cell is empty — the
* cells win over the exec metadata, so deleting an output value re-arms the
* row for the cascade and for manual incomplete-mode runs.
*/
/**
* Reason codes the eligibility predicate emits. Stable strings so the caller
* can aggregate skip reasons into one summary log per scheduler call instead
* of allocating a per-cell debug line.
*/
export type EligibilityReason =
| 'eligible'
| 'autoRun-off'
| 'in-flight'
| 'completed-on-auto'
| 'error-on-auto'
| 'cancelled-on-auto'
| 'completed-on-incomplete'
| 'has-prior-attempt'
| 'manual-bypass'
| 'deps-unmet'
export function classifyEligibility(
group: WorkflowGroup,
row: TableRow,
opts?: { isManualRun?: boolean; mode?: DispatchMode }
): EligibilityReason {
const isManualRun = opts?.isManualRun ?? false
const mode = opts?.mode ?? 'all'
if (group.autoRun === false && !isManualRun) return 'autoRun-off'
const exec = row.executions?.[group.id]
// Dispatcher pre-stamp orphans (`pending` + `executionId: null`) are
// placeholders left behind when a previous dispatcher loop wrote the stamp
// but no cell-task picked up (cascade-lock contention, trigger.dev queue
// failure, etc.). Treat them as claimable so a new dispatcher can re-enqueue
// — without this carve-out the row would render "Queued" forever. Matches
// the `pickNextEligibleGroupForRow` cascade-loop carve-out.
const isOrphanPreStamp = exec?.status === 'pending' && exec.executionId == null
if (!isOrphanPreStamp && isExecInFlight(exec)) return 'in-flight'
const status = exec?.status
// `mode: 'new'` is the auto-fire scope: only rows that have never been
// attempted on this group run. Any pre-existing exec entry — completed,
// cancelled, or error — keeps the cell sticky until the user manually
// re-runs via "Run column" / "Run all rows" / "Run this row".
// Exception: orphan pre-stamps are claimable (handled above).
if (mode === 'new' && exec && !isOrphanPreStamp) return 'has-prior-attempt'
const completedAndFilled = status === 'completed' && areOutputsFilled(group, row)
// For an enrichment a `completed` run is terminal even with empty outputs —
// a no-match is a real result, not an unfinished run. Treating it as "done"
// stops the auto cascade from re-invoking billable provider calls on every
// no-match row each dispatch. A genuine input change clears the exec entry
// (see deriveExecClearsForDataPatch), so real re-runs still happen.
const isDone = completedAndFilled || (group.type === 'enrichment' && status === 'completed')
if (!isManualRun && isDone) return 'completed-on-auto'
if (!isManualRun && status === 'error') return 'error-on-auto'
if (!isManualRun && status === 'cancelled') return 'cancelled-on-auto'
// Manual incomplete-mode runs (Run row / Run incomplete) treat a `completed`
// group as done even if an output is blank — only "Run all" re-runs it. The
// auto cascade still re-fills blank workflow outputs (completedAndFilled).
if (mode === 'incomplete') {
if (isManualRun ? status === 'completed' : isDone) {
return 'completed-on-incomplete'
}
}
if (isManualRun && group.autoRun === false) return 'manual-bypass'
return areGroupDepsSatisfied(group, row) ? 'eligible' : 'deps-unmet'
}
export function isGroupEligible(
group: WorkflowGroup,
row: TableRow,
opts?: { isManualRun?: boolean; mode?: 'all' | 'incomplete' }
): boolean {
const reason = classifyEligibility(group, row, opts)
return reason === 'eligible' || reason === 'manual-bypass'
}
/** Walks a row's workflow groups (in `workflowGroups` order) and returns the
* first one whose deps are met and that isn't already in-flight under a
* different worker. Skips `excludeGroupId` (the group we just finished in
* the cascade loop, to prevent self-retrigger). The cascade-loop is allowed
* to claim past a dispatcher pre-stamp (`pending` with `executionId: null`)
* — that's a placeholder, not a real worker claim. */
export function pickNextEligibleGroupForRow(
table: TableDefinition,
row: TableRow,
excludeGroupId?: string
): WorkflowGroup | null {
const groups = table.schema.workflowGroups ?? []
for (const group of groups) {
if (group.id === excludeGroupId) continue
const exec = row.executions?.[group.id]
// Dispatcher pre-stamp (pending + executionId: null) is a queued marker: an
// explicit run request whose cell-task bailed on lock contention. It's the
// handoff — the cascade owner runs it next. Treat it as `isManualRun` so an
// explicitly-requested `autoRun: false` group is honored (the dispatcher
// already applied manual eligibility before stamping it); groups with no
// marker stay `isManualRun: false` so pure dep-fill auto-cascade still
// respects `autoRun`. Either way the placeholder is cleared from the
// eligibility view so the group is claimable.
const isRequested = exec?.status === 'pending' && exec.executionId == null
const effectiveRow = isRequested
? { ...row, executions: { ...row.executions, [group.id]: undefined } as RowExecutions }
: row
if (isGroupEligible(group, effectiveRow, { isManualRun: isRequested, mode: 'incomplete' })) {
return group
}
}
return null
}
/**
* Shared options for the three `scheduleRuns*` entry points. `isManualRun`
* flips two gates in the eligibility predicate so a manual click can re-run
* terminal states and bypass the autoRun=false skip.
*/
export interface ScheduleOpts {
groupId?: string
groupIds?: string[]
isManualRun?: boolean
mode?: DispatchMode
}
/** Pure eligibility filter + payload building. Shared by the auto-fire path
* (`scheduleRunsForRows`) and the dispatcher's per-window batch path. */
export function buildPendingRuns(
table: TableDefinition,
rows: TableRow[],
opts?: ScheduleOpts
): WorkflowGroupCellPayload[] {
const allGroups = table.schema.workflowGroups ?? []
if (allGroups.length === 0) return []
if (rows.length === 0) return []
const groupIdFilter = opts?.groupIds
? new Set(opts.groupIds)
: opts?.groupId
? new Set([opts.groupId])
: null
const groups = groupIdFilter ? allGroups.filter((g) => groupIdFilter.has(g.id)) : allGroups
if (groups.length === 0) return []
const orderedRows = rows.length <= 1 ? rows : [...rows].sort((a, b) => a.position - b.position)
const pendingRuns: WorkflowGroupCellPayload[] = []
const reasonCounts: Partial<Record<EligibilityReason, number>> = {}
for (const row of orderedRows) {
for (const group of groups) {
const reason = classifyEligibility(group, row, {
isManualRun: opts?.isManualRun,
mode: opts?.mode,
})
reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1
if (reason !== 'eligible' && reason !== 'manual-bypass') continue
pendingRuns.push({
tableId: table.id,
tableName: table.name,
rowId: row.id,
groupId: group.id,
workflowId: group.workflowId,
...(group.enrichmentId ? { enrichmentId: group.enrichmentId } : {}),
workspaceId: table.workspaceId,
executionId: generateId(),
})
}
}
logger.debug(
`[Cascade] table=${table.id} rows=${rows.length} groups=${groups.length} manual=${opts?.isManualRun ?? false} mode=${opts?.mode ?? 'all'} reasons=${JSON.stringify(reasonCounts)}`
)
return pendingRuns
}
/** Build the per-cell `{payload, options}` items for `queue.batchEnqueue` /
* `queue.batchEnqueueAndWait`. Hydrates trigger.dev tags, concurrency keys,
* the inline runner, and the cancel key the inline backend uses to map a
* Stop click to the in-flight cell's AbortController.
*
* `runner` is only used by the database backend; trigger.dev triggers by task
* id. The cell-job import pulls in the executor + blocks stack, so skip it on
* trigger.dev to avoid a multi-second dispatcher cold-start. */
export async function buildEnqueueItems(
pendingRuns: WorkflowGroupCellPayload[]
): Promise<Array<{ payload: WorkflowGroupCellPayload; options: EnqueueOptions }>> {
const runner = isTriggerDevEnabled
? undefined
: ((await import('@/background/workflow-column-execution'))
.executeWorkflowGroupCellJob as EnqueueOptions['runner'])
return pendingRuns.map((runOpts) => ({
payload: runOpts,
options: {
metadata: {
workflowId: runOpts.workflowId,
workspaceId: runOpts.workspaceId,
correlation: {
executionId: runOpts.executionId,
requestId: `wfgrp-${runOpts.executionId}`,
source: 'workflow' as const,
workflowId: runOpts.workflowId,
triggerType: 'table',
},
},
concurrencyKey: runOpts.tableId,
concurrencyLimit: TABLE_CONCURRENCY_LIMIT,
tags: cellTagsFor(runOpts),
...(runner ? { runner } : {}),
cancelKey: cellCancelKey(runOpts.tableId, runOpts.rowId, runOpts.groupId),
},
}))
}
/** Stable key for `cancelInlineRun` lookups. Stamped on every enqueue item by
* `buildEnqueueItems`; the cancel path computes the same key per cell. */
export function cellCancelKey(tableId: string, rowId: string, groupId: string): string {
return `${tableId}:${rowId}:${groupId}`
}
/** Trigger.dev tags stamped on every `workflow-group-cell` run so tag-based
* cancel (`runs.list({ tag })` + `runs.cancel(id)`) can target a specific
* cell or table without needing per-cell jobIds. */
export function cellTagsFor(runOpts: WorkflowGroupCellPayload): string[] {
return [`tableId:${runOpts.tableId}`, `rowId:${runOpts.rowId}`, `group:${runOpts.groupId}`]
}
/** Cancel every active trigger.dev `workflow-group-cell` run whose tags
* match. Paginates `runs.list` and fires `runs.cancel` per match. Errors
* are logged and swallowed — the cell-write SQL guard already makes
* workers no-op on cancelled rows whether or not trigger.dev acked the
* cancel, so partial failure is safe. */
export async function cancelCellRunsByTags(tags: string[]): Promise<void> {
if (tags.length === 0) return
const { runs } = await import('@trigger.dev/sdk')
const cancellations: Array<Promise<unknown>> = []
try {
// Trigger.dev paginates with auto-iterating cursor — looping the page
// iterator is the documented usage pattern.
for await (const run of runs.list({
tag: tags,
taskIdentifier: 'workflow-group-cell',
status: ['PENDING_VERSION', 'QUEUED', 'DEQUEUED', 'EXECUTING', 'WAITING', 'DELAYED'],
})) {
cancellations.push(
runs.cancel(run.id).catch((err) => {
logger.warn(`cancelCellRunsByTags: cancel ${run.id} failed`, {
error: toError(err).message,
})
})
)
}
await Promise.allSettled(cancellations)
} catch (err) {
logger.warn(`cancelCellRunsByTags: list failed`, {
tags,
error: toError(err).message,
})
}
}
export function toTableRow(
r: typeof userTableRowsTable.$inferSelect,
executions: RowExecutions = {}
): TableRow {
return {
id: r.id,
data: r.data as RowData,
executions,
position: r.position,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}
}
export interface WorkflowGroupCellPayload {
tableId: string
tableName: string
rowId: string
groupId: string
/** Backing workflow id for manual groups; `''` for enrichment groups. */
workflowId: string
/** Registry enrichment id for enrichment groups. */
enrichmentId?: string
workspaceId: string
executionId: string
/** Owning dispatch, set by `dispatcherStep`. Lets the cell halt its dispatch
* on a hard stop (e.g. usage limit). Absent for cascade/auto-fire payloads
* that aren't driven by a dispatch. */
dispatchId?: string
/** User who triggered the run, for per-member usage attribution. Absent for
* auto-fire (row writes, CSV import) → billing falls back to the workspace
* billed account. */
triggeredByUserId?: string
}
/** Per-table concurrency cap. Mirrors trigger.dev's `concurrencyLimit: 20`. */
export const TABLE_CONCURRENCY_LIMIT = 20
/**
* Cancels in-flight workflow-group runs for a table or single row. Writes
* `cancelled` authoritatively for every `running` or `pending` group
* execution — the client-side write is the source of truth, independent of
* whether the trigger.dev cancel reaches the worker before its terminal
* write. Pass `groupIds` to restrict the cancel to a subset of groups on
* the row (used by `updateRow` to cancel only the downstream groups whose
* deps just changed). Pass `filter` (table-wide form only) to cancel just
* the cells on rows matching it — a filtered "select all" must not stop
* work on rows outside its scope, so like the per-row form it leaves
* active dispatches running (their in-flight checks skip cancelled cells).
*/
export async function cancelWorkflowGroupRuns(
tableId: string,
rowId?: string,
options?: {
groupIds?: string[]
filter?: Filter
excludeRowIds?: string[]
/** Set by a manual run cancelling prior work: its own already-inserted
* dispatch must survive the table-wide dispatch cancel. */
spareDispatchId?: string
}
): Promise<number> {
const { getTableById } = await import('@/lib/table/service')
const { updateRow } = await import('@/lib/table/rows/service')
const { getJobQueue } = await import('@/lib/core/async-jobs/config')
const { listActiveDispatches, markActiveDispatchesCancelled } = await import(
'@/lib/table/dispatcher'
)
const table = await getTableById(tableId)
if (!table) {
logger.warn(`cancelWorkflowGroupRuns: table ${tableId} not found`)
return 0
}
// Per-row cancel leaves the dispatcher alone — other rows in the same
// dispatch keep running. Table-wide cancel must stop it, else the cursor
// marches on and re-enqueues fresh cells past what we just cancelled.
// Filter-scoped cancel stops only dispatches with that exact filter scope
// (its own run); whole-table or differently-scoped dispatches keep running —
// their cells cancelled below are skipped via `cancelledAt > requestedAt`.
if (!rowId) {
const cancelledDispatches = await markActiveDispatchesCancelled(tableId, {
scopeFilter: options?.filter,
spareExcludedRowIds: options?.excludeRowIds,
spareDispatchId: options?.spareDispatchId,
})
logger.info(
`cancelWorkflowGroupRuns: cancelled ${cancelledDispatches.length} active dispatch(es) for table ${tableId}`,
{ dispatchIds: cancelledDispatches.map((d) => d.id) }
)
}
const allGroups = table.schema.workflowGroups ?? []
if (allGroups.length === 0) return 0
const groupIds = options?.groupIds
? new Set(allGroups.filter((g) => options.groupIds?.includes(g.id)).map((g) => g.id))
: new Set(allGroups.map((g) => g.id))
if (groupIds.size === 0) return 0
// Per-row Stop on a row the dispatcher hasn't reached yet has no sidecar
// entry to cancel — the dispatcher would later walk to that row, see no
// exec, classify eligible, and re-fire. Pre-write `cancelled` tombstones
// for active-dispatch in-scope groups so the existing `cancelledAt >
// dispatch.requestedAt` filter in `dispatcherStep` catches them. Skip
// when there's no active dispatch (nothing to outrun).
let aheadOfCursorTombstones: Array<{ groupId: string; workflowId: string }> = []
if (rowId) {
const activeDispatches = await listActiveDispatches(tableId)
const relevant = activeDispatches.filter((d) => {
if (d.scope.rowIds && !d.scope.rowIds.includes(rowId)) return false
return d.scope.groupIds.some((gid) => groupIds.has(gid))
})
if (relevant.length > 0) {
// Intersection of targeted groups with active-dispatch scopes — only
// these groups are at risk of being re-fired by an in-progress dispatch.
const atRisk = new Set<string>()
for (const d of relevant) {
for (const gid of d.scope.groupIds) {
if (groupIds.has(gid)) atRisk.add(gid)
}
}
aheadOfCursorTombstones = Array.from(atRisk).map((gid) => ({
groupId: gid,
workflowId: allGroups.find((g) => g.id === gid)?.workflowId ?? '',
}))
}
}
// Always filter by tableId — for the per-row case this prevents a
// cross-table rowId from doing a wasted DB round-trip and silently
// under-counting in the response. For the table-wide case it's the
// primary filter.
const inFlightStatuses = ['running', 'queued', 'pending']
const inFlightFilters = [
eq(tableRowExecutions.tableId, tableId),
inArray(tableRowExecutions.status, inFlightStatuses),
inArray(tableRowExecutions.groupId, Array.from(groupIds)),
]
if (rowId) {
inFlightFilters.push(eq(tableRowExecutions.rowId, rowId))
} else if (options?.excludeRowIds?.length) {
// Select-all minus deselections: the deselected rows' cells keep running.
inFlightFilters.push(notInArray(tableRowExecutions.rowId, options.excludeRowIds))
}
if (!rowId && options?.filter) {
// Filter-scoped cancel: only cells on rows matching the filter. Semi-join
// against the tenant's rows — the in-flight sidecar set is small, so the
// jsonb predicate is evaluated on few rows.
const filterClause = buildFilterClause(
options.filter,
USER_TABLE_ROWS_SQL_NAME,
table.schema.columns
)
if (filterClause) {
inFlightFilters.push(
inArray(
tableRowExecutions.rowId,
db
.select({ id: userTableRowsTable.id })
.from(userTableRowsTable)
.where(and(eq(userTableRowsTable.tableId, tableId), filterClause))
)
)
}
}
const inFlightRows = await db
.select()
.from(tableRowExecutions)
.where(and(...inFlightFilters))
const queue = await getJobQueue()
type RowMutation = {
rowId: string
executionsPatch: Record<string, RowExecutionMetadata>
jobIds: string[]
cancelledCount: number
}
const byRow = new Map<string, RowMutation>()
for (const r of inFlightRows) {
const prev: RowExecutionMetadata = {
status: r.status as RowExecutionMetadata['status'],
executionId: r.executionId ?? null,
jobId: r.jobId ?? null,
workflowId: r.workflowId,
error: r.error ?? null,
...(r.blockErrors && Object.keys(r.blockErrors as Record<string, string>).length > 0
? { blockErrors: r.blockErrors as Record<string, string> }
: {}),
}
const existing = byRow.get(r.rowId) ?? {
rowId: r.rowId,
executionsPatch: {},
jobIds: [],
cancelledCount: 0,
}
if (prev.jobId) existing.jobIds.push(prev.jobId)
existing.executionsPatch[r.groupId] = buildCancelledExecution(prev)
existing.cancelledCount++
byRow.set(r.rowId, existing)
}
const mutations: RowMutation[] = Array.from(byRow.values())
// Defense-in-depth for paused/awaiting cells: a cell that paused mid-run is
// stamped `pending` with a `paused-<executionId>` jobId and keeps a record in
// `paused_executions`. Mark those cancelling so a pending waitpoint short-
// circuits before it resumes (the resume worker also re-checks the cell's
// cancelled tombstone — that's the authoritative stop). No-op for cells with
// no paused record.
const pausedCancellations = inFlightRows
.filter((r) => r.executionId && r.jobId?.startsWith('paused-'))
.map((r) => ({ executionId: r.executionId as string, workflowId: r.workflowId }))
if (pausedCancellations.length > 0) {
const { PauseResumeManager } = await import(
'@/lib/workflows/executor/human-in-the-loop-manager'
)
await Promise.allSettled(
pausedCancellations.map((p) =>
PauseResumeManager.beginPausedCancellation(p.executionId, p.workflowId).catch((err) => {
logger.warn(`beginPausedCancellation failed for ${p.executionId}`, {
error: toError(err).message,
})
})
)
)
}
// Abort in-flight cell runs. The interface method `cancelByKey` is a no-op
// on the trigger.dev backend (no in-process AbortControllers) and aborts
// the matching AbortController on the database backend. Trigger.dev's tag
// sweep covers the SaaS path; the cell-write SQL guard is the
// authoritative stop signal regardless of backend.
for (const m of mutations) {
for (const gid of Object.keys(m.executionsPatch)) {
queue.cancelByKey(cellCancelKey(tableId, m.rowId, gid))
}
}
const tagSweepPromise = isTriggerDevEnabled
? cancelCellRunsByTags(rowId ? [`rowId:${rowId}`] : [`tableId:${tableId}`])
: Promise.resolve()
await Promise.allSettled([
...mutations.flatMap((m) =>
m.jobIds.map((jobId) =>
queue.cancelJob(jobId).catch((err) => {
logger.error(`Failed to cancel job ${jobId} for ${tableId}/${m.rowId}:`, err)
})
)
),
tagSweepPromise,
])
// `updateRow` no longer auto-fires the dispatcher post-write — the reactor
// was removed. Cancel-writes only touch executions[gid] state; no risk of
// re-enqueueing what we just cancelled.
await Promise.allSettled(
mutations.map((m) =>
updateRow(
{
tableId,
rowId: m.rowId,
data: {},
workspaceId: table.workspaceId,
executionsPatch: m.executionsPatch,
},
table,
`wfgrp-cancel-${m.rowId}`
).catch((err) => {
logger.error(`Failed to write cancelled state for row ${m.rowId}:`, err)
})
)
)
// Tombstones for ahead-of-cursor groups. The in-flight cancel writes above
// already cover groups that have a sidecar entry; we only need fresh
// tombstones for groups that don't (the dispatcher hasn't reached them
// yet, so there's nothing to cancel — but without a tombstone the
// dispatcher would still re-fire when its cursor walks to this row).
if (rowId && aheadOfCursorTombstones.length > 0) {
const alreadyHandled = new Set(mutations.flatMap((m) => Object.keys(m.executionsPatch)))
const needsTombstone = aheadOfCursorTombstones.filter((t) => !alreadyHandled.has(t.groupId))
if (needsTombstone.length > 0) {
const now = new Date()
await Promise.allSettled(
needsTombstone.map((t) =>
db
.insert(tableRowExecutions)
.values({
tableId,
rowId,
groupId: t.groupId,
status: 'cancelled',
executionId: null,
jobId: null,
workflowId: t.workflowId,
error: 'Cancelled',
runningBlockIds: [],
blockErrors: {},
cancelledAt: now,
updatedAt: now,
})
.onConflictDoNothing({
target: [tableRowExecutions.rowId, tableRowExecutions.groupId],
})
.catch((err) => {
logger.error(`Failed to write tombstone for ${tableId}/${rowId}/${t.groupId}:`, err)
})
)
)
}
}
return mutations.reduce((sum, m) => sum + m.cancelledCount, 0)
}
/**
* Run a set of groups across the table or a row subset. Single canonical
* user-driven run op — every UI gesture (single cell, per-row Play, action-bar
* Play/Refresh, column-header menu) reduces to this. `mode: 'all'` re-runs
* completed cells; `mode: 'incomplete'` skips them. `groupIds` omitted = every
* workflow group on the table. `rowIds` omitted = every row.
*/
export async function runWorkflowColumn(opts: {
tableId: string
workspaceId: string
mode: DispatchMode
requestId: string
groupIds?: string[]
rowIds?: string[]
/** "Select all under a filter" — run every row matching this filter (mutually exclusive with
* `rowIds`). Threaded into the dispatch scope so the dispatcher walks only matching rows. */
filter?: Filter
/** Select-all scope only: deselected rows — the dispatcher walk, eager clear, and pre-run
* cancel all skip them. */
excludeRowIds?: string[]
/** Optional cap on work before the dispatch completes (e.g. run only the
* first N eligible rows). Null/omitted = process every row in scope. */
limit?: DispatchLimit | null
/** When false, eligibility honors `autoRun: false` and treats completed
* cells as terminal — appropriate for auto-fire after row writes or
* schema changes. Defaults to true (user-initiated "Run column"). */
isManualRun?: boolean
/** User who triggered the run, for usage attribution. Omitted by auto-fire
* callers (row writes, CSV import) → falls back to the workspace billed
* account at billing time. */
triggeredByUserId?: string | null
}): Promise<{ dispatchId: string | null }> {
const {
tableId,
workspaceId,
mode,
requestId,
groupIds,
rowIds,
filter,
excludeRowIds,
limit,
triggeredByUserId,
} = opts
const isManualRun = opts.isManualRun ?? true
// Empty `rowIds` array means "scope explicitly empty" — auto-fire callers
// (CSV import on zero matches, etc.) end up here. Skip the dispatch entirely
// rather than walk the table with a no-match filter.
if (rowIds && rowIds.length === 0) return { dispatchId: null }
// Lazy imports: `./service` and `./dispatcher` both close cycles back to
// this module; `@trigger.dev/sdk` is heavy and only needed on this op.
const { getTableById } = await import('@/lib/table/service')
const table = await getTableById(tableId)
if (!table) throw new Error('Table not found')
if (table.workspaceId !== workspaceId) throw new Error('Invalid workspace ID')
const allGroups = table.schema.workflowGroups ?? []
const targetGroups = groupIds ? allGroups.filter((g) => groupIds.includes(g.id)) : allGroups
// Tables with no workflow groups are the majority. Auto-fire callers from
// every row write would otherwise produce error-level log spam on every
// PATCH/insert. Manual run-column callers always pass `groupIds` so they
// can't reach here with an empty target.
if (targetGroups.length === 0) return { dispatchId: null }
const targetGroupIds = targetGroups.map((g) => g.id)
const {
bulkClearWorkflowGroupCells,
cancelDispatchById,
insertDispatch,
readDispatch,
runDispatcherToCompletion,
} = await import('./dispatcher')
// Always insert a `table_run_dispatches` row, and insert it FIRST — before
// the prior-run cancel and the bulk clear below, which can take seconds on
// a large table. The client shows its Stop control optimistically from the
// moment the user clicks Run, so a Stop-all arriving during that prep work
// must find a dispatch row to cancel; inserted-after ordering made an early
// Stop-all a silent no-op and the run proceeded anyway. The dispatcher
// state machine is the single source of truth for cursor advancement, SSE
// emission, and cancel — backend (trigger.dev SaaS vs in-process) only
// affects how each window's cells get executed.
const dispatchId = await insertDispatch({
tableId,
workspaceId,
requestId,
mode,
scope: {
groupIds: targetGroupIds,
...(rowIds && rowIds.length > 0 ? { rowIds } : {}),
...(filter ? { filter } : {}),
...(excludeRowIds && excludeRowIds.length > 0 && !(rowIds && rowIds.length > 0)
? { excludeRowIds }
: {}),
},
limit,
isManualRun,
triggeredByUserId,
})
try {
// For manual runs (Run all rows / Run column / Refresh-row / Refresh-cell),
// cancel any prior active dispatches AND in-flight cells in scope before
// clearing. Without this:
// - Two dispatcher loops would walk overlapping rows and burn duplicate work.
// - mode:'all' bulk-clear deletes in-flight sidecar rows without aborting
// workers — those would keep writing into the wiped state.
// Scope: table-wide cancel when rowIds is empty (also cancels active
// dispatches via markActiveDispatchesCancelled, sparing the one just
// inserted above), per-row cancel otherwise (no dispatch cancel — other
// rows' dispatches keep running). Dep-edit cascade in `updateRow` already
// cancels its own scope before calling, so the duplicate work here is a
// cheap no-op for that caller. Auto-fire (`mode:'new'`) is harmless
// overlap-wise — the NOT EXISTS filter excludes already-attempted rows.
const cancelPriorRuns = isManualRun && (mode === 'all' || mode === 'incomplete')
if (cancelPriorRuns) {
if (!rowIds || rowIds.length === 0) {
// Filtered runs cancel only their own scope — a table-wide cancel here
// would stop unrelated work on rows outside the filter (or on deselected rows).
await cancelWorkflowGroupRuns(tableId, undefined, {
groupIds: targetGroupIds,
filter,
excludeRowIds,
spareDispatchId: dispatchId,
})
} else {
// Per-row cancel — sequential so we don't fan out N parallel
// markActiveDispatchesCancelled calls (it's a no-op when rowId is set,
// but each call still touches the DB).
for (const rowId of rowIds) {
await cancelWorkflowGroupRuns(tableId, rowId, { groupIds: targetGroupIds })
}
}
}
// Wipe targeted output cols + executions[gid] before any cells fire so the
// user sees the column flip to empty/Pending instantly. Skipped for capped
// runs: the eager clear can't know which N rows the dispatcher will pick
// (they depend on per-row eligibility as it walks positions), so wiping all
// rows in scope would blank far more than we re-run. `mode: 'all'` re-runs
// completed cells without the clear anyway — the clear is only for instant
// feedback, which the capped rows still get via the dispatcher's pre-stamp.
// Skip the eager clear for a filtered run: `bulkClearWorkflowGroupCells` keys by `rowIds`, and a
// filtered scope has none — clearing table-wide would blank rows that don't match the filter. The
// dispatcher's per-row pre-stamp still provides instant Pending feedback as it walks.
if (!limit && !filter) {
await bulkClearWorkflowGroupCells({
tableId,
groups: targetGroups.map((g) => ({ id: g.id, outputs: g.outputs })),
rowIds,
excludeRowIds,
mode,
})
}
} catch (err) {
// Prep failed after the dispatch row was inserted — cancel it so an
// orphaned `pending` dispatch can't pin the client's "about to run"
// overlay, then fail the request with the ORIGINAL error. The cleanup is
// best-effort: its own failure must not mask the prep failure.
try {
await cancelDispatchById(dispatchId)
} catch (cleanupErr) {
logger.error(`[Cascade] [${requestId}] failed to cancel dispatch after prep failure`, {
dispatchId,
error: toError(cleanupErr).message,
})
}
throw err
}
// A Stop-all can land during the prep above; its dispatch cancel is the
// authoritative stop. Don't fire the dispatcher loop for a dead dispatch —
// it would exit on its first status read, but the trigger.dev path would
// still spin up a task for nothing. Return a null dispatchId: the client
// seeds a returned id into its active-dispatch overlay, which would
// resurrect the Run/Stop UI the cancelled SSE event already cleared; null
// takes its "no dispatch created" path and rolls the optimistic bump back.
const current = await readDispatch(dispatchId)
if (!current || current.status === 'cancelled' || current.status === 'complete') {
logger.info(
`[Cascade] [${requestId}] dispatch ${dispatchId} cancelled during prep — not firing`
)
return { dispatchId: null }
}
logger.info(
`[Cascade] [${requestId}] dispatch ${dispatchId} table=${tableId} groups=[${targetGroupIds.join(',')}] rows=${rowIds ? `[${rowIds.join(',')}]` : 'all'} mode=${mode}`
)
if (isTriggerDevEnabled) {
// Trigger.dev runs `tableRunDispatcherTask`, which loops `dispatcherStep`
// until done with CRIU-checkpointed waits between windows.
const [{ tableRunDispatcherTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-run-dispatcher'),
import('@trigger.dev/sdk'),
import('@/lib/core/async-jobs/region'),
])
await tasks.trigger<typeof tableRunDispatcherTask>(
'table-run-dispatcher',
{ dispatchId },
{ concurrencyKey: dispatchId, region: await resolveTriggerRegion() }
)
} else {
// Local / no-trigger.dev: drive the same loop in-process, fire-and-forget
// so the HTTP request returns instantly (mirrors the trigger.dev path's
// async fan-out).
void runDispatcherToCompletion(dispatchId).catch((err) =>
logger.error(`[${requestId}] dispatcher loop failed`, {
dispatchId,
error: toError(err).message,
})
)
}
return { dispatchId }
}
// ───────────────────────────── Validation ─────────────────────────────
/**
/**
* Removes the given column names from a group's `dependencies.columns` and from
* its `inputMappings` (any mapping whose source `columnName` was removed). When
* either list ends up empty, drops the field entirely so schema validation
* doesn't see an empty object. Returns the same group reference when nothing
* changed.
*/
export function stripGroupDeps(group: WorkflowGroup, removed: ReadonlySet<string>): WorkflowGroup {
const cols = group.dependencies?.columns ?? []
const mappings = group.inputMappings ?? []
const filteredDeps = cols.filter((d) => !removed.has(d))
const filteredMappings = mappings.filter((m) => !removed.has(m.columnName))
const depsChanged = filteredDeps.length !== cols.length
const mappingsChanged = filteredMappings.length !== mappings.length
if (!depsChanged && !mappingsChanged) return group
const next: WorkflowGroup = { ...group }
if (depsChanged) {
next.dependencies = filteredDeps.length > 0 ? { columns: filteredDeps } : undefined
}
if (mappingsChanged) {
next.inputMappings = filteredMappings.length > 0 ? filteredMappings : undefined
}
return next
}
/**
* Validates schema-level invariants. Run on every `addTableColumn`,
* `addWorkflowGroup`, `updateWorkflowGroup`, `renameColumn`, `reorderColumns`,
* etc. Returns a list of human-readable errors (empty if valid).
*/
export function validateSchema(schema: TableSchema, columnOrder: string[] | undefined): string[] {
const errors: string[] = []
// Group refs and columnOrder hold stable column ids (not display names).
const columnsById = new Map(schema.columns.map((c) => [getColumnId(c), c]))
const groups = schema.workflowGroups ?? []
const groupsById = new Map(groups.map((g) => [g.id, g]))
// Reference integrity for group outputs.
const claimedColumns = new Map<string, string>() // columnId → groupId
for (const group of groups) {
if (group.outputs.length === 0) {
errors.push(`Workflow group "${group.name ?? group.id}" has no outputs.`)
}
for (const out of group.outputs) {
const col = columnsById.get(out.columnName)
if (!col) {
errors.push(
`Workflow group "${group.name ?? group.id}" references missing column "${out.columnName}".`
)
continue
}
if (col.workflowGroupId !== group.id) {
errors.push(
`Column "${col.name}" is referenced by group "${group.id}" but its workflowGroupId is "${col.workflowGroupId ?? '(unset)'}".`
)
}
const claimer = claimedColumns.get(out.columnName)
if (claimer && claimer !== group.id) {
errors.push(
`Column "${out.columnName}" is claimed by both groups "${claimer}" and "${group.id}".`
)
} else {
claimedColumns.set(out.columnName, group.id)
}
}
}
// Every column flagged with a workflowGroupId must appear in exactly one group's outputs.
for (const col of schema.columns) {
if (!col.workflowGroupId) continue
if (!groupsById.has(col.workflowGroupId)) {
errors.push(
`Column "${col.name}" references missing workflow group "${col.workflowGroupId}".`
)
continue
}
if (claimedColumns.get(getColumnId(col)) !== col.workflowGroupId) {
errors.push(
`Column "${col.name}" has workflowGroupId "${col.workflowGroupId}" but isn't in that group's outputs.`
)
}
if (col.required) {
errors.push(`Workflow-output column "${col.name}" cannot be required.`)
}
if (col.unique) {
errors.push(`Workflow-output column "${col.name}" cannot be unique.`)
}
}
// Dependency integrity. Deps are columns only — workflow output columns are
// valid deps too (the upstream group fills them, downstream becomes eligible
// when filled). A group can't depend on its own outputs.
for (const group of groups) {
const ownOutputs = new Set(group.outputs.map((o) => o.columnName))
for (const depCol of group.dependencies?.columns ?? []) {
const col = columnsById.get(depCol)
if (!col) {
errors.push(`Group "${group.name ?? group.id}" depends on missing column "${depCol}".`)
continue
}
if (ownOutputs.has(depCol)) {
errors.push(
`Group "${group.name ?? group.id}" depends on its own output column "${depCol}".`
)
}
}
}
// Cycle detection on the column-induced group graph. An edge A → B exists
// when B depends on a column that A produces.
const cycle = findGroupCycle(groups)
if (cycle) {
errors.push(
`Workflow groups form a dependency cycle: ${cycle.map((id) => groupsById.get(id)?.name ?? id).join(' → ')}.`
)
}
// Layout: every group's outputs must be contiguous in columnOrder (when set).
if (columnOrder && columnOrder.length > 0) {
for (const split of findSplitGroups(columnOrder, groups)) {
errors.push(
`Workflow group "${split.groupName}" output columns must be contiguous; got order [${split.actual.join(', ')}].`
)
}
}
return errors
}
/**
* Returns the cycle as an ordered list of group ids, or null if acyclic. Edges
* are induced by columns: an edge A → B exists iff B depends on a column that
* A produces.
*/
function findGroupCycle(groups: WorkflowGroup[]): string[] | null {
// Map each output column → the group that produces it.
const producerByColumn = new Map<string, string>()
for (const g of groups) {
for (const o of g.outputs) producerByColumn.set(o.columnName, g.id)
}
const adjacency = new Map<string, string[]>()
for (const g of groups) {
const upstream = new Set<string>()
for (const depCol of g.dependencies?.columns ?? []) {
const producer = producerByColumn.get(depCol)
if (producer && producer !== g.id) upstream.add(producer)
}
adjacency.set(g.id, [...upstream])
}
const VISITING = 1
const VISITED = 2
const state = new Map<string, number>()
const stack: string[] = []
const dfs = (id: string): string[] | null => {
if (state.get(id) === VISITED) return null
if (state.get(id) === VISITING) {
const cycleStart = stack.indexOf(id)
return cycleStart >= 0 ? [...stack.slice(cycleStart), id] : [id]
}
state.set(id, VISITING)
stack.push(id)
for (const next of adjacency.get(id) ?? []) {
const found = dfs(next)
if (found) return found
}
stack.pop()
state.set(id, VISITED)
return null
}
for (const g of groups) {
const cycle = dfs(g.id)
if (cycle) return cycle
}
return null
}
interface SplitGroupReport {
groupId: string
groupName: string
actual: number[]
}
/**
* Cell context stored on `paused_executions.metadata` so the resume worker
* can route post-resume block outputs back to the same `(tableId, rowId,
* groupId)` cell — i.e., one logical cell execution across pause/resume
* cycles instead of two.
*/
export interface CellResumeContext {
tableId: string
tableName: string
rowId: string
groupId: string
workspaceId: string
workflowId: string
}
interface PausedMetadataPatch {
cellContext?: CellResumeContext
[key: string]: unknown
}
/**
* Stash the cell context on the matching `paused_executions` row. Called
* by the cell task right after it writes the `pending`/paused state. The
* pause record was written by `PauseResumeManager.persistPauseResult`
* before `executeWorkflow` returned, so the row exists.
*/
export async function stashCellContextForResume(
ctx: CellResumeContext & { executionId: string }
): Promise<void> {
const { executionId, ...cellContext } = ctx
try {
const patch: PausedMetadataPatch = { cellContext }
await db
.update(pausedExecutions)
.set({
metadata: sql`coalesce(${pausedExecutions.metadata}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb`,
updatedAt: new Date(),
})
.where(eq(pausedExecutions.executionId, executionId))
} catch (err) {
logger.error(
`Failed to stash cell context on paused_executions (executionId=${executionId}):`,
err
)
}
}
/**
* Returns the cell context for an execution if one was stashed at pause
* time. Used by the resume worker to know whether the workflow it's about
* to resume belongs to a table cell — and if so, where to write outputs.
*/
export async function findCellContextByExecutionId(
executionId: string
): Promise<CellResumeContext | null> {
try {
const [row] = await db
.select({ metadata: pausedExecutions.metadata })
.from(pausedExecutions)
.where(eq(pausedExecutions.executionId, executionId))
.limit(1)
const meta = row?.metadata as PausedMetadataPatch | null
return meta?.cellContext ?? null
} catch (err) {
logger.error(`Failed to read cell context for executionId=${executionId}:`, err)
return null
}
}
/**
* Returns groups whose output columns occupy non-contiguous positions in the
* given columnOrder. Empty array means all groups are cohesive.
*/
export function findSplitGroups(
columnOrder: string[],
groups: WorkflowGroup[]
): SplitGroupReport[] {
const positions = new Map<string, number>()
columnOrder.forEach((name, idx) => positions.set(name, idx))
const reports: SplitGroupReport[] = []
for (const group of groups) {
const indices = group.outputs
.map((o) => positions.get(o.columnName))
.filter((i): i is number => i !== undefined)
.sort((a, b) => a - b)
if (indices.length < 2) continue
const min = indices[0]
const max = indices[indices.length - 1]
if (max - min + 1 !== indices.length) {
reports.push({
groupId: group.id,
groupName: group.name ?? group.id,
actual: indices,
})
}
}
return reports
}
/** Throws if the schema has any invariant violations. Convenience for callers. */
export function assertValidSchema(schema: TableSchema, columnOrder: string[] | undefined): void {
const errs = validateSchema(schema, columnOrder)
if (errs.length > 0) {
throw new Error(`Schema validation failed: ${errs.join('; ')}`)
}
}