Skip to content

Commit 366829b

Browse files
authored
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
1 parent 47f1430 commit 366829b

10 files changed

Lines changed: 165 additions & 91 deletions

File tree

apps/docs/openapi-v2-logs.json

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -640,8 +640,16 @@
640640
},
641641
"status": {
642642
"type": "string",
643-
"enum": ["pending", "running", "redacting", "completed", "failed", "cancelled"],
644-
"description": "Current execution status. `redacting` is transient while run output is scrubbed."
643+
"enum": [
644+
"pending",
645+
"running",
646+
"paused",
647+
"redacting",
648+
"completed",
649+
"failed",
650+
"cancelled"
651+
],
652+
"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."
645653
},
646654
"level": {
647655
"type": "string",
@@ -1028,8 +1036,16 @@
10281036
},
10291037
"status": {
10301038
"type": "string",
1031-
"enum": ["pending", "running", "redacting", "completed", "failed", "cancelled"],
1032-
"description": "Current execution status. `redacting` is transient while run output is scrubbed."
1039+
"enum": [
1040+
"pending",
1041+
"running",
1042+
"paused",
1043+
"redacting",
1044+
"completed",
1045+
"failed",
1046+
"cancelled"
1047+
],
1048+
"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."
10331049
},
10341050
"level": {
10351051
"type": "string",

apps/docs/openapi-v2-workflows.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3923,13 +3923,13 @@
39233923
"enum": [
39243924
"pending",
39253925
"running",
3926+
"paused",
39263927
"redacting",
39273928
"completed",
39283929
"failed",
3929-
"cancelled",
3930-
"paused"
3930+
"cancelled"
39313931
],
3932-
"description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed."
3932+
"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."
39333933
},
39343934
"trigger": {
39353935
"type": "string",
@@ -4063,14 +4063,14 @@
40634063
"enum": [
40644064
"pending",
40654065
"running",
4066+
"paused",
40664067
"redacting",
40674068
"completed",
40684069
"failed",
40694070
"cancelled",
4070-
"paused",
40714071
"queued"
40724072
],
4073-
"description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed."
4073+
"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."
40744074
},
40754075
"trigger": {
40764076
"anyOf": [

apps/sim/app/api/v2/logs/[runId]/route.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ describe('GET /api/v2/logs/[runId]', () => {
9494
})
9595
})
9696

97+
it('serves a run whose persisted status is paused', async () => {
98+
mocks.execute.mockResolvedValue({
99+
log: { ...log, status: 'paused' },
100+
workflowFolderPath: '/agents',
101+
executionData: { traceSpans: [], finalOutput: null },
102+
})
103+
104+
const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), {
105+
params: Promise.resolve({ runId: 'run-1' }),
106+
})
107+
108+
expect(response.status).toBe(200)
109+
expect((await response.json()).data).toMatchObject({ runId: 'run-1', status: 'paused' })
110+
})
111+
97112
it('conceals canonical workspace authorization as log not-found', async () => {
98113
mocks.execute.mockRejectedValueOnce(new NoWorkspaceAccessError())
99114

apps/sim/app/api/v2/logs/route.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,24 @@ describe('GET /api/v2/logs', () => {
9898
})
9999
})
100100

101+
it('serves a run whose persisted status is paused', async () => {
102+
mocks.execute.mockResolvedValue({
103+
items: [{ log: { ...log, status: 'paused' }, executionData: null }],
104+
nextCursor: null,
105+
includeFullDetails: false,
106+
includeFinalOutput: false,
107+
includeTraceSpans: false,
108+
})
109+
110+
const response = await GET(
111+
new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`)
112+
)
113+
const body = await response.json()
114+
115+
expect(response.status).toBe(200)
116+
expect(body.data[0]).toMatchObject({ runId: 'run-1', status: 'paused' })
117+
})
118+
101119
it('rejects malformed cursors after admission and before protected reads', async () => {
102120
const response = await GET(
103121
new NextRequest(
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { v2LogStatusSchema } from '@/lib/api/contracts/v2/logs'
3+
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
4+
5+
/**
6+
* Both log endpoints pass `workflow_execution_logs.status` through verbatim — unlike the
7+
* run endpoints there is no `paused` overlay and no `queued` — so any drift between the
8+
* reported enum and the persisted list 500s a whole page of results.
9+
*/
10+
describe('v2 log status schema', () => {
11+
it('publishes exactly the persisted statuses', () => {
12+
expect(v2LogStatusSchema.options).toEqual([
13+
'pending',
14+
'running',
15+
'paused',
16+
'redacting',
17+
'completed',
18+
'failed',
19+
'cancelled',
20+
])
21+
})
22+
23+
it('stays derived from the persisted status list', () => {
24+
expect(v2LogStatusSchema.options).toEqual([...PERSISTED_WORKFLOW_EXECUTION_STATUSES])
25+
})
26+
})

apps/sim/lib/api/contracts/v2/logs.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
v2FolderPathSchema,
1111
v2TimestampSchema,
1212
} from '@/lib/api/contracts/v2/shared'
13-
import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types'
13+
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
1414

1515
/**
1616
* v2 logs contracts. The query schemas are reused verbatim from v1 (the request
@@ -23,29 +23,16 @@ const v2LogCostSchema = z
2323
.nullable()
2424
.describe('Cost charged for the run, or null when unavailable.')
2525
/**
26-
* Every status the execution logger can persist, including the transient
27-
* `redacting` state written while a finished run's output is scrubbed. The
28-
* column is free text, so a value missing here fails the response parse and
29-
* turns a single row into a 500 for the whole page. `_ExhaustiveLogStatus`
30-
* makes a future addition to the persisted union a compile error instead.
26+
* Both log endpoints pass `workflow_execution_logs.status` through verbatim, so the
27+
* reported set is exactly the persisted set — a value missing here fails the response
28+
* parse, and because list validation is whole-page one such row turns an entire page
29+
* into a 500.
3130
*/
32-
const V2_LOG_STATUSES = [
33-
'pending',
34-
'running',
35-
'redacting',
36-
'completed',
37-
'failed',
38-
'cancelled',
39-
] as const satisfies readonly PersistedWorkflowExecutionStatus[]
40-
41-
type AssertNever<T extends never> = T
42-
type _ExhaustiveLogStatus = AssertNever<
43-
Exclude<PersistedWorkflowExecutionStatus, (typeof V2_LOG_STATUSES)[number]>
44-
>
45-
4631
export const v2LogStatusSchema = z
47-
.enum(V2_LOG_STATUSES)
48-
.describe('Current execution status. `redacting` is transient while run output is scrubbed.')
32+
.enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES)
33+
.describe(
34+
'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.'
35+
)
4936

5037
/** Execution `files` is a per-run jsonb array of attachment metadata. */
5138
const v2LogFilesSchema = z

apps/sim/lib/api/contracts/v2/workflow-run-status.test.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,49 @@ import {
44
v2WorkflowRunStatusFilterSchema,
55
v2WorkflowRunStatusValueSchema,
66
} from '@/lib/api/contracts/v2/workflows'
7-
import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types'
7+
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
88

99
/**
10-
* The runtime mirror of the persisted union. `satisfies` keeps it honest against
11-
* `PersistedWorkflowExecutionStatus`, and the `AssertNever` gate in the contract keeps
12-
* that union honest against the reported enums, so a status added to the execution logger
13-
* fails compilation in both places before it can 500 a response parse.
10+
* Both run endpoints report `workflow_execution_logs.status`, overlaid with `paused` from
11+
* `paused_executions`, so every reported value lands in the persisted set. These tests
12+
* guard the two ways that can break: the derivation being replaced by a hand-maintained
13+
* list again, and a status being added to the persisted set without anyone confirming it
14+
* belongs on the public wire (and regenerating the OpenAPI specs).
1415
*/
15-
const PERSISTED_STATUSES = [
16-
'pending',
17-
'running',
18-
'redacting',
19-
'completed',
20-
'failed',
21-
'cancelled',
22-
] as const satisfies readonly PersistedWorkflowExecutionStatus[]
23-
2416
describe('v2 workflow run status schemas', () => {
25-
it.each(PERSISTED_STATUSES)('reports the persisted status %s on both run endpoints', (status) => {
26-
expect(v2WorkflowRunListStatusValueSchema.parse(status)).toBe(status)
27-
expect(v2WorkflowRunStatusValueSchema.parse(status)).toBe(status)
17+
it('publishes exactly the persisted statuses on the run list', () => {
18+
expect(v2WorkflowRunListStatusValueSchema.options).toEqual([
19+
'pending',
20+
'running',
21+
'paused',
22+
'redacting',
23+
'completed',
24+
'failed',
25+
'cancelled',
26+
])
2827
})
2928

30-
it('reports the paused overlay on both run endpoints', () => {
31-
expect(v2WorkflowRunListStatusValueSchema.parse('paused')).toBe('paused')
32-
expect(v2WorkflowRunStatusValueSchema.parse('paused')).toBe('paused')
29+
it('stays derived from the persisted status list', () => {
30+
expect(v2WorkflowRunListStatusValueSchema.options).toEqual([
31+
...PERSISTED_WORKFLOW_EXECUTION_STATUSES,
32+
])
33+
expect(v2WorkflowRunStatusValueSchema.options).toEqual([
34+
...PERSISTED_WORKFLOW_EXECUTION_STATUSES,
35+
'queued',
36+
])
3337
})
3438

3539
it('reports queued only where the job queue is consulted', () => {
36-
expect(v2WorkflowRunStatusValueSchema.parse('queued')).toBe('queued')
40+
expect(v2WorkflowRunStatusValueSchema.options).toEqual([
41+
'pending',
42+
'running',
43+
'paused',
44+
'redacting',
45+
'completed',
46+
'failed',
47+
'cancelled',
48+
'queued',
49+
])
3750
expect(v2WorkflowRunListStatusValueSchema.safeParse('queued').success).toBe(false)
3851
})
3952

apps/sim/lib/api/contracts/v2/workflows.ts

Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
workflowIdParamsSchema,
3737
} from '@/lib/api/contracts/workflows'
3838
import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults'
39-
import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types'
39+
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
4040

4141
export const V2_WORKFLOW_RUN_ID_HEADER = 'X-Run-Id'
4242

@@ -982,48 +982,28 @@ export const v2ResumeWorkflowContract = defineRouteContract({
982982
},
983983
})
984984

985-
/**
986-
* Every status the execution logger can persist into `workflow_execution_logs.status`,
987-
* including the transient `redacting` state written while a finished run's output is
988-
* scrubbed. The column is free text and both run endpoints pass it straight through, so
989-
* a value missing here fails the response parse — and because list validation is
990-
* whole-page, one such row turns an entire page into a 500. `_ExhaustiveRunStatus` makes
991-
* a future addition to the persisted union a compile error instead.
992-
*/
993-
const V2_PERSISTED_RUN_STATUSES = [
994-
'pending',
995-
'running',
996-
'redacting',
997-
'completed',
998-
'failed',
999-
'cancelled',
1000-
] as const satisfies readonly PersistedWorkflowExecutionStatus[]
1001-
1002-
type AssertNever<T extends never> = T
1003-
type _ExhaustiveRunStatus = AssertNever<
1004-
Exclude<PersistedWorkflowExecutionStatus, (typeof V2_PERSISTED_RUN_STATUSES)[number]>
1005-
>
985+
const RUN_STATUS_DESCRIPTION =
986+
'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.'
1006987

1007988
/**
1008-
* The list projection overlays `paused` onto the persisted status whenever the run has a
1009-
* `paused` or `partially_resumed` row in `paused_executions`. It cannot report `queued`:
1010-
* a run that is still only in the job queue has no log row to list.
989+
* The list projection passes `workflow_execution_logs.status` through except where it
990+
* overlays `paused` for a run holding a `paused` or `partially_resumed` row in
991+
* `paused_executions` — so a reported `paused` is either that overlay or the persisted
992+
* value a failed resume attempt left behind. Both branches land in the persisted set, so the reported enum is
993+
* derived from it — a value missing here fails the response parse, and because list
994+
* validation is whole-page one such row turns an entire page into a 500. `queued` is not
995+
* reportable: a run still only in the job queue has no log row to list.
1011996
*/
1012-
const V2_WORKFLOW_RUN_LIST_STATUSES = [...V2_PERSISTED_RUN_STATUSES, 'paused'] as const
1013-
1014-
const RUN_STATUS_DESCRIPTION =
1015-
'Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed.'
1016-
1017997
export const v2WorkflowRunListStatusValueSchema = z
1018-
.enum(V2_WORKFLOW_RUN_LIST_STATUSES)
998+
.enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES)
1019999
.describe(RUN_STATUS_DESCRIPTION)
10201000

10211001
/**
10221002
* The single-run read additionally consults the async job queue by deterministic job id,
10231003
* so a run accepted but not yet started reports `queued` rather than 404.
10241004
*/
10251005
export const v2WorkflowRunStatusValueSchema = z
1026-
.enum([...V2_WORKFLOW_RUN_LIST_STATUSES, 'queued'])
1006+
.enum([...PERSISTED_WORKFLOW_EXECUTION_STATUSES, 'queued'])
10271007
.describe(RUN_STATUS_DESCRIPTION)
10281008

10291009
/**

apps/sim/lib/logs/types.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,31 @@ export interface WorkflowExecutionLog {
204204
createdAt: string
205205
}
206206

207+
/**
208+
* Every value written into `workflow_execution_logs.status`. The column is free text and
209+
* one writer sets it through a raw `sql` CASE Drizzle cannot type-check, so this list —
210+
* not the column type — is the only source of truth. API contracts that pass the column
211+
* through derive their enums from it, so adding a status here widens the public wire; the
212+
* contract tests fail until that widening is reviewed and the OpenAPI specs regenerated.
213+
*
214+
* `redacting` is transient while a finished run's output is scrubbed. `paused` is written
215+
* only by `PauseResumeManager.markResumeAttemptFailed`, when a resume attempt does not run
216+
* to completion — it failed admission, the run buffer was unavailable, the resume job could
217+
* not be enqueued, or the attempt was cancelled. An ordinary human-in-the-loop pause
218+
* persists `pending`.
219+
*/
220+
export const PERSISTED_WORKFLOW_EXECUTION_STATUSES = [
221+
'pending',
222+
'running',
223+
'paused',
224+
'redacting',
225+
'completed',
226+
'failed',
227+
'cancelled',
228+
] as const
229+
207230
export type PersistedWorkflowExecutionStatus =
208-
| 'running'
209-
| 'pending'
210-
| 'completed'
211-
| 'failed'
212-
| 'cancelled'
213-
| 'redacting'
231+
(typeof PERSISTED_WORKFLOW_EXECUTION_STATUSES)[number]
214232

215233
export interface CompletedWorkflowExecutionLog extends WorkflowExecutionLog {
216234
persistedStatus: PersistedWorkflowExecutionStatus

packages/db/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,8 @@ export const workflowExecutionLogs = pgTable(
411411
),
412412

413413
level: text('level').notNull(), // 'info' | 'error'
414-
status: text('status').notNull().default('running'), // 'running' | 'pending' | 'completed' | 'failed' | 'cancelled'
414+
/** See `PERSISTED_WORKFLOW_EXECUTION_STATUSES` in `apps/sim/lib/logs/types.ts`. */
415+
status: text('status').notNull().default('running'),
415416
trigger: text('trigger').notNull(), // 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
416417

417418
startedAt: timestamp('started_at').notNull(),

0 commit comments

Comments
 (0)