Skip to content

Commit 6967c60

Browse files
fix(api): close v2 resume and log gaps
1 parent 01c3176 commit 6967c60

16 files changed

Lines changed: 430 additions & 34 deletions

File tree

apps/docs/openapi-v2-logs.json

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"get": {
3737
"operationId": "listLogs",
3838
"summary": "List Logs",
39-
"description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only; pass `details=full` to include the per-execution `workflow` summary, and additionally `includeFinalOutput=true` / `includeTraceSpans=true` to materialize `finalOutput` / `traceSpans` on each entry.",
39+
"description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only. Pass `details=full` to include the per-execution `workflow` summary. Requesting `includeFinalOutput=true` or `includeTraceSpans=true` automatically enables full detail and materializes the requested field.",
4040
"tags": ["Logs"],
4141
"x-codeSamples": [
4242
{
@@ -158,7 +158,7 @@
158158
{
159159
"name": "details",
160160
"in": "query",
161-
"description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary and enables the includeFinalOutput / includeTraceSpans materialization flags.",
161+
"description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary. Requesting includeFinalOutput or includeTraceSpans also enables full detail.",
162162
"schema": {
163163
"type": "string",
164164
"enum": ["basic", "full"],
@@ -168,7 +168,7 @@
168168
{
169169
"name": "includeTraceSpans",
170170
"in": "query",
171-
"description": "When true, includes block-level execution trace spans on each entry. Only applies when details=full.",
171+
"description": "When true, includes block-level execution trace spans on each entry and automatically enables full detail.",
172172
"schema": {
173173
"type": "boolean",
174174
"default": false
@@ -177,7 +177,7 @@
177177
{
178178
"name": "includeFinalOutput",
179179
"in": "query",
180-
"description": "When true, includes the workflow's final output on each entry. Only applies when details=full.",
180+
"description": "When true, includes the workflow's final output on each entry and automatically enables full detail.",
181181
"schema": {
182182
"type": "boolean",
183183
"default": false
@@ -455,6 +455,56 @@
455455
}
456456
}
457457
},
458+
"TraceSpan": {
459+
"type": "object",
460+
"additionalProperties": true,
461+
"description": "A block, model, tool, or workflow trace span with timing, cost, and failure metadata.",
462+
"required": ["id", "name", "type"],
463+
"properties": {
464+
"id": { "type": "string" },
465+
"name": { "type": "string" },
466+
"type": { "type": "string" },
467+
"duration": { "type": "number" },
468+
"durationMs": { "type": "number" },
469+
"startTime": { "type": "string" },
470+
"endTime": { "type": "string" },
471+
"status": { "type": "string" },
472+
"errorHandled": {
473+
"type": "boolean",
474+
"description": "Whether an error handler path handled this span's failure."
475+
},
476+
"errorType": {
477+
"type": "string",
478+
"description": "Structured failure class such as RateLimitError."
479+
},
480+
"errorMessage": {
481+
"type": "string",
482+
"description": "Human-readable failure message."
483+
},
484+
"blockId": { "type": "string" },
485+
"input": {},
486+
"output": {},
487+
"tokens": {},
488+
"cost": {
489+
"type": "object",
490+
"properties": {
491+
"total": { "type": "number" },
492+
"input": { "type": "number" },
493+
"output": { "type": "number" },
494+
"toolCost": { "type": "number" }
495+
}
496+
},
497+
"relativeStartMs": { "type": "number" },
498+
"toolCalls": {
499+
"type": "array",
500+
"items": { "type": "object", "additionalProperties": true }
501+
},
502+
"children": {
503+
"type": "array",
504+
"items": { "$ref": "#/components/schemas/TraceSpan" }
505+
}
506+
}
507+
},
458508
"LogWorkflowSummary": {
459509
"type": "object",
460510
"description": "Workflow summary captured at execution time. Present on a list entry only when details=full.",
@@ -632,14 +682,13 @@
632682
"finalOutput": {
633683
"type": "object",
634684
"additionalProperties": true,
635-
"description": "The workflow's final output. The shape depends on the workflow. Present only when details=full and includeFinalOutput=true."
685+
"description": "The workflow's final output. The shape depends on the workflow. Present when includeFinalOutput=true; requesting it automatically enables full detail."
636686
},
637687
"traceSpans": {
638688
"type": "array",
639-
"description": "Block-level execution trace spans with timing, inputs, and outputs. Present only when details=full and includeTraceSpans=true.",
689+
"description": "Block-level execution trace spans with timing, cost, and failure metadata. Present when includeTraceSpans=true; requesting it automatically enables full detail.",
640690
"items": {
641-
"type": "object",
642-
"additionalProperties": true
691+
"$ref": "#/components/schemas/TraceSpan"
643692
}
644693
}
645694
}
@@ -730,10 +779,9 @@
730779
},
731780
"traceSpans": {
732781
"type": "array",
733-
"description": "Materialized block-level execution trace spans with timing, inputs, and outputs. Empty when the run has no spans.",
782+
"description": "Materialized block-level execution trace spans with timing, cost, and failure metadata. Empty when the run has no spans.",
734783
"items": {
735-
"type": "object",
736-
"additionalProperties": true
784+
"$ref": "#/components/schemas/TraceSpan"
737785
}
738786
},
739787
"finalOutput": {

apps/docs/openapi-v2-workflows.json

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,7 +1607,32 @@
16071607
},
16081608
"paused": {
16091609
"type": ["object", "null"],
1610-
"description": "Pause detail for human-in-the-loop runs."
1610+
"description": "Pause detail for human-in-the-loop runs, including the context ID required by the resume endpoint.",
1611+
"required": [
1612+
"contextId",
1613+
"pausedAt",
1614+
"resumeAt",
1615+
"pauseKind",
1616+
"blockedOnBlockId",
1617+
"automaticResumeWaitingReason",
1618+
"pausedExecutionId",
1619+
"pausePointCount",
1620+
"resumedCount"
1621+
],
1622+
"properties": {
1623+
"contextId": { "type": "string" },
1624+
"pausedAt": { "type": "string" },
1625+
"resumeAt": { "type": ["string", "null"] },
1626+
"pauseKind": {
1627+
"type": ["string", "null"],
1628+
"enum": ["time", "human", null]
1629+
},
1630+
"blockedOnBlockId": { "type": ["string", "null"] },
1631+
"automaticResumeWaitingReason": { "type": ["string", "null"] },
1632+
"pausedExecutionId": { "type": "string" },
1633+
"pausePointCount": { "type": "number" },
1634+
"resumedCount": { "type": "number" }
1635+
}
16111636
},
16121637
"cost": {
16131638
"type": ["object", "null"],
@@ -1683,7 +1708,7 @@
16831708
"post": {
16841709
"operationId": "resumeWorkflowExecutionV2",
16851710
"summary": "Resume a workflow execution",
1686-
"description": "Resumes one human-in-the-loop pause context on the parent execution. The resumed attempt receives a new execution ID. Sync attempts return the execution resource, stream attempts return Server-Sent Events, and async or serialized attempts return a 202 receipt whose `statusUrl` is the v2 execution resource.",
1711+
"description": "Resumes one human-in-the-loop pause context on the parent execution. Responses are always JSON and the resumed attempt receives a new execution ID. Sync attempts return the execution resource. Async, serialized, and inherited stream-mode attempts return a 202 receipt whose `statusUrl` is the v2 execution resource.",
16871712
"tags": ["Workflows"],
16881713
"security": [
16891714
{

apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,48 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => {
322322
)
323323
})
324324

325+
it('queues inherited stream-mode resumes when the caller requires JSON', async () => {
326+
mockGetPausedExecutionDetail.mockResolvedValueOnce(
327+
createPausedExecution({ executionMode: 'stream' })
328+
)
329+
mockEnqueueOrStartResume.mockResolvedValueOnce({
330+
status: 'started',
331+
resumeExecutionId: 'resume-execution-1',
332+
resumeEntryId: 'resume-entry-1',
333+
pausedExecution: { id: 'paused-execution-1' },
334+
contextId: CONTEXT_ID,
335+
resumeInput: { approved: true },
336+
userId: 'current-api-key-user',
337+
})
338+
const { request } = makeRequest()
339+
340+
const response = await handleResumeExecution({
341+
request,
342+
workflowId: WORKFLOW_ID,
343+
executionId: EXECUTION_ID,
344+
contextId: CONTEXT_ID,
345+
workspaceId: WORKSPACE_ID,
346+
userId: 'current-api-key-user',
347+
resumeInput: { approved: true },
348+
isApiCaller: true,
349+
pollingSurface: 'v2',
350+
allowStreaming: false,
351+
})
352+
353+
expect(response.status).toBe(202)
354+
expect(response.headers.get('Content-Type')).toContain('application/json')
355+
await expect(response.json()).resolves.toMatchObject({
356+
async: true,
357+
executionId: 'resume-execution-1',
358+
statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1',
359+
})
360+
expect(mockEnqueueResume).toHaveBeenCalledWith(
361+
'resume-execution',
362+
expect.objectContaining({ resumeExecutionId: 'resume-execution-1' }),
363+
expect.objectContaining({ jobId: 'resume-execution:resume-entry-1' })
364+
)
365+
})
366+
325367
it.each([
326368
{ statusCode: 402, message: 'Member usage limit reached', retryable: false },
327369
{ statusCode: 429, message: 'Target concurrency full', retryable: true },

apps/sim/app/api/resume/resume-handler.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ interface HandleResumeExecutionOptions {
5252
resumeInput: unknown
5353
isApiCaller: boolean
5454
pollingSurface: 'legacy' | 'v2'
55+
/** When false, inherited stream-mode resumes use async JSON polling instead of SSE. */
56+
allowStreaming?: boolean
5557
}
5658

5759
function loadPausedExecutionSnapshot(
@@ -114,6 +116,7 @@ export async function handleResumeExecution({
114116
resumeInput,
115117
isApiCaller,
116118
pollingSurface,
119+
allowStreaming = true,
117120
}: HandleResumeExecutionOptions): Promise<NextResponse> {
118121
const requestId = generateRequestId()
119122
const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({
@@ -224,8 +227,11 @@ export async function handleResumeExecution({
224227
userId: enqueueResult.userId,
225228
}
226229

230+
const persistedExecutionMode = persistedSnapshot.metadata.executionMode ?? 'sync'
227231
const executionMode = isApiCaller
228-
? (persistedSnapshot.metadata.executionMode ?? 'sync')
232+
? persistedExecutionMode === 'stream' && !allowStreaming
233+
? 'async'
234+
: persistedExecutionMode
229235
: undefined
230236
const includeThinking = persistedSnapshot.metadata.includeThinking === true
231237
const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockCheckRateLimit,
9+
mockResolveWorkspaceAccess,
10+
mockListPublicWorkflowLogs,
11+
mockMaterializeExecutionData,
12+
} = vi.hoisted(() => ({
13+
mockCheckRateLimit: vi.fn(),
14+
mockResolveWorkspaceAccess: vi.fn(),
15+
mockListPublicWorkflowLogs: vi.fn(),
16+
mockMaterializeExecutionData: vi.fn(),
17+
}))
18+
19+
vi.mock('@/app/api/v1/middleware', () => ({
20+
checkRateLimit: mockCheckRateLimit,
21+
resolveWorkspaceAccess: mockResolveWorkspaceAccess,
22+
}))
23+
24+
vi.mock('@/app/api/v2/lib/gate', () => ({
25+
v2ApiGateError: vi.fn().mockResolvedValue(null),
26+
}))
27+
28+
vi.mock('@/lib/logs/public-queries', () => ({
29+
decodePublicLogCursor: vi.fn(),
30+
listPublicWorkflowLogs: mockListPublicWorkflowLogs,
31+
}))
32+
33+
vi.mock('@/lib/logs/execution/trace-store', () => ({
34+
materializeExecutionData: mockMaterializeExecutionData,
35+
}))
36+
37+
import { GET } from '@/app/api/v2/logs/route'
38+
39+
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
40+
const RATE_LIMIT_OK = {
41+
allowed: true,
42+
userId: 'user-1',
43+
keyType: 'workspace',
44+
limit: 100,
45+
remaining: 99,
46+
resetAt: new Date('2026-08-06T01:00:00.000Z'),
47+
}
48+
const LOG_ROW = {
49+
executionId: 'execution-1',
50+
workflowId: 'workflow-1',
51+
workspaceId: WORKSPACE_ID,
52+
deploymentVersionId: null,
53+
status: 'completed',
54+
level: 'info',
55+
trigger: 'api',
56+
startedAt: new Date('2026-08-06T00:00:00.000Z'),
57+
endedAt: new Date('2026-08-06T00:00:01.000Z'),
58+
totalDurationMs: 1000,
59+
costTotal: null,
60+
files: null,
61+
executionData: { stored: true },
62+
workflowName: 'Support Agent',
63+
workflowDescription: null,
64+
workflowArchivedAt: null,
65+
}
66+
67+
function callLogs(query: string) {
68+
return GET(
69+
new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${query}`)
70+
)
71+
}
72+
73+
describe('GET /api/v2/logs materialized fields', () => {
74+
beforeEach(() => {
75+
vi.clearAllMocks()
76+
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
77+
mockResolveWorkspaceAccess.mockResolvedValue(null)
78+
mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null })
79+
})
80+
81+
it.each([false, 0, ''])('preserves a requested falsy final output: %j', async (finalOutput) => {
82+
mockMaterializeExecutionData.mockResolvedValue({ finalOutput })
83+
84+
const response = await callLogs('includeFinalOutput=true')
85+
const body = await response.json()
86+
87+
expect(response.status).toBe(200)
88+
expect(body.data[0].finalOutput).toBe(finalOutput)
89+
expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' })
90+
expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith(
91+
expect.objectContaining({ includeExecutionData: true })
92+
)
93+
})
94+
95+
it('makes includeTraceSpans imply full detail', async () => {
96+
const traceSpans = [{ id: 'span-1', name: 'Agent', type: 'agent' }]
97+
mockMaterializeExecutionData.mockResolvedValue({ traceSpans })
98+
99+
const response = await callLogs('includeTraceSpans=true')
100+
const body = await response.json()
101+
102+
expect(response.status).toBe(200)
103+
expect(body.data[0].traceSpans).toEqual(traceSpans)
104+
expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' })
105+
expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith(
106+
expect.objectContaining({ includeExecutionData: true })
107+
)
108+
})
109+
})

0 commit comments

Comments
 (0)