Skip to content

Commit d87daa7

Browse files
authored
fix(api): report a finished run's real status once its pauses have settled; show the request id in the CLI (#9924)
* fix(cli): show the server's request id on API errors * fix(api): report a finished run's status once none of its pauses can be resumed * fix(cli): keep the request id on every error built from a response * fix(cli): read an internal route's request id beside the error
1 parent b01becc commit d87daa7

17 files changed

Lines changed: 453 additions & 56 deletions

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15969,7 +15969,7 @@
1596915969
"failed",
1597015970
"cancelled"
1597115971
],
15972-
"description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting at a human or time pause, or a resume attempt did not complete. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason."
15972+
"description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run has a human or time pause that can still be resumed; once the run has finished and every pause point has resumed or failed, it reports the finished status. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason."
1597315973
},
1597415974
"trigger": {
1597515975
"type": "string",
@@ -16468,7 +16468,7 @@
1646816468
"cancelled",
1646916469
"queued"
1647016470
],
16471-
"description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting at a human or time pause, or a resume attempt did not complete. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason."
16471+
"description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run has a human or time pause that can still be resumed; once the run has finished and every pause point has resumed or failed, it reports the finished status. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason."
1647216472
},
1647316473
"trigger": {
1647416474
"anyOf": [

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1983,7 +1983,7 @@ export const v2ResumeWorkflowContract = defineRouteContract({
19831983
})
19841984

19851985
const RUN_STATUS_DESCRIPTION =
1986-
"Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting at a human or time pause, or a resume attempt did not complete. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason."
1986+
"Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run has a human or time pause that can still be resumed; once the run has finished and every pause point has resumed or failed, it reports the finished status. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason."
19871987

19881988
/**
19891989
* The list projection passes `workflow_execution_logs.status` through except where it

‎apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
* PostgreSQL: the pause's reviewer content is stored and read back with the run's resolved
44
* secret redacted, and the run detail withholds that content on the same terms as the run's
55
* output, while the pause's input fields stay readable. The workspace's paused-run list shows
6-
* each point on exactly the same terms.
6+
* each point on exactly the same terms. A run whose resumed execution failed reads failed, not
7+
* paused, once no point is left to resume.
78
*/
89
import { db } from '@sim/db'
910
import {
@@ -32,6 +33,7 @@ import { encryptSecret } from '@/lib/core/security/encryption'
3233
import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow'
3334
import { listPausedRuns } from '@/lib/workflows/application/list-paused-runs'
3435
import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run'
36+
import { listWorkflowExecutions } from '@/lib/workflows/executor/execution-queries'
3537
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
3638
import type { PausePoint } from '@/executor/types'
3739

@@ -475,3 +477,55 @@ describe("the workspace's paused-run list", () => {
475477
expect(second.runs[0].pausedAt.getTime()).toBeLessThanOrEqual(first.runs[0].pausedAt.getTime())
476478
})
477479
})
480+
481+
describe('a run whose resumed execution failed', () => {
482+
/** What a failed resume leaves behind: the log failed, its point failed, the record still open. */
483+
async function settleAsFailed(runId: string) {
484+
const [row] = await db
485+
.select({ id: pausedExecutions.id, pausePoints: pausedExecutions.pausePoints })
486+
.from(pausedExecutions)
487+
.where(eq(pausedExecutions.executionId, runId))
488+
const points = row.pausePoints as Record<string, PausePoint>
489+
await db
490+
.update(pausedExecutions)
491+
.set({
492+
pausePoints: Object.fromEntries(
493+
Object.entries(points).map(([id, point]) => [id, { ...point, resumeStatus: 'failed' }])
494+
),
495+
})
496+
.where(eq(pausedExecutions.id, row.id))
497+
await db
498+
.update(workflowExecutionLogs)
499+
.set({ status: 'failed', level: 'error', endedAt: new Date() })
500+
.where(eq(workflowExecutionLogs.executionId, runId))
501+
}
502+
503+
async function listedStatus(runId: string) {
504+
const rows = await listWorkflowExecutions({ workflowId, limit: 100, order: 'desc' })
505+
return rows.data.find((row) => row.executionId === runId)?.status
506+
}
507+
508+
it('reads failed in the run detail and the run list once no point can be resumed', async () => {
509+
const runId = await pausedRun()
510+
await settleAsFailed(runId)
511+
512+
const run = await readRun(owner, runId, { includeOutput: false })
513+
expect(run).toMatchObject({ status: 'failed', paused: null, pausePoints: null })
514+
expect(await listedStatus(runId)).toBe('failed')
515+
})
516+
517+
it('still reads paused while a point is waiting, even though the log has finished', async () => {
518+
const runId = await pausedRun()
519+
await db
520+
.update(workflowExecutionLogs)
521+
.set({ status: 'failed', level: 'error', endedAt: new Date() })
522+
.where(eq(workflowExecutionLogs.executionId, runId))
523+
524+
const run = await readRun(owner, runId, { includeOutput: false })
525+
expect(run.status).toBe('paused')
526+
expect(run.pausePoints).toEqual([
527+
expect.objectContaining({ contextId: 'approval', status: 'paused' }),
528+
])
529+
expect(await listedStatus(runId)).toBe('paused')
530+
})
531+
})

‎apps/sim/lib/workflows/executor/execution-queries.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { db } from '@sim/db'
22
import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema'
33
import { and, asc, desc, eq, gt, gte, lt, lte, or, sql } from 'drizzle-orm'
4+
import { PAUSE_POINT_ROWS } from '@/lib/workflows/executor/pause-point-sql'
45

56
export type WorkflowExecutionStatus =
67
| 'pending'
@@ -26,8 +27,20 @@ export interface ListWorkflowExecutionsInput {
2627
order: 'asc' | 'desc'
2728
}
2829

30+
/**
31+
* An open pause record reads `paused` unless the log has finished and no point is left to resume,
32+
* the same rule `readWorkflowExecutionStatus` applies to a single run.
33+
*/
2934
const executionStatus = sql<string>`CASE
30-
WHEN ${pausedExecutions.status} IN ('paused', 'partially_resumed') THEN 'paused'
35+
WHEN ${pausedExecutions.status} IN ('paused', 'partially_resumed')
36+
AND NOT (
37+
${workflowExecutionLogs.status} IN ('completed', 'failed', 'cancelled')
38+
AND NOT EXISTS (
39+
select 1 from ${PAUSE_POINT_ROWS}
40+
where coalesce(point.value ->> 'resumeStatus', 'paused') NOT IN ('resumed', 'failed')
41+
)
42+
)
43+
THEN 'paused'
3144
ELSE ${workflowExecutionLogs.status}
3245
END`
3346

‎apps/sim/lib/workflows/executor/execution-status.test.ts‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({
1616
import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs'
1717
import { isWaitingHumanPausePoint } from '@/lib/workflows/executor/execution-state'
1818
import {
19+
getProjectedWorkflowExecutionStatus,
1920
getWorkflowExecutionStatus,
2021
readPausedRunPoints,
2122
} from '@/lib/workflows/executor/execution-status'
@@ -665,6 +666,120 @@ describe('getWorkflowExecutionStatus polls that read no execution data', () => {
665666
})
666667
})
667668

669+
describe('getWorkflowExecutionStatus once a pause has settled', () => {
670+
const FAILED_TIME_POINT = {
671+
contextId: 'wait-five',
672+
blockId: 'wait-five',
673+
registeredAt: '2026-08-05T12:00:01.000Z',
674+
resumeStatus: 'failed',
675+
pauseKind: 'time',
676+
resumeAt: '2026-08-05T12:05:01.000Z',
677+
}
678+
679+
/** A run whose resumed execution failed: the log is terminal, the pause record still open. */
680+
function queueFailedRunWithOpenPause(pausePoints: Record<string, unknown>) {
681+
queueTableRows(schemaMock.workflowExecutionLogs, [
682+
{
683+
executionId: 'execution-1',
684+
workflowId: 'workflow-1',
685+
workspaceId: 'workspace-1',
686+
status: 'failed',
687+
level: 'error',
688+
trigger: 'api',
689+
startedAt: new Date('2026-08-05T12:00:00.000Z'),
690+
endedAt: new Date('2026-08-05T12:05:03.000Z'),
691+
totalDurationMs: 303000,
692+
executionData: {},
693+
costTotal: null,
694+
},
695+
])
696+
queueTableRows(schemaMock.resumeQueue, [])
697+
queueTableRows(schemaMock.pausedExecutions, [
698+
{
699+
id: 'paused-execution-1',
700+
status: 'paused',
701+
pausePoints,
702+
metadata: {},
703+
resumedCount: 0,
704+
pausedAt: new Date('2026-08-05T12:00:01.000Z'),
705+
nextResumeAt: null,
706+
},
707+
])
708+
}
709+
710+
beforeEach(() => {
711+
resetDbChainMock()
712+
mockGetJob.mockResolvedValue(null)
713+
mockMaterializeForDisplayWithBlockOutputs.mockResolvedValue({
714+
executionData: {
715+
error: 'Lookup: upstream returned 500',
716+
traceSpans: [
717+
{
718+
id: 'span-1',
719+
name: 'Lookup',
720+
type: 'api',
721+
status: 'error',
722+
blockId: 'lookup',
723+
executionOrder: 2,
724+
},
725+
],
726+
},
727+
blockOutputs: new Map(),
728+
})
729+
})
730+
731+
it('reports a run whose resumed execution failed as failed, with its error and failing block', async () => {
732+
queueFailedRunWithOpenPause({ 'wait-five': FAILED_TIME_POINT })
733+
734+
const projected = await getProjectedWorkflowExecutionStatus(input)
735+
736+
expect(projected?.status).toMatchObject({
737+
status: 'failed',
738+
paused: null,
739+
error: 'Lookup: upstream returned 500',
740+
})
741+
expect(projected?.pausePoints).toBeNull()
742+
expect(projected?.failedBlock).toMatchObject({ blockId: 'lookup', blockName: 'Lookup' })
743+
})
744+
745+
it.each([
746+
[
747+
'a human pause',
748+
{
749+
contextId: 'approval',
750+
blockId: 'approval',
751+
resumeStatus: 'paused',
752+
pauseKind: 'human',
753+
resumeAt: null,
754+
},
755+
],
756+
[
757+
'a time pause',
758+
{
759+
contextId: 'wait-ten',
760+
blockId: 'wait-ten',
761+
resumeStatus: 'paused',
762+
pauseKind: 'time',
763+
resumeAt: '2026-08-05T12:10:01.000Z',
764+
},
765+
],
766+
])('still reads paused while %s is waiting beside the failed one', async (_, waiting) => {
767+
queueFailedRunWithOpenPause({ 'wait-five': FAILED_TIME_POINT, [waiting.contextId]: waiting })
768+
769+
const status = await getWorkflowExecutionStatus(input)
770+
771+
expect(status).toMatchObject({
772+
status: 'paused',
773+
error: null,
774+
paused: {
775+
contextId: waiting.contextId,
776+
pauseKind: waiting.pauseKind,
777+
resumeAt: waiting.resumeAt,
778+
},
779+
})
780+
})
781+
})
782+
668783
describe('readPausedRunPoints cap', () => {
669784
beforeEach(() => {
670785
resetDbChainMock()

‎apps/sim/lib/workflows/executor/execution-status.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null {
8383
}, null)
8484
}
8585

86+
/** A point no resume can act on any more: it resumed, or its resumed execution failed. */
87+
function isSettledPausePoint(point: PausePoint): boolean {
88+
return point.resumeStatus === 'resumed' || point.resumeStatus === 'failed'
89+
}
90+
8691
function extractError(executionData: unknown): string | null {
8792
if (!executionData || typeof executionData !== 'object') return null
8893
const data = executionData as ExecutionDataShape
@@ -519,8 +524,15 @@ async function readWorkflowExecutionStatus(
519524
)
520525
.limit(1)
521526

527+
/**
528+
* An open pause record makes the run read `paused` unless the log has finished and no point
529+
* is left to resume: a resume that ran and failed marks its point `failed` and fails the log
530+
* but leaves the record open, and that point can never be resumed again.
531+
*/
522532
const isCurrentlyPaused =
523-
!!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed')
533+
!!pausedRow &&
534+
(pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed') &&
535+
!(hasTerminalLog && normalizePausePoints(pausedRow.pausePoints).every(isSettledPausePoint))
524536

525537
let status: WorkflowExecutionStatusResponse['status']
526538
if (isCurrentlyPaused) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { pausedExecutions } from '@sim/db/schema'
2+
import { sql } from 'drizzle-orm'
3+
4+
/**
5+
* Every pause point of a `paused_executions` row, as the derived table `point(value)`.
6+
*
7+
* `pause_points` is an object keyed by context id, or an array on legacy rows; both shapes are
8+
* read, as `normalizePausePoints` reads them, and each set function only ever receives the shape
9+
* it accepts.
10+
*/
11+
export const PAUSE_POINT_ROWS = sql`(
12+
select value from jsonb_each(
13+
case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'object'
14+
then ${pausedExecutions.pausePoints} else '{}'::jsonb end
15+
)
16+
union all
17+
select value from jsonb_array_elements(
18+
case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'array'
19+
then ${pausedExecutions.pausePoints} else '[]'::jsonb end
20+
)
21+
) as point(value)`

‎apps/sim/lib/workflows/executor/paused-runs.ts‎

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
resumeKeyset,
1515
textKey,
1616
} from '@/lib/api/list-query'
17+
import { PAUSE_POINT_ROWS } from '@/lib/workflows/executor/pause-point-sql'
1718

1819
/** A paused run of an active workflow, with the raw `pause_points` its detail is built from. */
1920
interface WorkspacePausedRunRow {
@@ -35,25 +36,8 @@ const PAUSED_RUN_KEYS: readonly KeysetKey<WorkspacePausedRunRow>[] = [
3536
const POINT_WAITS_ON_HUMAN = sql`coalesce(point.value ->> 'pauseKind', 'human') <> 'time'
3637
and coalesce(point.value ->> 'resumeStatus', 'paused') = 'paused'`
3738

38-
/**
39-
* A paused run with a pause point still waiting on a human. `pause_points` is an object keyed
40-
* by context id, or an array on legacy rows; both shapes are read, as `normalizePausePoints`
41-
* reads them, and each set function only ever receives the shape it accepts.
42-
*/
43-
const WAITS_ON_HUMAN = sql`exists (
44-
select 1 from (
45-
select value from jsonb_each(
46-
case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'object'
47-
then ${pausedExecutions.pausePoints} else '{}'::jsonb end
48-
)
49-
union all
50-
select value from jsonb_array_elements(
51-
case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'array'
52-
then ${pausedExecutions.pausePoints} else '[]'::jsonb end
53-
)
54-
) as point(value)
55-
where ${POINT_WAITS_ON_HUMAN}
56-
)`
39+
/** A paused run with a pause point still waiting on a human. */
40+
const WAITS_ON_HUMAN = sql`exists (select 1 from ${PAUSE_POINT_ROWS} where ${POINT_WAITS_ON_HUMAN})`
5741

5842
/** The cursor that resumes the list after `row`. */
5943
export function pausedRunCursorKeys(row: WorkspacePausedRunRow): CursorKey[] {

‎packages/sim-cli/src/commands/credentials.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,8 @@ function withKeylessRetryHint(error: unknown, providerId: string): unknown {
186186
error.status,
187187
error.code,
188188
error.details,
189-
error.exitCode
189+
error.exitCode,
190+
error.requestId
190191
)
191192
}
192193

‎packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ export async function waitWorkspaceOperation(
152152
requestId: report?.requestId,
153153
applied: report?.applied,
154154
},
155-
failure.exitCode
155+
failure.exitCode,
156+
failure.requestId
156157
)
157158
}
158159
if (report.status !== 'processing') return { report, timedOut: false }

0 commit comments

Comments
 (0)