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

187 lines
6.6 KiB
TypeScript

/**
* Pure dep-satisfaction helpers shared by the server-side scheduler and the
* client UI. Lives in its own file (not `workflow-columns.ts`) so the client
* can import it without pulling in `@sim/db` and other server-only deps.
*/
import { createLogger } from '@sim/logger'
import type {
RowData,
RowExecutionMetadata,
RowExecutions,
TableRow,
WorkflowGroup,
} from '@/lib/table/types'
const logger = createLogger('OptimisticCascade')
/**
* True when the cell is `pending` / `queued` / `running`. Single source of
* truth for the "is this exec in flight" classification across the
* eligibility predicate, optimistic patches, status counters, and renderer.
* `pending` counts even without a jobId so the row-gutter Stop button is
* available the moment the user clicks Play — the cancel path writes
* `cancelled` authoritatively whether or not a real trigger.dev run exists
* yet, which is correct: cancel means "don't run this."
*/
export function isExecInFlight(exec: RowExecutionMetadata | undefined): boolean {
if (!exec) return false
const s = exec.status
return s === 'queued' || s === 'running' || s === 'pending'
}
/**
* A cell run the user/stop killed. The single source of truth for "do not run /
* do not write this cell" — used by the in-memory write guard, the worker's
* pre-execution check, and the resume worker. The SQL guard in
* `writeExecutionsPatch` mirrors this status test in its `WHERE`.
*/
export function isExecCancelled(exec: RowExecutionMetadata | undefined): boolean {
return exec?.status === 'cancelled'
}
/**
* Cancelled AND killed after `since`. The dispatcher's tombstone test: a cell
* cancelled after a dispatch was requested must be skipped by that dispatch's
* later windows, even though the dispatcher pre-stamped it before the stop.
*/
export function isExecCancelledAfter(exec: RowExecutionMetadata | undefined, since: Date): boolean {
if (!isExecCancelled(exec) || !exec?.cancelledAt) return false
const at = Date.parse(exec.cancelledAt)
return Number.isFinite(at) && at > since.getTime()
}
/**
* A dependency column counts as unmet when its value is empty OR explicitly
* `false`. An unchecked checkbox is treated as "dependency not satisfied", so
* only checking a box (false→true) makes dependents eligible — unchecking
* (true→false) never triggers a rerun.
*/
function isDepValueUnmet(value: unknown): boolean {
return value === null || value === undefined || value === '' || value === false
}
/**
* True when every output column the group writes still has a non-empty value
* on this row. The "completed" exec status is metadata, but the cells are the
* source of truth — if the user cleared an output cell, the row is effectively
* incomplete and should be re-run on dep-fill / manual incomplete-mode runs.
*/
export function areOutputsFilled(group: WorkflowGroup, row: TableRow): boolean {
if (group.outputs.length === 0) return true
for (const o of group.outputs) {
const v = row.data[o.columnName]
if (v === null || v === undefined || v === '') return false
}
return true
}
/**
* Returns true when every column this group depends on is non-empty on this
* row. Workflow output columns count the same as plain columns — the model
* is uniform.
*/
export function areGroupDepsSatisfied(group: WorkflowGroup, row: TableRow): boolean {
const cols = group.dependencies?.columns ?? []
for (const colName of cols) {
if (isDepValueUnmet(row.data[colName])) return false
}
return true
}
export interface UnmetDeps {
/** Column names whose value on this row is empty. */
columns: string[]
}
/**
* Like `areGroupDepsSatisfied` but returns *which* columns are unmet, so the
* UI can render "Waiting on column_a, column_b".
*/
export function getUnmetGroupDeps(group: WorkflowGroup, row: TableRow): UnmetDeps {
const cols = group.dependencies?.columns ?? []
const columns: string[] = []
for (const colName of cols) {
if (isDepValueUnmet(row.data[colName])) columns.push(colName)
}
return { columns }
}
/**
* Optimistic mirror of the server's row-update→scheduler cascade: for every
* workflow group whose deps were unmet *before* the patch and are satisfied
* *after*, OR whose dep column was touched by the patch (the server will
* cancel+re-run via `deriveExecClearsForDataPatch` + the in-flight cancel
* orchestration), return a new `executions` map with that group flipped to
* `pending`. The cell renderer treats `pending` as "Queued".
*
* Returns `null` when nothing changed, so callers can short-circuit.
*/
export function optimisticallyScheduleNewlyEligibleGroups(
groups: WorkflowGroup[],
beforeRow: TableRow,
patch: Partial<RowData>
): RowExecutions | null {
if (groups.length === 0) return null
const afterRow: TableRow = {
...beforeRow,
data: { ...beforeRow.data, ...patch } as RowData,
}
const patchedColumns = new Set(Object.keys(patch))
let next: RowExecutions | null = null
let flipped = 0
let skipped = 0
for (const group of groups) {
if (group.autoRun === false) {
skipped++
continue
}
if (!areGroupDepsSatisfied(group, afterRow)) {
skipped++
continue
}
const exec = beforeRow.executions?.[group.id]
if (exec?.status === 'pending' && exec.jobId) {
skipped++
continue
}
const isStaleCompleted = exec?.status === 'completed' && !areOutputsFilled(group, afterRow)
const wasSatisfied = areGroupDepsSatisfied(group, beforeRow)
const becameSatisfied = !wasSatisfied
const isRetryable = exec?.status === 'cancelled' || exec?.status === 'error'
// Dep-column touched: the server clears terminal entries + cancels in-
// flight downstream groups, so optimistically flip to `pending`
// regardless of current exec status (queued/running included — they're
// about to be cancelled and re-run).
const depTouched = (group.dependencies?.columns ?? []).some((d) => patchedColumns.has(d))
if (!depTouched && (exec?.status === 'queued' || exec?.status === 'running')) {
skipped++
continue
}
if (!becameSatisfied && !isStaleCompleted && !isRetryable && !depTouched && exec) {
skipped++
continue
}
flipped++
if (next === null) next = { ...(beforeRow.executions ?? {}) }
const pending: RowExecutionMetadata = {
status: 'pending',
executionId: exec?.executionId ?? null,
jobId: null,
workflowId: exec?.workflowId ?? group.workflowId,
error: null,
}
next[group.id] = pending
}
if (flipped > 0) {
logger.debug(`[OptimisticCascade] row=${beforeRow.id} flipped=${flipped} skipped=${skipped}`)
}
return next
}