Skip to content

Commit 3387ee7

Browse files
feat(api): v2 executions status + cancel with queued backfill
GET /api/v2/workflows/[id]/executions/[executionId] is the single status URL for sync and async runs: before the async worker writes the durable log row, status is backfilled from the job queue (deterministic job id) as 'queued'/'running' — closing v1's 202-to-pickup 404 window — and failed runs carry the structured error object. POST .../cancel renders the shared cancellation lib in the v2 envelope with the tightened 5-value reason enum. Both authenticate via the shared resolveV2WorkflowAccess (X-API-Key, authz masked as 404, allowPersonalApiKeys honored). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz
1 parent 9c36400 commit 3387ee7

6 files changed

Lines changed: 465 additions & 3 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { v2CancelWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution'
8+
import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response'
9+
import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access'
10+
11+
const logger = createLogger('V2CancelExecutionAPI')
12+
13+
export const runtime = 'nodejs'
14+
export const dynamic = 'force-dynamic'
15+
16+
/** POST /api/v2/workflows/[id]/executions/[executionId]/cancel */
17+
export const POST = withRouteHandler(
18+
async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => {
19+
const parsed = await parseRequest(v2CancelWorkflowExecutionContract, req, context, {
20+
validationErrorResponse: v2ValidationError,
21+
})
22+
if (!parsed.success) return parsed.response
23+
const { id: workflowId, executionId } = parsed.data.params
24+
25+
const access = await resolveV2WorkflowAccess(req, workflowId, 'write')
26+
if (!access.ok) return access.response
27+
28+
try {
29+
logger.info('Cancel execution requested', { workflowId, executionId, userId: access.userId })
30+
31+
const result = await cancelWorkflowExecution({
32+
executionId,
33+
workflowId,
34+
userId: access.userId,
35+
workspaceId: access.workflow.workspaceId ?? undefined,
36+
})
37+
38+
return v2Data(result)
39+
} catch (error) {
40+
logger.error('Failed to cancel execution', {
41+
workflowId,
42+
executionId,
43+
error: getErrorMessage(error, 'Unknown error'),
44+
})
45+
return v2Error('INTERNAL_ERROR', 'Internal server error')
46+
}
47+
}
48+
)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, workflowAuthzMockFns } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockAuthenticateV1Request, mockGetJob, mockGetWorkflowExecutionStatus, mockCancel } =
8+
vi.hoisted(() => ({
9+
mockAuthenticateV1Request: vi.fn(),
10+
mockGetJob: vi.fn(),
11+
mockGetWorkflowExecutionStatus: vi.fn(),
12+
mockCancel: vi.fn(),
13+
}))
14+
15+
vi.mock('@/app/api/v1/auth', () => ({
16+
authenticateV1Request: mockAuthenticateV1Request,
17+
}))
18+
19+
vi.mock('@/lib/workspaces/utils', () => ({
20+
getWorkspaceBillingSettings: vi.fn().mockResolvedValue({ allowPersonalApiKeys: true }),
21+
}))
22+
23+
vi.mock('@/lib/workflows/executor/execution-status', () => ({
24+
getWorkflowExecutionStatus: mockGetWorkflowExecutionStatus,
25+
}))
26+
27+
vi.mock('@/lib/execution/cancel-workflow-execution', () => ({
28+
cancelWorkflowExecution: mockCancel,
29+
}))
30+
31+
vi.mock('@/lib/core/async-jobs', () => ({
32+
getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }),
33+
}))
34+
35+
vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({
36+
WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:',
37+
}))
38+
39+
import { POST as cancelPost } from './cancel/route'
40+
import { GET } from './route'
41+
42+
const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
43+
44+
const workflowRecord = {
45+
id: 'workflow-1',
46+
userId: 'owner-1',
47+
workspaceId: 'workspace-1',
48+
}
49+
50+
function callStatus(query = '') {
51+
const req = createMockRequest(
52+
'GET',
53+
undefined,
54+
{},
55+
`http://localhost:3000/api/v2/workflows/workflow-1/executions/exec-1${query}`
56+
)
57+
return GET(req, { params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }) })
58+
}
59+
60+
describe('v2 executions status + cancel', () => {
61+
beforeEach(() => {
62+
vi.clearAllMocks()
63+
mockAuthenticateV1Request.mockResolvedValue({
64+
authenticated: true,
65+
userId: 'key-user-1',
66+
keyType: 'workspace',
67+
workspaceId: 'workspace-1',
68+
})
69+
mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord })
70+
})
71+
72+
it('returns the execution resource with a structured error', async () => {
73+
mockGetWorkflowExecutionStatus.mockResolvedValue({
74+
executionId: 'exec-1',
75+
workflowId: 'workflow-1',
76+
status: 'failed',
77+
trigger: 'api',
78+
level: 'error',
79+
startedAt: '2026-07-31T00:00:00.000Z',
80+
endedAt: '2026-07-31T00:00:05.000Z',
81+
totalDurationMs: 5000,
82+
paused: null,
83+
cost: { total: 0.02 },
84+
error: 'Send Email: Invalid credentials',
85+
finalOutput: null,
86+
blockOutputs: null,
87+
})
88+
89+
const res = await callStatus()
90+
91+
expect(res.status).toBe(200)
92+
const body = await res.json()
93+
expect(body.data.status).toBe('failed')
94+
expect(body.data.error.code).toBe('EXECUTION_FAILED')
95+
expect(body.data.error.message).toBe('Send Email: Invalid credentials')
96+
expect(body.data.durationMs).toBe(5000)
97+
})
98+
99+
it('backfills queued status from the job queue before the log row exists', async () => {
100+
mockGetWorkflowExecutionStatus.mockResolvedValue(null)
101+
mockGetJob.mockResolvedValue({
102+
status: 'pending',
103+
metadata: { workflowId: 'workflow-1' },
104+
})
105+
106+
const res = await callStatus()
107+
108+
expect(res.status).toBe(200)
109+
expect((await res.json()).data.status).toBe('queued')
110+
expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:exec-1')
111+
})
112+
113+
it('404s when neither a log row nor a matching job exists', async () => {
114+
mockGetWorkflowExecutionStatus.mockResolvedValue(null)
115+
mockGetJob.mockResolvedValue(null)
116+
117+
const res = await callStatus()
118+
119+
expect(res.status).toBe(404)
120+
expect((await res.json()).error.code).toBe('NOT_FOUND')
121+
})
122+
123+
it('masks cross-workspace access as 404', async () => {
124+
mockAuthenticateV1Request.mockResolvedValue({
125+
authenticated: true,
126+
userId: 'key-user-1',
127+
keyType: 'workspace',
128+
workspaceId: 'other-workspace',
129+
})
130+
131+
const res = await callStatus()
132+
133+
expect(res.status).toBe(404)
134+
expect(mockGetWorkflowExecutionStatus).not.toHaveBeenCalled()
135+
})
136+
137+
it('cancels through the shared lib and returns the tightened result', async () => {
138+
mockCancel.mockResolvedValue({
139+
success: true,
140+
executionId: 'exec-1',
141+
redisAvailable: true,
142+
durablyRecorded: true,
143+
locallyAborted: false,
144+
pausedCancelled: false,
145+
reason: 'recorded',
146+
})
147+
148+
const req = createMockRequest('POST', undefined, {})
149+
const res = await cancelPost(req, {
150+
params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }),
151+
})
152+
153+
expect(res.status).toBe(200)
154+
const body = await res.json()
155+
expect(body.data).toMatchObject({ success: true, reason: 'recorded' })
156+
expect(mockCancel).toHaveBeenCalledWith({
157+
executionId: 'exec-1',
158+
workflowId: 'workflow-1',
159+
userId: 'key-user-1',
160+
workspaceId: 'workspace-1',
161+
})
162+
})
163+
164+
it('401s without an API key (no session/anonymous path on executions)', async () => {
165+
mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' })
166+
167+
const res = await callStatus()
168+
169+
expect(res.status).toBe(401)
170+
})
171+
})
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import {
5+
type V2WorkflowExecutionStatus,
6+
v2GetWorkflowExecutionContract,
7+
} from '@/lib/api/contracts/v2/workflows'
8+
import { parseRequest } from '@/lib/api/server'
9+
import { getJobQueue } from '@/lib/core/async-jobs'
10+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution'
12+
import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status'
13+
import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response'
14+
import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access'
15+
import { classifyExecutionError } from '@/executor/utils/errors'
16+
17+
const logger = createLogger('V2WorkflowExecutionStatusAPI')
18+
19+
export const dynamic = 'force-dynamic'
20+
21+
/**
22+
* Maps the async job's phase onto the execution status enum for the window
23+
* before the worker writes the durable log row.
24+
*/
25+
function jobStatusToExecutionStatus(jobStatus: string): V2WorkflowExecutionStatus['status'] | null {
26+
switch (jobStatus) {
27+
case 'pending':
28+
return 'queued'
29+
case 'processing':
30+
return 'running'
31+
case 'failed':
32+
return 'failed'
33+
case 'completed':
34+
return 'completed'
35+
default:
36+
return null
37+
}
38+
}
39+
40+
/**
41+
* GET /api/v2/workflows/[id]/executions/[executionId] — the single status URL
42+
* for both sync and async runs. When no log row exists yet, the async job
43+
* queue is consulted (deterministic job id) so a freshly-queued run reports
44+
* `queued` instead of 404.
45+
*/
46+
export const GET = withRouteHandler(
47+
async (
48+
request: NextRequest,
49+
context: { params: Promise<{ id: string; executionId: string }> }
50+
) => {
51+
const parsed = await parseRequest(v2GetWorkflowExecutionContract, request, context, {
52+
validationErrorResponse: v2ValidationError,
53+
})
54+
if (!parsed.success) return parsed.response
55+
const { id: workflowId, executionId } = parsed.data.params
56+
const { includeOutput, selectedOutputs } = parsed.data.query
57+
58+
const access = await resolveV2WorkflowAccess(request, workflowId, 'read')
59+
if (!access.ok) return access.response
60+
61+
try {
62+
const status = await getWorkflowExecutionStatus({
63+
workflowId,
64+
executionId,
65+
includeOutput,
66+
selectedOutputs,
67+
})
68+
69+
if (status) {
70+
return v2Data({
71+
executionId: status.executionId,
72+
workflowId: status.workflowId,
73+
status: status.status,
74+
trigger: status.trigger ?? null,
75+
startedAt: status.startedAt,
76+
endedAt: status.endedAt,
77+
durationMs: status.totalDurationMs,
78+
paused: status.paused,
79+
cost: status.cost,
80+
error: status.error ? classifyExecutionError(new Error(status.error)) : null,
81+
output: status.finalOutput,
82+
blockOutputs: status.blockOutputs,
83+
})
84+
}
85+
86+
// No log row yet — a queued/just-started async run. Backfilled from the
87+
// job queue via the deterministic id; authz already ran above.
88+
const jobQueue = await getJobQueue()
89+
const job = await jobQueue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`)
90+
const jobWorkflowId =
91+
job?.metadata && typeof job.metadata === 'object'
92+
? (job.metadata as { workflowId?: string }).workflowId
93+
: undefined
94+
const mapped = job ? jobStatusToExecutionStatus(job.status) : null
95+
if (!job || jobWorkflowId !== workflowId || !mapped) {
96+
return v2Error('NOT_FOUND', 'Execution not found')
97+
}
98+
99+
return v2Data({
100+
executionId,
101+
workflowId,
102+
status: mapped,
103+
trigger: 'api',
104+
startedAt: null,
105+
endedAt: null,
106+
durationMs: null,
107+
paused: null,
108+
cost: null,
109+
error:
110+
mapped === 'failed' && job.error ? classifyExecutionError(new Error(job.error)) : null,
111+
output: null,
112+
blockOutputs: null,
113+
})
114+
} catch (error) {
115+
logger.error('Failed to fetch execution status', {
116+
workflowId,
117+
executionId,
118+
error: getErrorMessage(error, 'Unknown error'),
119+
})
120+
return v2Error('INTERNAL_ERROR', 'Internal server error')
121+
}
122+
}
123+
)

0 commit comments

Comments
 (0)