Skip to content

Commit a4c1152

Browse files
feat(api): add v2 workflow resume endpoint
1 parent af162f8 commit a4c1152

10 files changed

Lines changed: 971 additions & 357 deletions

File tree

apps/docs/content/docs/en/workflows/blocks/human-in-the-loop.mdx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,15 @@ Access resume data in downstream blocks using `<blockId.fieldName>`.
8888
<Tab>
8989
### REST API
9090

91-
Programmatically resume workflows using the resume endpoint. The `contextId` is available from the block's `resumeEndpoint` output or from the `_resume` object in the paused execution response.
91+
Programmatically resume workflows through the v2 execution resource. The `contextId` is available from the block's `resumeEndpoint` output or from the `_resume` object in the paused execution response.
9292

9393
```bash
94-
POST /api/resume/{workflowId}/{executionId}/{contextId}
94+
POST /api/v2/workflows/{workflowId}/executions/{executionId}/resume
9595
Content-Type: application/json
9696
X-API-Key: your-api-key
9797

9898
{
99+
"contextId": "<contextId>",
99100
"input": {
100101
"approved": true,
101102
"comments": "Looks good to proceed"
@@ -109,11 +110,16 @@ Access resume data in downstream blocks using `<blockId.fieldName>`.
109110

110111
```json
111112
{
112-
"success": true,
113-
"status": "completed",
114-
"executionId": "<resumeExecutionId>",
115-
"output": { ... },
116-
"metadata": { "duration": 1234, "startTime": "...", "endTime": "..." }
113+
"data": {
114+
"executionId": "<resumeExecutionId>",
115+
"workflowId": "<workflowId>",
116+
"status": "completed",
117+
"output": { ... },
118+
"error": null,
119+
"startedAt": "...",
120+
"endedAt": "...",
121+
"durationMs": 1234
122+
}
117123
}
118124
```
119125

@@ -125,11 +131,10 @@ Access resume data in downstream blocks using `<blockId.fieldName>`.
125131

126132
```json
127133
{
128-
"success": true,
129-
"async": true,
130-
"executionId": "<resumeExecutionId>",
131-
"message": "Resume execution queued",
132-
"statusUrl": "/api/v2/workflows/<workflowId>/executions/<resumeExecutionId>"
134+
"data": {
135+
"executionId": "<resumeExecutionId>",
136+
"statusUrl": "/api/v2/workflows/<workflowId>/executions/<resumeExecutionId>"
137+
}
133138
}
134139
```
135140

@@ -143,6 +148,14 @@ Access resume data in downstream blocks using `<blockId.fieldName>`.
143148
```
144149

145150
Returns the execution status and, when completed, the full workflow output.
151+
152+
The legacy endpoint remains available without behavior changes for existing integrations:
153+
154+
```bash
155+
POST /api/resume/{workflowId}/{executionId}/{contextId}
156+
```
157+
158+
Its async response continues to expose `jobId` and the legacy `/api/jobs/{jobId}` polling URL.
146159

147160
To check on a paused execution's pause points and resume links:
148161

apps/docs/openapi-core.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -914,9 +914,10 @@
914914
"example": {
915915
"success": true,
916916
"async": true,
917+
"jobId": "job_4a3b2c1d0e",
917918
"executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58",
918919
"message": "Resume execution queued",
919-
"statusUrl": "https://www.sim.ai/api/v2/workflows/81f661e1-d704-4861-b5c1-5bb3cf57e6a7/executions/f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58"
920+
"statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e"
920921
}
921922
}
922923
}

apps/docs/openapi-v2-workflows.json

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,6 +1536,173 @@
15361536
}
15371537
}
15381538
},
1539+
"/api/v2/workflows/{id}/executions/{executionId}/resume": {
1540+
"post": {
1541+
"operationId": "resumeWorkflowExecutionV2",
1542+
"summary": "Resume a workflow execution",
1543+
"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.",
1544+
"tags": ["Workflows"],
1545+
"security": [
1546+
{
1547+
"apiKey": []
1548+
}
1549+
],
1550+
"parameters": [
1551+
{
1552+
"$ref": "#/components/parameters/WorkflowId"
1553+
},
1554+
{
1555+
"name": "executionId",
1556+
"in": "path",
1557+
"required": true,
1558+
"description": "The execution ID of the paused parent run.",
1559+
"schema": {
1560+
"type": "string",
1561+
"minLength": 1
1562+
}
1563+
}
1564+
],
1565+
"requestBody": {
1566+
"required": true,
1567+
"description": "The pause context to resume and its optional input. Bodies over 10 MB and unknown keys are rejected.",
1568+
"content": {
1569+
"application/json": {
1570+
"schema": {
1571+
"type": "object",
1572+
"additionalProperties": false,
1573+
"required": ["contextId"],
1574+
"properties": {
1575+
"contextId": {
1576+
"type": "string",
1577+
"minLength": 1,
1578+
"description": "The context ID of the human-in-the-loop pause point."
1579+
},
1580+
"input": {
1581+
"description": "Input supplied to the paused block."
1582+
}
1583+
}
1584+
},
1585+
"example": {
1586+
"contextId": "ctx_123",
1587+
"input": {
1588+
"approved": true,
1589+
"comments": "Looks good to proceed"
1590+
}
1591+
}
1592+
}
1593+
}
1594+
},
1595+
"responses": {
1596+
"200": {
1597+
"description": "The completed, failed, paused, or cancelled resume execution resource.",
1598+
"content": {
1599+
"application/json": {
1600+
"schema": {
1601+
"type": "object",
1602+
"required": ["data"],
1603+
"properties": {
1604+
"data": {
1605+
"$ref": "#/components/schemas/ExecutionResource"
1606+
}
1607+
}
1608+
},
1609+
"example": {
1610+
"data": {
1611+
"executionId": "resume_exec_1",
1612+
"workflowId": "wf_123",
1613+
"status": "completed",
1614+
"output": {
1615+
"result": "approved"
1616+
},
1617+
"error": null,
1618+
"durationMs": 420
1619+
}
1620+
}
1621+
}
1622+
}
1623+
},
1624+
"202": {
1625+
"description": "The resume is queued. Poll `statusUrl` using the returned resume execution ID.",
1626+
"content": {
1627+
"application/json": {
1628+
"schema": {
1629+
"type": "object",
1630+
"required": ["data"],
1631+
"properties": {
1632+
"data": {
1633+
"type": "object",
1634+
"required": ["executionId", "statusUrl"],
1635+
"properties": {
1636+
"executionId": {
1637+
"type": "string"
1638+
},
1639+
"statusUrl": {
1640+
"type": "string"
1641+
},
1642+
"queuePosition": {
1643+
"type": "integer",
1644+
"minimum": 1
1645+
}
1646+
}
1647+
}
1648+
}
1649+
},
1650+
"example": {
1651+
"data": {
1652+
"executionId": "resume_exec_1",
1653+
"statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/executions/resume_exec_1"
1654+
}
1655+
}
1656+
}
1657+
}
1658+
},
1659+
"400": {
1660+
"$ref": "#/components/responses/BadRequest"
1661+
},
1662+
"401": {
1663+
"$ref": "#/components/responses/Unauthorized"
1664+
},
1665+
"403": {
1666+
"$ref": "#/components/responses/Forbidden"
1667+
},
1668+
"404": {
1669+
"$ref": "#/components/responses/NotFound"
1670+
},
1671+
"409": {
1672+
"description": "The pause context cannot be resumed in its current state.",
1673+
"content": {
1674+
"application/json": {
1675+
"schema": {
1676+
"$ref": "#/components/schemas/Error"
1677+
}
1678+
}
1679+
}
1680+
},
1681+
"413": {
1682+
"$ref": "#/components/responses/PayloadTooLarge"
1683+
},
1684+
"423": {
1685+
"$ref": "#/components/responses/Locked"
1686+
},
1687+
"429": {
1688+
"$ref": "#/components/responses/RateLimited"
1689+
},
1690+
"500": {
1691+
"$ref": "#/components/responses/InternalError"
1692+
},
1693+
"503": {
1694+
"description": "Resume execution infrastructure temporarily unavailable.",
1695+
"content": {
1696+
"application/json": {
1697+
"schema": {
1698+
"$ref": "#/components/schemas/Error"
1699+
}
1700+
}
1701+
}
1702+
}
1703+
}
1704+
}
1705+
},
15391706
"/api/v2/workflows/{id}/executions/{executionId}/cancel": {
15401707
"post": {
15411708
"operationId": "cancelExecutionV2",

apps/docs/openapi.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -874,9 +874,10 @@
874874
"example": {
875875
"success": true,
876876
"async": true,
877+
"jobId": "job_4a3b2c1d0e",
877878
"executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58",
878879
"message": "Resume execution queued",
879-
"statusUrl": "https://www.sim.ai/api/v2/workflows/81f661e1-d704-4861-b5c1-5bb3cf57e6a7/executions/f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58"
880+
"statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e"
880881
}
881882
}
882883
}

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
5858
}))
5959

6060
import { GET, POST } from '@/app/api/resume/[workflowId]/[executionId]/[contextId]/route'
61+
import { handleResumeExecution } from '@/app/api/resume/resume-handler'
6162

6263
const WORKFLOW_ID = 'workflow-1'
6364
const EXECUTION_ID = 'execution-1'
@@ -240,7 +241,7 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => {
240241
})
241242
})
242243

243-
it('returns the resume execution ID as the only public async polling handle', async () => {
244+
it('preserves the legacy async job polling response', async () => {
244245
mockGetPausedExecutionDetail.mockResolvedValueOnce(
245246
createPausedExecution({ executionMode: 'async' })
246247
)
@@ -257,6 +258,52 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => {
257258

258259
const response = await POST(request, context)
259260

261+
expect(response.status).toBe(202)
262+
await expect(response.json()).resolves.toEqual({
263+
success: true,
264+
async: true,
265+
jobId: 'resume-execution:resume-execution-1',
266+
executionId: 'resume-execution-1',
267+
message: 'Resume execution queued',
268+
statusUrl: 'https://test.sim.ai/api/jobs/resume-execution:resume-execution-1',
269+
})
270+
expect(mockEnqueueResume).toHaveBeenCalledWith(
271+
'resume-execution',
272+
expect.objectContaining({ resumeExecutionId: 'resume-execution-1' }),
273+
expect.objectContaining({
274+
metadata: expect.objectContaining({ workflowId: WORKFLOW_ID }),
275+
})
276+
)
277+
expect(mockEnqueueResume.mock.calls[0]?.[2]).not.toHaveProperty('jobId')
278+
})
279+
280+
it('uses deterministic dispatch and execution polling for the v2 surface', async () => {
281+
mockGetPausedExecutionDetail.mockResolvedValueOnce(
282+
createPausedExecution({ executionMode: 'async' })
283+
)
284+
mockEnqueueOrStartResume.mockResolvedValueOnce({
285+
status: 'started',
286+
resumeExecutionId: 'resume-execution-1',
287+
resumeEntryId: 'resume-entry-1',
288+
pausedExecution: { id: 'paused-execution-1' },
289+
contextId: CONTEXT_ID,
290+
resumeInput: { approved: true },
291+
userId: 'current-api-key-user',
292+
})
293+
const { request } = makeRequest()
294+
295+
const response = await handleResumeExecution({
296+
request,
297+
workflowId: WORKFLOW_ID,
298+
executionId: EXECUTION_ID,
299+
contextId: CONTEXT_ID,
300+
workspaceId: WORKSPACE_ID,
301+
userId: 'current-api-key-user',
302+
resumeInput: { approved: true },
303+
isApiCaller: true,
304+
pollingSurface: 'v2',
305+
})
306+
260307
expect(response.status).toBe(202)
261308
await expect(response.json()).resolves.toEqual({
262309
success: true,

0 commit comments

Comments
 (0)