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
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { task } from '@trigger.dev/sdk'
|
|
import { withCascadeLock } from '@/lib/table/cascade-lock'
|
|
import { isExecCancelled } from '@/lib/table/deps'
|
|
import type { RowData, RowExecutionMetadata } from '@/lib/table/types'
|
|
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
|
import { RESUME_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits'
|
|
|
|
const logger = createLogger('TriggerResumeExecution')
|
|
|
|
export type ResumeExecutionPayload = {
|
|
resumeEntryId: string
|
|
resumeExecutionId: string
|
|
pausedExecutionId: string
|
|
contextId: string
|
|
resumeInput: unknown
|
|
userId: string
|
|
workflowId: string
|
|
parentExecutionId: string
|
|
}
|
|
|
|
export async function executeResumeJob(payload: ResumeExecutionPayload) {
|
|
const { resumeExecutionId, pausedExecutionId, contextId, workflowId, parentExecutionId } = payload
|
|
|
|
logger.info('Starting background resume execution', {
|
|
resumeExecutionId,
|
|
pausedExecutionId,
|
|
contextId,
|
|
workflowId,
|
|
parentExecutionId,
|
|
})
|
|
|
|
try {
|
|
const pausedExecution = await PauseResumeManager.getPausedExecutionById(pausedExecutionId)
|
|
if (!pausedExecution) {
|
|
throw new Error(`Paused execution not found: ${pausedExecutionId}`)
|
|
}
|
|
|
|
// If this paused execution belongs to a table cell, rehydrate the cell
|
|
// context so post-resume block outputs land on the same row + group as
|
|
// the original cell task. Without this, blocks that run after the human
|
|
// approves write nothing back to the table — the row silently truncates
|
|
// at the pause boundary.
|
|
const { findCellContextByExecutionId } = await import('@/lib/table/workflow-columns')
|
|
const cellContext = await findCellContextByExecutionId(parentExecutionId)
|
|
|
|
// A paused/awaiting table cell that was cancelled by "Stop all" must not
|
|
// resume — the cancel write is authoritative (matches the cell-write guard
|
|
// philosophy). Aborting here also stops the wasted compute the guard alone
|
|
// can't prevent. Read the cell's current exec and bail if cancelled.
|
|
if (cellContext) {
|
|
const { getRowById } = await import('@/lib/table/rows/service')
|
|
const cellRow = await getRowById(
|
|
cellContext.tableId,
|
|
cellContext.rowId,
|
|
cellContext.workspaceId
|
|
)
|
|
if (isExecCancelled(cellRow?.executions?.[cellContext.groupId])) {
|
|
logger.info('Skipping resume — table cell cancelled', {
|
|
tableId: cellContext.tableId,
|
|
rowId: cellContext.rowId,
|
|
groupId: cellContext.groupId,
|
|
parentExecutionId,
|
|
})
|
|
return {
|
|
success: false,
|
|
workflowId,
|
|
executionId: resumeExecutionId,
|
|
parentExecutionId,
|
|
status: 'cancelled' as const,
|
|
output: undefined,
|
|
executedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
}
|
|
|
|
const writers = cellContext
|
|
? await buildResumeCellWriters(cellContext, parentExecutionId)
|
|
: null
|
|
|
|
// No cell context → plain resume, no lock, no cascade continuation.
|
|
if (!cellContext || !writers) {
|
|
const result = await PauseResumeManager.startResumeExecution({
|
|
resumeEntryId: payload.resumeEntryId,
|
|
resumeExecutionId: payload.resumeExecutionId,
|
|
pausedExecution,
|
|
contextId: payload.contextId,
|
|
resumeInput: payload.resumeInput,
|
|
userId: payload.userId,
|
|
})
|
|
logger.info('Background resume execution completed', {
|
|
resumeExecutionId,
|
|
workflowId,
|
|
success: result.success,
|
|
status: result.status,
|
|
})
|
|
return {
|
|
success: result.success,
|
|
workflowId,
|
|
executionId: resumeExecutionId,
|
|
parentExecutionId,
|
|
status: result.status,
|
|
output: result.output,
|
|
executedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
// Cell-context path: hold the row's cascade lock for the resume + any
|
|
// downstream cascade continuation. On lock contention, fall through to
|
|
// resume-only (the lock holder will pick up the resumed group's
|
|
// completion on its next eligibility scan).
|
|
const outcome = await withCascadeLock(
|
|
cellContext.tableId,
|
|
cellContext.rowId,
|
|
parentExecutionId,
|
|
async () => {
|
|
const result = await runResumeAndCellTerminal(payload, pausedExecution, writers)
|
|
if (result.status === 'paused') return result
|
|
await continueCascadeAfterResume(cellContext)
|
|
return result
|
|
}
|
|
)
|
|
|
|
let result
|
|
if (outcome.status === 'contended') {
|
|
logger.info(
|
|
`Resume cascade lock held — writing resumed group only (table=${cellContext.tableId} row=${cellContext.rowId} executionId=${parentExecutionId})`
|
|
)
|
|
result = await runResumeAndCellTerminal(payload, pausedExecution, writers)
|
|
} else {
|
|
result = outcome.result
|
|
}
|
|
|
|
logger.info('Background resume execution completed', {
|
|
resumeExecutionId,
|
|
workflowId,
|
|
success: result.success,
|
|
status: result.status,
|
|
})
|
|
|
|
return {
|
|
success: result.success,
|
|
workflowId,
|
|
executionId: resumeExecutionId,
|
|
parentExecutionId,
|
|
status: result.status,
|
|
output: result.output,
|
|
executedAt: new Date().toISOString(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Background resume execution failed', {
|
|
resumeExecutionId,
|
|
workflowId,
|
|
error: toError(error).message,
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
type CellWriters = {
|
|
cellOnBlockComplete: (blockId: string, output: unknown) => Promise<void>
|
|
writeCellTerminal: (
|
|
status: 'completed' | 'error' | 'paused',
|
|
error: string | null
|
|
) => Promise<void>
|
|
}
|
|
|
|
async function buildResumeCellWriters(
|
|
cellContext: {
|
|
tableId: string
|
|
rowId: string
|
|
workspaceId: string
|
|
groupId: string
|
|
workflowId: string
|
|
},
|
|
parentExecutionId: string
|
|
): Promise<CellWriters | null> {
|
|
const { getTableById } = await import('@/lib/table/service')
|
|
const { writeWorkflowGroupState, buildOutputsByBlockId } = await import('@/lib/table/cell-write')
|
|
const { pluckByPath } = await import('@/lib/table/pluck')
|
|
|
|
const table = await getTableById(cellContext.tableId)
|
|
const group = table?.schema.workflowGroups?.find((g) => g.id === cellContext.groupId)
|
|
if (!group) {
|
|
logger.warn('Cell context found but table or group missing — falling back to plain resume', {
|
|
parentExecutionId,
|
|
tableId: cellContext.tableId,
|
|
groupId: cellContext.groupId,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const outputsByBlockId = buildOutputsByBlockId(group)
|
|
const accumulatedData: RowData = {}
|
|
const blockErrors: Record<string, string> = {}
|
|
const writeCtx = {
|
|
tableId: cellContext.tableId,
|
|
rowId: cellContext.rowId,
|
|
workspaceId: cellContext.workspaceId,
|
|
groupId: cellContext.groupId,
|
|
executionId: parentExecutionId,
|
|
requestId: `wfgrp-resume-${parentExecutionId}`,
|
|
}
|
|
let writeChain: Promise<void> = Promise.resolve()
|
|
let terminalWritten = false
|
|
|
|
const cellOnBlockComplete = async (blockId: string, output: unknown) => {
|
|
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 errorMessage =
|
|
blockResult &&
|
|
typeof blockResult === 'object' &&
|
|
typeof (blockResult as { error?: unknown }).error === 'string'
|
|
? (blockResult as { error: string }).error
|
|
: null
|
|
if (errorMessage) {
|
|
blockErrors[blockId] = errorMessage
|
|
} else {
|
|
for (const out of outputs) {
|
|
const plucked = pluckByPath(blockResult, out.path)
|
|
if (plucked === undefined) continue
|
|
accumulatedData[out.columnName] = plucked as RowData[string]
|
|
}
|
|
}
|
|
const dataSnapshot: RowData = { ...accumulatedData }
|
|
const blockErrorsSnapshot = { ...blockErrors }
|
|
writeChain = writeChain
|
|
.then(async () => {
|
|
if (terminalWritten) return
|
|
const partial: RowExecutionMetadata = {
|
|
status: 'running',
|
|
executionId: parentExecutionId,
|
|
jobId: null,
|
|
workflowId: cellContext.workflowId,
|
|
error: null,
|
|
blockErrors: blockErrorsSnapshot,
|
|
}
|
|
await writeWorkflowGroupState(writeCtx, {
|
|
executionState: partial,
|
|
dataPatch: dataSnapshot,
|
|
})
|
|
})
|
|
.catch((err) => {
|
|
logger.warn(
|
|
`Resume per-block partial write failed (table=${cellContext.tableId} row=${cellContext.rowId} group=${cellContext.groupId}):`,
|
|
err
|
|
)
|
|
})
|
|
}
|
|
|
|
const writeCellTerminal = async (
|
|
status: 'completed' | 'error' | 'paused',
|
|
error: string | null
|
|
) => {
|
|
terminalWritten = true
|
|
await writeChain.catch(() => {})
|
|
// Paused → keep `pending` + sentinel jobId so eligibility predicates
|
|
// continue treating the row as in-flight while we wait on another
|
|
// pause. Mirrors the initial cell-task pause branch.
|
|
const terminal: RowExecutionMetadata =
|
|
status === 'paused'
|
|
? {
|
|
status: 'pending',
|
|
executionId: parentExecutionId,
|
|
jobId: `paused-${parentExecutionId}`,
|
|
workflowId: cellContext.workflowId,
|
|
error: null,
|
|
blockErrors,
|
|
}
|
|
: {
|
|
status,
|
|
executionId: parentExecutionId,
|
|
jobId: null,
|
|
workflowId: cellContext.workflowId,
|
|
error,
|
|
runningBlockIds: [],
|
|
blockErrors,
|
|
}
|
|
await writeWorkflowGroupState(writeCtx, {
|
|
executionState: terminal,
|
|
dataPatch: accumulatedData,
|
|
})
|
|
}
|
|
|
|
return { cellOnBlockComplete, writeCellTerminal }
|
|
}
|
|
|
|
async function runResumeAndCellTerminal(
|
|
payload: ResumeExecutionPayload,
|
|
pausedExecution: Awaited<ReturnType<typeof PauseResumeManager.getPausedExecutionById>>,
|
|
writers: CellWriters
|
|
): Promise<Awaited<ReturnType<typeof PauseResumeManager.startResumeExecution>>> {
|
|
if (!pausedExecution) throw new Error('Paused execution missing — already nulled by caller')
|
|
const result = await PauseResumeManager.startResumeExecution({
|
|
resumeEntryId: payload.resumeEntryId,
|
|
resumeExecutionId: payload.resumeExecutionId,
|
|
pausedExecution,
|
|
contextId: payload.contextId,
|
|
resumeInput: payload.resumeInput,
|
|
userId: payload.userId,
|
|
onBlockComplete: writers.cellOnBlockComplete,
|
|
})
|
|
|
|
if (result.status === 'paused') {
|
|
await writers.writeCellTerminal('paused', null)
|
|
} else if (result.success) {
|
|
await writers.writeCellTerminal('completed', null)
|
|
} else {
|
|
await writers.writeCellTerminal('error', result.error ?? 'Workflow execution failed')
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
async function continueCascadeAfterResume(cellContext: {
|
|
tableId: string
|
|
rowId: string
|
|
workspaceId: string
|
|
groupId: string
|
|
}): Promise<void> {
|
|
const { getTableById } = await import('@/lib/table/service')
|
|
const { getRowById } = await import('@/lib/table/rows/service')
|
|
const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns')
|
|
const { runRowCascadeLoop } = await import('@/background/workflow-column-execution')
|
|
|
|
const freshTable = await getTableById(cellContext.tableId)
|
|
if (!freshTable) return
|
|
const freshRow = await getRowById(cellContext.tableId, cellContext.rowId, cellContext.workspaceId)
|
|
if (!freshRow) return
|
|
const next = pickNextEligibleGroupForRow(freshTable, freshRow, cellContext.groupId)
|
|
if (!next) return
|
|
await runRowCascadeLoop({
|
|
tableId: cellContext.tableId,
|
|
tableName: freshTable.name,
|
|
rowId: cellContext.rowId,
|
|
workspaceId: cellContext.workspaceId,
|
|
groupId: next.id,
|
|
workflowId: next.workflowId,
|
|
executionId: generateId(),
|
|
})
|
|
}
|
|
|
|
export const resumeExecutionTask = task({
|
|
id: 'resume-execution',
|
|
machine: 'medium-1x',
|
|
retry: {
|
|
maxAttempts: 1,
|
|
},
|
|
queue: {
|
|
concurrencyLimit: RESUME_EXECUTION_CONCURRENCY_LIMIT,
|
|
},
|
|
run: executeResumeJob,
|
|
})
|