diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 6ee095fb60b..d4df4c2b0df 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -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", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 3d650764b1a..722d9688c06 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -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": [ diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index 8b0c445a4d5..27d0226bda7 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -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()) diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 069bb3ed605..74b5755c412 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -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( diff --git a/apps/sim/lib/api/contracts/v2/log-status.test.ts b/apps/sim/lib/api/contracts/v2/log-status.test.ts new file mode 100644 index 00000000000..8d48aac3c95 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/log-status.test.ts @@ -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]) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 7e01f7c60be..18895a327cf 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -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 -type _ExhaustiveLogStatus = AssertNever< - Exclude -> - 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 diff --git a/apps/sim/lib/api/contracts/v2/workflow-run-status.test.ts b/apps/sim/lib/api/contracts/v2/workflow-run-status.test.ts index 55801f4f3e1..5b656655bcb 100644 --- a/apps/sim/lib/api/contracts/v2/workflow-run-status.test.ts +++ b/apps/sim/lib/api/contracts/v2/workflow-run-status.test.ts @@ -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) }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 2359af203b2..2eb1131dd71 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -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 -type _ExhaustiveRunStatus = AssertNever< - Exclude -> +const RUN_STATUS_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.' /** - * 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. + * 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. */ -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.' - 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) /** diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index ebcc5531d20..fd4c1b18b41 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -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 diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 7023252251c..a000b0e81eb 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -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(),