fix(v2): derive the log and run status enums from the persisted status list (#6612)

* fix(v2): derive the log and run status enums from the persisted status list

`GET /api/v2/logs` and `GET /api/v2/logs/{runId}` parse the raw
`workflow_execution_logs.status` column against a six-value enum that omits
`paused`, so a run holding that value returns 500. The list response is
validated whole-page, so one such row 500s every page it lands on, and the
row is durable until the run is resumed, cancelled, or failed.

`paused` is not written by an ordinary human-in-the-loop pause — that path
persists `pending` (logging-session.ts:1180). It is written by
`PauseResumeManager.markResumeAttemptFailed`, which fires on any
`ResumeAdmissionError`: a workspace over its usage limit, an archived or
undeployed workflow, or a concurrent resume losing the claim race. That is a
routine business path.

The enum was supposed to be protected by an `AssertNever` exhaustiveness gate,
but the gate was vacuous: it compared against `PersistedWorkflowExecutionStatus`,
a hand-written union that was itself missing `paused`, because the write goes
through a raw `sql` CASE fragment Drizzle cannot type-check. Adding `paused` to
both lists would leave the same vacuous gate in place for the next status.

Instead, `PERSISTED_WORKFLOW_EXECUTION_STATUSES` becomes the single runtime
source of truth, `PersistedWorkflowExecutionStatus` is derived from it, and both
v2 contracts derive their enums from the const rather than re-declaring them.
Both surfaces pass the column through verbatim, so their reported set is the
persisted set by definition — there is no editorial choice for a gate to force,
only the question of whether a newly persisted status should be public, which
the option-list tests now pin. The `[...V2_PERSISTED_RUN_STATUSES, 'paused']`
append on the runs contract is deleted rather than adjusted; it would otherwise
be a duplicate.

Alternatives rejected:
- A `.catch()` or `safeParse` in the presenters is dead code:
  `v2-json-route.ts:271` re-parses the whole body with the same schema.
- Normalizing `markResumeAttemptFailed` to write `pending` would remove the
  distinction the resume claim query at human-in-the-loop-manager.ts:973 relies
  on, and leaves the contract wrong for any other future status.
- Typing the Drizzle column does not help: the offending write is a raw `sql`
  fragment, and `packages/db` cannot import the app's status list.

The v2 workflows spec changes are reordering and description only — the value
set there already contained `paused`. The v2 logs spec gains `paused`, which is
additive and safe while the whole `/api/v2` surface is behind the off-by-default
`v2-api` flag; it must land before v2 GA, after which it would be breaking.

* fix(v2): document both provenances of a reported paused run status

* fix(v2): stop promising a paused discriminator the response cannot always provide

* fix(v2): describe the paused discriminator as the code actually records it
This commit is contained in:
Waleed
2026-08-12 01:51:55 -07:00
committed by GitHub
parent 47f143016e
commit 366829b6b0
10 changed files with 166 additions and 92 deletions
+20 -4
View File
@@ -640,8 +640,16 @@
},
"status": {
"type": "string",
"enum": ["pending", "running", "redacting", "completed", "failed", "cancelled"],
"description": "Current execution status. `redacting` is transient while run output is scrubbed."
"enum": [
"pending",
"running",
"paused",
"redacting",
"completed",
"failed",
"cancelled"
],
"description": "Current execution status. `redacting` is transient while run output is scrubbed. `paused` is reported when a resume attempt did not run to completion and the run is waiting to be resumed again."
},
"level": {
"type": "string",
@@ -1028,8 +1036,16 @@
},
"status": {
"type": "string",
"enum": ["pending", "running", "redacting", "completed", "failed", "cancelled"],
"description": "Current execution status. `redacting` is transient while run output is scrubbed."
"enum": [
"pending",
"running",
"paused",
"redacting",
"completed",
"failed",
"cancelled"
],
"description": "Current execution status. `redacting` is transient while run output is scrubbed. `paused` is reported when a resume attempt did not run to completion and the run is waiting to be resumed again."
},
"level": {
"type": "string",
+5 -5
View File
@@ -3923,13 +3923,13 @@
"enum": [
"pending",
"running",
"paused",
"redacting",
"completed",
"failed",
"cancelled",
"paused"
"cancelled"
],
"description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed."
"description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there."
},
"trigger": {
"type": "string",
@@ -4063,14 +4063,14 @@
"enum": [
"pending",
"running",
"paused",
"redacting",
"completed",
"failed",
"cancelled",
"paused",
"queued"
],
"description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed."
"description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there."
},
"trigger": {
"anyOf": [
@@ -94,6 +94,21 @@ describe('GET /api/v2/logs/[runId]', () => {
})
})
it('serves a run whose persisted status is paused', async () => {
mocks.execute.mockResolvedValue({
log: { ...log, status: 'paused' },
workflowFolderPath: '/agents',
executionData: { traceSpans: [], finalOutput: null },
})
const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), {
params: Promise.resolve({ runId: 'run-1' }),
})
expect(response.status).toBe(200)
expect((await response.json()).data).toMatchObject({ runId: 'run-1', status: 'paused' })
})
it('conceals canonical workspace authorization as log not-found', async () => {
mocks.execute.mockRejectedValueOnce(new NoWorkspaceAccessError())
+18
View File
@@ -98,6 +98,24 @@ describe('GET /api/v2/logs', () => {
})
})
it('serves a run whose persisted status is paused', async () => {
mocks.execute.mockResolvedValue({
items: [{ log: { ...log, status: 'paused' }, executionData: null }],
nextCursor: null,
includeFullDetails: false,
includeFinalOutput: false,
includeTraceSpans: false,
})
const response = await GET(
new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`)
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data[0]).toMatchObject({ runId: 'run-1', status: 'paused' })
})
it('rejects malformed cursors after admission and before protected reads', async () => {
const response = await GET(
new NextRequest(
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { v2LogStatusSchema } from '@/lib/api/contracts/v2/logs'
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
/**
* Both log endpoints pass `workflow_execution_logs.status` through verbatim — unlike the
* run endpoints there is no `paused` overlay and no `queued` — so any drift between the
* reported enum and the persisted list 500s a whole page of results.
*/
describe('v2 log status schema', () => {
it('publishes exactly the persisted statuses', () => {
expect(v2LogStatusSchema.options).toEqual([
'pending',
'running',
'paused',
'redacting',
'completed',
'failed',
'cancelled',
])
})
it('stays derived from the persisted status list', () => {
expect(v2LogStatusSchema.options).toEqual([...PERSISTED_WORKFLOW_EXECUTION_STATUSES])
})
})
+9 -22
View File
@@ -10,7 +10,7 @@ import {
v2FolderPathSchema,
v2TimestampSchema,
} from '@/lib/api/contracts/v2/shared'
import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types'
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
/**
* v2 logs contracts. The query schemas are reused verbatim from v1 (the request
@@ -23,29 +23,16 @@ const v2LogCostSchema = z
.nullable()
.describe('Cost charged for the run, or null when unavailable.')
/**
* Every status the execution logger can persist, including the transient
* `redacting` state written while a finished run's output is scrubbed. The
* column is free text, so a value missing here fails the response parse and
* turns a single row into a 500 for the whole page. `_ExhaustiveLogStatus`
* makes a future addition to the persisted union a compile error instead.
* Both log endpoints pass `workflow_execution_logs.status` through verbatim, so the
* reported set is exactly the persisted set — a value missing here fails the response
* parse, and because list validation is whole-page one such row turns an entire page
* into a 500.
*/
const V2_LOG_STATUSES = [
'pending',
'running',
'redacting',
'completed',
'failed',
'cancelled',
] as const satisfies readonly PersistedWorkflowExecutionStatus[]
type AssertNever<T extends never> = T
type _ExhaustiveLogStatus = AssertNever<
Exclude<PersistedWorkflowExecutionStatus, (typeof V2_LOG_STATUSES)[number]>
>
export const v2LogStatusSchema = z
.enum(V2_LOG_STATUSES)
.describe('Current execution status. `redacting` is transient while run output is scrubbed.')
.enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES)
.describe(
'Current execution status. `redacting` is transient while run output is scrubbed. `paused` is reported when a resume attempt did not run to completion and the run is waiting to be resumed again.'
)
/** Execution `files` is a per-run jsonb array of attachment metadata. */
const v2LogFilesSchema = z
@@ -4,36 +4,49 @@ import {
v2WorkflowRunStatusFilterSchema,
v2WorkflowRunStatusValueSchema,
} from '@/lib/api/contracts/v2/workflows'
import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types'
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
/**
* The runtime mirror of the persisted union. `satisfies` keeps it honest against
* `PersistedWorkflowExecutionStatus`, and the `AssertNever` gate in the contract keeps
* that union honest against the reported enums, so a status added to the execution logger
* fails compilation in both places before it can 500 a response parse.
* Both run endpoints report `workflow_execution_logs.status`, overlaid with `paused` from
* `paused_executions`, so every reported value lands in the persisted set. These tests
* guard the two ways that can break: the derivation being replaced by a hand-maintained
* list again, and a status being added to the persisted set without anyone confirming it
* belongs on the public wire (and regenerating the OpenAPI specs).
*/
const PERSISTED_STATUSES = [
'pending',
'running',
'redacting',
'completed',
'failed',
'cancelled',
] as const satisfies readonly PersistedWorkflowExecutionStatus[]
describe('v2 workflow run status schemas', () => {
it.each(PERSISTED_STATUSES)('reports the persisted status %s on both run endpoints', (status) => {
expect(v2WorkflowRunListStatusValueSchema.parse(status)).toBe(status)
expect(v2WorkflowRunStatusValueSchema.parse(status)).toBe(status)
it('publishes exactly the persisted statuses on the run list', () => {
expect(v2WorkflowRunListStatusValueSchema.options).toEqual([
'pending',
'running',
'paused',
'redacting',
'completed',
'failed',
'cancelled',
])
})
it('reports the paused overlay on both run endpoints', () => {
expect(v2WorkflowRunListStatusValueSchema.parse('paused')).toBe('paused')
expect(v2WorkflowRunStatusValueSchema.parse('paused')).toBe('paused')
it('stays derived from the persisted status list', () => {
expect(v2WorkflowRunListStatusValueSchema.options).toEqual([
...PERSISTED_WORKFLOW_EXECUTION_STATUSES,
])
expect(v2WorkflowRunStatusValueSchema.options).toEqual([
...PERSISTED_WORKFLOW_EXECUTION_STATUSES,
'queued',
])
})
it('reports queued only where the job queue is consulted', () => {
expect(v2WorkflowRunStatusValueSchema.parse('queued')).toBe('queued')
expect(v2WorkflowRunStatusValueSchema.options).toEqual([
'pending',
'running',
'paused',
'redacting',
'completed',
'failed',
'cancelled',
'queued',
])
expect(v2WorkflowRunListStatusValueSchema.safeParse('queued').success).toBe(false)
})
+13 -33
View File
@@ -36,7 +36,7 @@ import {
workflowIdParamsSchema,
} from '@/lib/api/contracts/workflows'
import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults'
import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types'
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
export const V2_WORKFLOW_RUN_ID_HEADER = 'X-Run-Id'
@@ -982,40 +982,20 @@ export const v2ResumeWorkflowContract = defineRouteContract({
},
})
/**
* Every status the execution logger can persist into `workflow_execution_logs.status`,
* including the transient `redacting` state written while a finished run's output is
* scrubbed. The column is free text and both run endpoints pass it straight through, so
* a value missing here fails the response parse — and because list validation is
* whole-page, one such row turns an entire page into a 500. `_ExhaustiveRunStatus` makes
* a future addition to the persisted union a compile error instead.
*/
const V2_PERSISTED_RUN_STATUSES = [
'pending',
'running',
'redacting',
'completed',
'failed',
'cancelled',
] as const satisfies readonly PersistedWorkflowExecutionStatus[]
type AssertNever<T extends never> = T
type _ExhaustiveRunStatus = AssertNever<
Exclude<PersistedWorkflowExecutionStatus, (typeof V2_PERSISTED_RUN_STATUSES)[number]>
>
/**
* The list projection overlays `paused` onto the persisted status whenever the run has a
* `paused` or `partially_resumed` row in `paused_executions`. It cannot report `queued`:
* a run that is still only in the job queue has no log row to list.
*/
const V2_WORKFLOW_RUN_LIST_STATUSES = [...V2_PERSISTED_RUN_STATUSES, 'paused'] as const
const RUN_STATUS_DESCRIPTION =
'Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed.'
'Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there.'
/**
* The list projection passes `workflow_execution_logs.status` through except where it
* overlays `paused` for a run holding a `paused` or `partially_resumed` row in
* `paused_executions` — so a reported `paused` is either that overlay or the persisted
* value a failed resume attempt left behind. Both branches land in the persisted set, so the reported enum is
* derived from it — a value missing here fails the response parse, and because list
* validation is whole-page one such row turns an entire page into a 500. `queued` is not
* reportable: a run still only in the job queue has no log row to list.
*/
export const v2WorkflowRunListStatusValueSchema = z
.enum(V2_WORKFLOW_RUN_LIST_STATUSES)
.enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES)
.describe(RUN_STATUS_DESCRIPTION)
/**
@@ -1023,7 +1003,7 @@ export const v2WorkflowRunListStatusValueSchema = z
* so a run accepted but not yet started reports `queued` rather than 404.
*/
export const v2WorkflowRunStatusValueSchema = z
.enum([...V2_WORKFLOW_RUN_LIST_STATUSES, 'queued'])
.enum([...PERSISTED_WORKFLOW_EXECUTION_STATUSES, 'queued'])
.describe(RUN_STATUS_DESCRIPTION)
/**
+24 -6
View File
@@ -204,13 +204,31 @@ export interface WorkflowExecutionLog {
createdAt: string
}
/**
* Every value written into `workflow_execution_logs.status`. The column is free text and
* one writer sets it through a raw `sql` CASE Drizzle cannot type-check, so this list —
* not the column type — is the only source of truth. API contracts that pass the column
* through derive their enums from it, so adding a status here widens the public wire; the
* contract tests fail until that widening is reviewed and the OpenAPI specs regenerated.
*
* `redacting` is transient while a finished run's output is scrubbed. `paused` is written
* only by `PauseResumeManager.markResumeAttemptFailed`, when a resume attempt does not run
* to completion — it failed admission, the run buffer was unavailable, the resume job could
* not be enqueued, or the attempt was cancelled. An ordinary human-in-the-loop pause
* persists `pending`.
*/
export const PERSISTED_WORKFLOW_EXECUTION_STATUSES = [
'pending',
'running',
'paused',
'redacting',
'completed',
'failed',
'cancelled',
] as const
export type PersistedWorkflowExecutionStatus =
| 'running'
| 'pending'
| 'completed'
| 'failed'
| 'cancelled'
| 'redacting'
(typeof PERSISTED_WORKFLOW_EXECUTION_STATUSES)[number]
export interface CompletedWorkflowExecutionLog extends WorkflowExecutionLog {
persistedStatus: PersistedWorkflowExecutionStatus
+2 -1
View File
@@ -411,7 +411,8 @@ export const workflowExecutionLogs = pgTable(
),
level: text('level').notNull(), // 'info' | 'error'
status: text('status').notNull().default('running'), // 'running' | 'pending' | 'completed' | 'failed' | 'cancelled'
/** See `PERSISTED_WORKFLOW_EXECUTION_STATUSES` in `apps/sim/lib/logs/types.ts`. */
status: text('status').notNull().default('running'),
trigger: text('trigger').notNull(), // 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
startedAt: timestamp('started_at').notNull(),