diff --git a/apps/docs/content/docs/en/api-reference/getting-started.mdx b/apps/docs/content/docs/en/api-reference/getting-started.mdx index c8093e72c14..2b744d8586d 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -49,28 +49,28 @@ A workflow must be deployed before it can be executed via the API. Click the **D ```bash - curl -X POST https://www.sim.ai/api/workflows/{workflowId}/execute \ + curl -X POST https://www.sim.ai/api/v2/workflows/{workflowId}/execute \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_API_KEY" \ - -d '{"inputs": {}}' + -d '{"input": {}}' ``` ```typescript const response = await fetch( - `https://www.sim.ai/api/workflows/${workflowId}/execute`, + `https://www.sim.ai/api/v2/workflows/${workflowId}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.SIM_API_KEY!, }, - body: JSON.stringify({ inputs: {} }), + body: JSON.stringify({ input: {} }), } ) const data = await response.json() - console.log(data.output) + console.log(data.data.output) ``` @@ -79,16 +79,16 @@ A workflow must be deployed before it can be executed via the API. Click the **D import os response = requests.post( - f"https://www.sim.ai/api/workflows/{workflow_id}/execute", + f"https://www.sim.ai/api/v2/workflows/{workflow_id}/execute", headers={ "Content-Type": "application/json", "X-API-Key": os.environ["SIM_API_KEY"], }, - json={"inputs": {}}, + json={"input": {}}, ) data = response.json() - print(data["output"]) + print(data["data"]["output"]) ``` @@ -103,77 +103,61 @@ By default, workflow executions are **synchronous** — the API blocks until the For long-running workflows, use **asynchronous execution** by passing `async: true`: ```bash -curl -X POST https://www.sim.ai/api/workflows/{workflowId}/execute \ +curl -X POST https://www.sim.ai/api/v2/workflows/{workflowId}/execute \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_API_KEY" \ - -d '{"inputs": {}, "async": true}' + -d '{"input": {}, "async": true}' ``` -This returns immediately with a `jobId` and `statusUrl`: +This returns immediately with an `executionId` and `statusUrl`: ```json { - "success": true, - "jobId": "job_abc123", - "statusUrl": "https://www.sim.ai/api/jobs/job_abc123", - "message": "Workflow execution started", - "async": true + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "statusUrl": "https://www.sim.ai/api/v2/workflows/{workflowId}/executions/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } } ``` -Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Execution Status](/api-reference/execution/getWorkflowExecution) endpoint until the status is terminal: ```bash -curl https://www.sim.ai/api/jobs/{jobId} \ +curl https://www.sim.ai/api/v2/workflows/{workflowId}/executions/{executionId}?includeOutput=true \ -H "X-API-Key: YOUR_API_KEY" ``` - Job status transitions follow: `queued` → `processing` → `completed` or `failed`. The `output` field is only present when status is `completed`. + Execution status transitions follow: `queued` → `running` → `completed`, `failed`, `cancelled`, or `paused`. The `data.output` field is populated for completed executions when `includeOutput=true`. ## Response Format -Successful responses include an `output` object with your workflow results and a `limits` object with your current rate limit and usage status: +Successful v2 responses wrap the execution resource in `data`: ```json { - "success": true, - "output": { - "result": "Hello, world!" - }, - "limits": { - "workflowExecutionRateLimit": { - "sync": { - "requestsPerMinute": 60, - "maxBurst": 10, - "remaining": 59, - "resetAt": "2025-01-01T00:01:00Z" - }, - "async": { - "requestsPerMinute": 30, - "maxBurst": 5, - "remaining": 30, - "resetAt": "2025-01-01T00:01:00Z" - } - }, - "usage": { - "currentPeriodCost": 1.25, - "limit": 50.00, - "plan": "pro", - "isExceeded": false - } + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "{workflowId}", + "status": "completed", + "output": { "result": "Hello, world!" }, + "error": null, + "durationMs": 842 } } ``` ## Error Handling -The API uses standard HTTP status codes. Error responses include a human-readable `error` message: +The API uses standard HTTP status codes. v2 errors include a stable code and human-readable message: ```json { - "error": "Workflow not found" + "error": { + "code": "NOT_FOUND", + "message": "Workflow not found" + } } ``` @@ -191,7 +175,7 @@ The API uses standard HTTP status codes. Error responses include a human-readabl ## Rate Limits -Rate limits depend on your subscription plan and apply separately to synchronous and asynchronous executions. Every execution response includes a `limits` object showing your current rate limit status. +Rate limits depend on your subscription plan and apply separately to synchronous and asynchronous executions. When rate limited, the API returns a `429` response with a `Retry-After` header indicating how many seconds to wait before retrying. diff --git a/apps/docs/content/docs/en/api-reference/python.mdx b/apps/docs/content/docs/en/api-reference/python.mdx index d70bb50e3aa..b00d4e88eeb 100644 --- a/apps/docs/content/docs/en/api-reference/python.mdx +++ b/apps/docs/content/docs/en/api-reference/python.mdx @@ -80,7 +80,7 @@ result = client.execute_workflow( **Returns:** `WorkflowExecutionResult | AsyncExecutionResult` -When `async_execution=True`, returns immediately with a `job_id` and `status_url` for polling. Otherwise, waits for completion. +When `async_execution=True`, returns immediately with an `execution_id` and `status_url` for polling. Otherwise, waits for completion. ##### get_workflow_status() @@ -112,30 +112,42 @@ if is_ready: **Returns:** `bool` -##### get_job_status() +##### get_workflow_execution() -Get the status of an async job execution. +Get the status and optional outputs of a workflow execution. ```python -status = client.get_job_status("job-id-from-async-execution") -print("Status:", status["status"]) # 'queued', 'processing', 'completed', 'failed' +status = client.get_workflow_execution("workflow-id", "execution-id", include_output=True) +print("Status:", status["status"]) # 'queued', 'running', 'completed', 'failed' if status["status"] == "completed": print("Output:", status["output"]) ``` **Parameters:** -- `task_id` (str): The job ID returned from async execution +- `workflow_id` (str): The workflow ID +- `execution_id` (str): The execution ID returned from async execution +- `include_output` (bool, optional): Include the final output for completed executions +- `selected_outputs` (list[str], optional): Block output selectors to include **Returns:** `Dict[str, Any]` **Response fields:** -- `success` (bool): Whether the request was successful -- `taskId` (str): The job ID -- `status` (str): One of `'queued'`, `'processing'`, `'completed'`, `'failed'`, `'cancelled'` -- `metadata` (dict): Contains `startedAt`, `completedAt`, and `duration` -- `output` (any, optional): The workflow output (when completed) -- `error` (any, optional): Error details (when failed) -- `estimatedDuration` (int, optional): Estimated duration in milliseconds (when processing/queued) +- `executionId` (str): The execution ID +- `workflowId` (str): The workflow ID +- `status` (str): One of `'queued'`, `'pending'`, `'running'`, `'paused'`, `'completed'`, `'failed'`, `'cancelled'` +- `startedAt` / `endedAt` (str): Execution timestamps +- `durationMs` (int, optional): Duration in milliseconds +- `output` (any, optional): The workflow output when requested for a completed execution +- `blockOutputs` (dict, optional): Requested block outputs +- `error` (dict, optional): Structured failure details with `code`, `message`, and optional `details` + +##### get_job_status() + +Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_execution()` with the execution ID instead. + +```python +status = client.get_job_status("legacy-job-id") +``` ##### execute_with_retry() @@ -270,9 +282,8 @@ class WorkflowExecutionResult: @dataclass class AsyncExecutionResult: success: bool - job_id: str + execution_id: str status_url: str - execution_id: Optional[str] = None message: str = "" async_execution: bool = True ``` @@ -494,22 +505,26 @@ def execute_async(): ) # Check if result is an async execution - if hasattr(result, 'job_id'): - print(f"Job ID: {result.job_id}") + if hasattr(result, 'async_execution') and result.async_execution: + print(f"Execution ID: {result.execution_id}") print(f"Status endpoint: {result.status_url}") # Poll for completion - status = client.get_job_status(result.job_id) + status = client.get_workflow_execution( + "workflow-id", result.execution_id, include_output=True + ) - while status["status"] in ["queued", "processing"]: + while status["status"] in ["queued", "pending", "running"]: print(f"Current status: {status['status']}") time.sleep(2) # Wait 2 seconds - status = client.get_job_status(result.job_id) + status = client.get_workflow_execution( + "workflow-id", result.execution_id, include_output=True + ) if status["status"] == "completed": print("Workflow completed!") print(f"Output: {status['output']}") - print(f"Duration: {status['metadata']['duration']}") + print(f"Duration: {status['durationMs']}") else: print(f"Workflow failed: {status['error']}") @@ -656,13 +671,13 @@ def stream_workflow(): def generate(): response = requests.post( - 'https://sim.ai/api/workflows/WORKFLOW_ID/execute', + 'https://sim.ai/api/v2/workflows/WORKFLOW_ID/execute', headers={ 'Content-Type': 'application/json', 'X-API-Key': os.getenv('SIM_API_KEY') }, json={ - 'message': 'Generate a story', + 'input': {'message': 'Generate a story'}, 'stream': True, 'selectedOutputs': ['agent1.content'] }, @@ -765,9 +780,9 @@ import { FAQ } from '@/components/ui/faq' \ No newline at end of file +]} /> diff --git a/apps/docs/content/docs/en/api-reference/typescript.mdx b/apps/docs/content/docs/en/api-reference/typescript.mdx index 791849f94a5..9f18bbb0d3c 100644 --- a/apps/docs/content/docs/en/api-reference/typescript.mdx +++ b/apps/docs/content/docs/en/api-reference/typescript.mdx @@ -94,7 +94,7 @@ const result = await client.executeWorkflow('workflow-id', { message: 'Hello, wo **Returns:** `Promise` -When `async: true`, returns immediately with a `jobId` and `statusUrl` for polling. Otherwise, waits for completion. +When `async: true`, returns immediately with an `executionId` and `statusUrl` for polling. Otherwise, waits for completion. ##### getWorkflowStatus() @@ -126,31 +126,45 @@ if (isReady) { **Returns:** `Promise` -##### getJobStatus() +##### getWorkflowExecution() -Get the status of an async job execution. +Get the status and optional outputs of a workflow execution. ```typescript -const status = await client.getJobStatus('job-id-from-async-execution'); -console.log('Status:', status.status); // 'queued', 'processing', 'completed', 'failed' +const status = await client.getWorkflowExecution('workflow-id', 'execution-id', { + includeOutput: true +}); +console.log('Status:', status.status); // 'queued', 'running', 'completed', 'failed' if (status.status === 'completed') { console.log('Output:', status.output); } ``` **Parameters:** -- `jobId` (string): The job ID returned from async execution +- `workflowId` (string): The workflow ID +- `executionId` (string): The execution ID returned from async execution +- `options.includeOutput` (boolean, optional): Include the final output for completed executions +- `options.selectedOutputs` (string[], optional): Block output selectors to include -**Returns:** `Promise` +**Returns:** `Promise` **Response fields:** -- `success` (boolean): Whether the request was successful -- `taskId` (string): The job ID -- `status` (string): One of `'queued'`, `'processing'`, `'completed'`, `'failed'`, `'cancelled'` -- `metadata` (object): Contains `startedAt`, `completedAt`, and `duration` -- `output` (any, optional): The workflow output (when completed) -- `error` (any, optional): Error details (when failed) -- `estimatedDuration` (number, optional): Estimated duration in milliseconds (when processing/queued) +- `executionId` (string): The execution ID +- `workflowId` (string): The workflow ID +- `status` (string): One of `'queued'`, `'pending'`, `'running'`, `'paused'`, `'completed'`, `'failed'`, `'cancelled'` +- `startedAt` / `endedAt` (string): Execution timestamps +- `durationMs` (number, nullable): Duration in milliseconds +- `output` (any, nullable): The workflow output when requested for a completed execution +- `blockOutputs` (object, nullable): Requested block outputs +- `error` (object, nullable): Structured failure details with `code`, `message`, and optional `details` + +##### getJobStatus() + +Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowExecution()` with the execution ID instead. + +```typescript +const status = await client.getJobStatus('legacy-job-id'); +``` ##### executeWithRetry() @@ -278,9 +292,8 @@ interface WorkflowExecutionResult { ```typescript interface AsyncExecutionResult { success: boolean; - jobId: string; + executionId: string; statusUrl: string; - executionId?: string; message: string; async: true; } @@ -766,23 +779,27 @@ async function executeAsync() { }); // Check if result is an async execution - if ('jobId' in result) { - console.log('Job ID:', result.jobId); + if ('async' in result && result.async) { + console.log('Execution ID:', result.executionId); console.log('Status endpoint:', result.statusUrl); // Poll for completion - let status = await client.getJobStatus(result.jobId); + let status = await client.getWorkflowExecution('workflow-id', result.executionId, { + includeOutput: true + }); - while (status.status === 'queued' || status.status === 'processing') { + while (status.status === 'queued' || status.status === 'pending' || status.status === 'running') { console.log('Current status:', status.status); await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds - status = await client.getJobStatus(result.jobId); + status = await client.getWorkflowExecution('workflow-id', result.executionId, { + includeOutput: true + }); } if (status.status === 'completed') { console.log('Workflow completed!'); console.log('Output:', status.output); - console.log('Duration:', status.metadata.duration); + console.log('Duration:', status.durationMs); } else { console.error('Workflow failed:', status.error); } @@ -931,14 +948,14 @@ function StreamingWorkflow() { // IMPORTANT: Make this API call from your backend server, not the browser // Never expose your API key in client-side code - const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', { + const response = await fetch('https://sim.ai/api/v2/workflows/WORKFLOW_ID/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.SIM_API_KEY! // Server-side environment variable only }, body: JSON.stringify({ - message: 'Generate a story', + input: { message: 'Generate a story' }, stream: true, selectedOutputs: ['agent1.content'] }) @@ -1021,7 +1038,7 @@ import { FAQ } from '@/components/ui/faq' `. ### REST API - 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. + 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. ```bash - POST /api/resume/{workflowId}/{executionId}/{contextId} + POST /api/v2/workflows/{workflowId}/executions/{executionId}/resume Content-Type: application/json X-API-Key: your-api-key { + "contextId": "", "input": { "approved": true, "comments": "Looks good to proceed" @@ -109,11 +110,16 @@ Access resume data in downstream blocks using ``. ```json { - "success": true, - "status": "completed", - "executionId": "", - "output": { ... }, - "metadata": { "duration": 1234, "startTime": "...", "endTime": "..." } + "data": { + "executionId": "", + "workflowId": "", + "status": "completed", + "output": { ... }, + "error": null, + "startedAt": "...", + "endedAt": "...", + "durationMs": 1234 + } } ``` @@ -121,16 +127,14 @@ Access resume data in downstream blocks using ``. - **Stream mode** (`stream: true` on the original execute call) — The resume response streams SSE events with `selectedOutputs` chunks, just like the initial execution. - - **Async mode** (`X-Execution-Mode: async` on the original execute call) — The resume dispatches execution to a background worker and returns immediately with `202`, including a `jobId` and `statusUrl` for polling: + - **Async mode** (`async: true` on the original v2 execute call) — The resume dispatches execution to a background worker and returns immediately with `202`, including the resume attempt's `executionId` and v2 `statusUrl` for polling: ```json { - "success": true, - "async": true, - "jobId": "", - "executionId": "", - "message": "Resume execution queued", - "statusUrl": "/api/jobs/" + "data": { + "executionId": "", + "statusUrl": "/api/v2/workflows//executions/" + } } ``` @@ -139,11 +143,19 @@ Access resume data in downstream blocks using ``. Poll the `statusUrl` from the async response to check when the resume completes: ```bash - GET /api/jobs/{jobId} + GET /api/v2/workflows/{workflowId}/executions/{resumeExecutionId}?includeOutput=true X-API-Key: your-api-key ``` - Returns job status and, when completed, the full workflow output. + Returns the execution status and, when completed, the full workflow output. + + The legacy endpoint remains available without behavior changes for existing integrations: + + ```bash + POST /api/resume/{workflowId}/{executionId}/{contextId} + ``` + + Its async response continues to expose `jobId` and the legacy `/api/jobs/{jobId}` polling URL. To check on a paused execution's pause points and resume links: @@ -163,7 +175,7 @@ Access resume data in downstream blocks using ``. ## API Execute Behavior -When triggering a workflow via the execute API (`POST /api/workflows/{id}/execute`), HITL blocks cause the execution to pause and return the `_resume` data in the response: +When triggering a workflow through `POST /api/v2/workflows/{id}/execute`, HITL blocks cause the execution to pause and return the `_resume` data in the v2 response envelope. The legacy `POST /api/workflows/{id}/execute` endpoint remains available for existing integrations. @@ -171,19 +183,23 @@ When triggering a workflow via the execute API (`POST /api/workflows/{id}/execut ```json { - "success": true, - "executionId": "", - "output": { - "data": { - "operation": "human", - "_resume": { - "apiUrl": "/api/resume/{workflowId}/{executionId}/{contextId}", - "uiUrl": "/resume/{workflowId}/{executionId}", - "contextId": "", - "executionId": "", - "workflowId": "" + "data": { + "executionId": "", + "workflowId": "", + "status": "paused", + "output": { + "data": { + "operation": "human", + "_resume": { + "apiUrl": "/api/resume/{workflowId}/{executionId}/{contextId}", + "uiUrl": "/resume/{workflowId}/{executionId}", + "contextId": "", + "executionId": "", + "workflowId": "" + } } - } + }, + "error": null } } ``` diff --git a/apps/docs/content/docs/en/workflows/deployment/api.mdx b/apps/docs/content/docs/en/workflows/deployment/api.mdx index 1e3821d5776..ad35c338f9d 100644 --- a/apps/docs/content/docs/en/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/api.mdx @@ -26,7 +26,7 @@ Click **Deploy** to publish your workflow for the first time, or **Update** to p Once deployed, your workflow is available at: ``` -POST https://sim.ai/api/workflows/{workflow-id}/execute +POST https://sim.ai/api/v2/workflows/{workflow-id}/execute ``` @@ -96,10 +96,10 @@ At the bottom of the tab, two buttons give you quick access to key settings: By default, API endpoints require an API key passed in the `x-api-key` header. Generate keys in **Settings → Sim Keys** or via the **Generate API Key** button in the API tab. ```bash -curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ - -d '{ "input": "Hello" }' + -d '{ "input": { "message": "Hello" } }' ``` ### API Info and Public Access @@ -128,10 +128,10 @@ The default mode. Send a request and wait for the complete response: ```bash -curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ - -d '{ "input": "Summarize this article" }' + -d '{ "input": { "message": "Summarize this article" } }' ``` @@ -139,25 +139,25 @@ curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ import requests, os response = requests.post( - "https://sim.ai/api/workflows/{workflow-id}/execute", + "https://sim.ai/api/v2/workflows/{workflow-id}/execute", headers={ "Content-Type": "application/json", "x-api-key": os.environ["SIM_API_KEY"] }, - json={"input": "Summarize this article"} + json={"input": {"message": "Summarize this article"}} ) print(response.json()) ``` ```typescript -const response = await fetch('https://sim.ai/api/workflows/{workflow-id}/execute', { +const response = await fetch('https://sim.ai/api/v2/workflows/{workflow-id}/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.SIM_API_KEY! }, - body: JSON.stringify({ input: 'Summarize this article' }) + body: JSON.stringify({ input: { message: 'Summarize this article' } }) }); console.log(await response.json()); ``` @@ -179,11 +179,11 @@ The `selectedOutputs` values in the request body follow the format `blockName.fi ```bash -curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ -d '{ - "input": "Write a long essay", + "input": { "prompt": "Write a long essay" }, "stream": true, "selectedOutputs": ["agent_1.content"] }' @@ -194,13 +194,13 @@ curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ import requests, os response = requests.post( - "https://sim.ai/api/workflows/{workflow-id}/execute", + "https://sim.ai/api/v2/workflows/{workflow-id}/execute", headers={ "Content-Type": "application/json", "x-api-key": os.environ["SIM_API_KEY"] }, json={ - "input": "Write a long essay", + "input": {"prompt": "Write a long essay"}, "stream": True, "selectedOutputs": ["agent_1.content"] }, @@ -213,14 +213,14 @@ for line in response.iter_lines(): ```typescript -const response = await fetch('https://sim.ai/api/workflows/{workflow-id}/execute', { +const response = await fetch('https://sim.ai/api/v2/workflows/{workflow-id}/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.SIM_API_KEY! }, body: JSON.stringify({ - input: 'Write a long essay', + input: { prompt: 'Write a long essay' }, stream: true, selectedOutputs: ['agent_1.content'] }) @@ -242,12 +242,12 @@ while (true) { By default a streaming run carries answer text only. To also receive the Agent block's reasoning and its tool-call lifecycle, set `includeThinking` / `includeToolCalls`: ```bash -curl -N -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -N -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ -H "X-Sim-Stream-Protocol: agent-events-v1" \ -d '{ - "input": "Research this topic", + "input": { "prompt": "Research this topic" }, "stream": true, "selectedOutputs": ["agent_1.content"], "includeThinking": true, @@ -280,76 +280,76 @@ The `version` field is part of the external API contract. Treat the reference as ### Asynchronous -For long-running workflows, async mode returns a job ID immediately so you don't need to hold the connection open. Add the `X-Execution-Mode: async` header to your request. The API returns HTTP 202 with a job ID and status URL. Poll the status URL until the job completes. +For long-running workflows, async mode returns an execution ID immediately so you don't need to hold the connection open. Set `"async": true` in the v2 request body. The API returns HTTP 202 with an execution ID and v2 status URL. Poll that execution resource until the run completes. - - + + ```bash -curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ - -H "X-Execution-Mode: async" \ - -d '{ "input": "Process this large dataset" }' + -d '{ "input": { "task": "Process this large dataset" }, "async": true }' ``` **Response** (HTTP 202): ```json { - "success": true, - "async": true, - "jobId": "run_abc123", - "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", - "message": "Workflow execution queued", - "statusUrl": "https://sim.ai/api/jobs/run_abc123" + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "statusUrl": "https://sim.ai/api/v2/workflows/{workflow-id}/executions/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } } ``` ```bash -curl https://sim.ai/api/jobs/{jobId} \ +curl "https://sim.ai/api/v2/workflows/{workflow-id}/executions/{executionId}?includeOutput=true" \ -H "x-api-key: $SIM_API_KEY" ``` **While processing:** ```json { - "success": true, - "taskId": "run_abc123", - "status": "processing", - "metadata": { - "createdAt": "2025-09-10T12:00:00.000Z", - "startedAt": "2025-09-10T12:00:01.000Z" - }, - "estimatedDuration": 300000 + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "{workflow-id}", + "status": "running", + "startedAt": "2025-09-10T12:00:01.000Z", + "endedAt": null, + "durationMs": null, + "output": null + } } ``` **When completed:** ```json { - "success": true, - "taskId": "run_abc123", - "status": "completed", - "metadata": { - "createdAt": "2025-09-10T12:00:00.000Z", + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "{workflow-id}", + "status": "completed", "startedAt": "2025-09-10T12:00:01.000Z", - "completedAt": "2025-09-10T12:00:05.000Z", - "duration": 4000 - }, - "output": { "result": "..." } + "endedAt": "2025-09-10T12:00:05.000Z", + "durationMs": 4000, + "output": { "result": "..." } + } } ``` -#### Job Status Values +#### Execution Status Values | Status | Description | |--------|-------------| -| `queued` | Job is waiting to be picked up | -| `processing` | Workflow is actively executing | -| `completed` | Finished successfully — `output` field contains the result | +| `queued` | Execution is waiting to be picked up | +| `pending` | The durable execution record exists but has not started | +| `running` | Workflow is actively executing | +| `paused` | Workflow is waiting for a resume condition or input | +| `completed` | Finished successfully — `output` is populated when requested | | `failed` | Execution failed — `error` field contains the message | +| `cancelled` | Execution was cancelled | Poll the `statusUrl` from the initial response until the status is `completed` or `failed`. @@ -360,11 +360,11 @@ Poll the `statusUrl` from the initial response until the status is `completed` o | **Community** | 5 minutes | 90 minutes | | **Pro / Max / Team / Enterprise** | 50 minutes | 90 minutes | -If a job exceeds its time limit it is automatically marked as `failed`. +If an execution exceeds its time limit it is automatically marked as `failed`. -#### Job Retention +#### Execution Retention -Completed and failed job results are retained for **24 hours**. After that, the status endpoint returns `404`. Retrieve and store results on your end if you need them longer. +Completed and failed runs are read from execution logs and follow the workspace's execution-log retention policy. #### Capacity Limits diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index 6ed28693188..ecde7d1730f 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -153,7 +153,7 @@ "get": { "operationId": "getWorkflowExecution", "summary": "Get Execution Status", - "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.", + "description": "Get the current status of a workflow execution. Returns `queued` immediately after async dispatch, then the run's durable lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. This legacy-compatible resource remains available for existing integrations.", "tags": ["Execution"], "x-codeSamples": [ { @@ -1394,6 +1394,7 @@ "AsyncExecutionResult": { "type": "object", "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "required": ["success", "async", "jobId", "executionId", "message", "statusUrl"], "properties": { "success": { "type": "boolean", @@ -1506,8 +1507,8 @@ }, "status": { "type": "string", - "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], - "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "enum": ["queued", "pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `queued` is projected from the async queue before the durable execution log exists; `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", "example": "completed" }, "trigger": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 22fc0bae059..00d943e77a7 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1536,6 +1536,173 @@ } } }, + "/api/v2/workflows/{id}/executions/{executionId}/resume": { + "post": { + "operationId": "resumeWorkflowExecutionV2", + "summary": "Resume a workflow execution", + "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.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused parent run.", + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": true, + "description": "The pause context to resume and its optional input. Bodies over 10 MB and unknown keys are rejected.", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["contextId"], + "properties": { + "contextId": { + "type": "string", + "minLength": 1, + "description": "The context ID of the human-in-the-loop pause point." + }, + "input": { + "description": "Input supplied to the paused block." + } + } + }, + "example": { + "contextId": "ctx_123", + "input": { + "approved": true, + "comments": "Looks good to proceed" + } + } + } + } + }, + "responses": { + "200": { + "description": "The completed, failed, paused, or cancelled resume execution resource.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ExecutionResource" + } + } + }, + "example": { + "data": { + "executionId": "resume_exec_1", + "workflowId": "wf_123", + "status": "completed", + "output": { + "result": "approved" + }, + "error": null, + "durationMs": 420 + } + } + } + } + }, + "202": { + "description": "The resume is queued. Poll `statusUrl` using the returned resume execution ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["executionId", "statusUrl"], + "properties": { + "executionId": { + "type": "string" + }, + "statusUrl": { + "type": "string" + }, + "queuePosition": { + "type": "integer", + "minimum": 1 + } + } + } + } + }, + "example": { + "data": { + "executionId": "resume_exec_1", + "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/executions/resume_exec_1" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "The pause context cannot be resumed in its current state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "description": "Resume execution infrastructure temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/api/v2/workflows/{id}/executions/{executionId}/cancel": { "post": { "operationId": "cancelExecutionV2", diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index b2e8ca4c523..6e81a450470 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -173,7 +173,7 @@ "get": { "operationId": "getWorkflowExecution", "summary": "Get Execution Status", - "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling \u2014 works for any execution, including ones that pause and resume.", + "description": "Get the current status of a workflow execution. Returns `queued` immediately after async dispatch, then the run's durable lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. This legacy-compatible resource remains available for existing integrations.", "tags": ["Workflows"], "x-codeSamples": [ { @@ -6611,6 +6611,7 @@ "AsyncExecutionResult": { "type": "object", "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "required": ["success", "async", "jobId", "executionId", "message", "statusUrl"], "properties": { "success": { "type": "boolean", @@ -7020,8 +7021,8 @@ }, "status": { "type": "string", - "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], - "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "enum": ["queued", "pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `queued` is projected from the async queue before the durable execution log exists; `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", "example": "completed" }, "trigger": { diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts index ddc55bf4df9..9ba0d765a7e 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts @@ -9,6 +9,7 @@ const { mockGetCurrentPayer, mockGetPauseContextDetail, mockGetPausedExecutionDetail, + mockEnqueueResume, mockPreprocessExecution, mockValidateWorkflowAccess, } = vi.hoisted(() => ({ @@ -16,6 +17,7 @@ const { mockGetCurrentPayer: vi.fn(), mockGetPauseContextDetail: vi.fn(), mockGetPausedExecutionDetail: vi.fn(), + mockEnqueueResume: vi.fn().mockResolvedValue('resume-execution:resume-execution-1'), mockPreprocessExecution: vi.fn(), mockValidateWorkflowAccess: vi.fn(), })) @@ -28,6 +30,14 @@ vi.mock('@/lib/execution/preprocessing', () => ({ preprocessExecution: mockPreprocessExecution, })) +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ enqueue: mockEnqueueResume }), +})) + +vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ + RESUME_EXECUTION_JOB_ID_PREFIX: 'resume-execution:', +})) + vi.mock('@sim/utils/id', () => ({ generateId: () => 'resume-preflight-1', })) @@ -48,6 +58,7 @@ vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({ })) import { GET, POST } from '@/app/api/resume/[workflowId]/[executionId]/[contextId]/route' +import { handleResumeExecution } from '@/app/api/resume/resume-handler' const WORKFLOW_ID = 'workflow-1' const EXECUTION_ID = 'execution-1' @@ -84,6 +95,7 @@ interface PausedExecutionOverrides { snapshotWorkspaceId?: string snapshotActorUserId?: string billingAttribution?: unknown + executionMode?: 'sync' | 'stream' | 'async' } function createPausedExecution(overrides: PausedExecutionOverrides = {}) { @@ -108,7 +120,7 @@ function createPausedExecution(overrides: PausedExecutionOverrides = {}) { triggerType: 'manual', useDraftState: false, startTime: '2026-07-10T00:00:00.000Z', - executionMode: 'sync', + executionMode: overrides.executionMode ?? 'sync', }, workflow: { version: '1', blocks: [], connections: [] }, input: {}, @@ -229,6 +241,87 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => { }) }) + it('preserves the legacy async job polling response', async () => { + mockGetPausedExecutionDetail.mockResolvedValueOnce( + createPausedExecution({ executionMode: 'async' }) + ) + mockEnqueueOrStartResume.mockResolvedValueOnce({ + status: 'started', + resumeExecutionId: 'resume-execution-1', + resumeEntryId: 'resume-entry-1', + pausedExecution: { id: 'paused-execution-1' }, + contextId: CONTEXT_ID, + resumeInput: { approved: true }, + userId: 'current-api-key-user', + }) + const { request, context } = makeRequest() + + const response = await POST(request, context) + + expect(response.status).toBe(202) + await expect(response.json()).resolves.toEqual({ + success: true, + async: true, + jobId: 'resume-execution:resume-execution-1', + executionId: 'resume-execution-1', + message: 'Resume execution queued', + statusUrl: 'https://test.sim.ai/api/jobs/resume-execution:resume-execution-1', + }) + expect(mockEnqueueResume).toHaveBeenCalledWith( + 'resume-execution', + expect.objectContaining({ resumeExecutionId: 'resume-execution-1' }), + expect.objectContaining({ + metadata: expect.objectContaining({ workflowId: WORKFLOW_ID }), + }) + ) + expect(mockEnqueueResume.mock.calls[0]?.[2]).not.toHaveProperty('jobId') + }) + + it('uses deterministic dispatch and execution polling for the v2 surface', async () => { + mockGetPausedExecutionDetail.mockResolvedValueOnce( + createPausedExecution({ executionMode: 'async' }) + ) + mockEnqueueOrStartResume.mockResolvedValueOnce({ + status: 'started', + resumeExecutionId: 'resume-execution-1', + resumeEntryId: 'resume-entry-1', + pausedExecution: { id: 'paused-execution-1' }, + contextId: CONTEXT_ID, + resumeInput: { approved: true }, + userId: 'current-api-key-user', + }) + const { request } = makeRequest() + + const response = await handleResumeExecution({ + request, + workflowId: WORKFLOW_ID, + executionId: EXECUTION_ID, + contextId: CONTEXT_ID, + workspaceId: WORKSPACE_ID, + userId: 'current-api-key-user', + resumeInput: { approved: true }, + isApiCaller: true, + pollingSurface: 'v2', + }) + + expect(response.status).toBe(202) + await expect(response.json()).resolves.toEqual({ + success: true, + async: true, + executionId: 'resume-execution-1', + message: 'Resume execution queued', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + }) + expect(mockEnqueueResume).toHaveBeenCalledWith( + 'resume-execution', + expect.objectContaining({ resumeExecutionId: 'resume-execution-1' }), + expect.objectContaining({ + jobId: 'resume-execution:resume-entry-1', + metadata: expect.objectContaining({ workflowId: WORKFLOW_ID }), + }) + ) + }) + it.each([ { statusCode: 402, message: 'Member usage limit reached', retryable: false }, { statusCode: 429, message: 'Target concurrency full', retryable: true }, diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index fc87a1237da..80af4959ff9 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -1,106 +1,19 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' -import { type NextRequest, NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { getPauseContextDetailContract, resumeWorkflowExecutionContextContract, } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { AuthType } from '@/lib/auth/hybrid' -import { - assertBillingAttributionSnapshot, - type BillingAttributionSnapshot, -} from '@/lib/billing/core/billing-attribution' -import { getJobQueue } from '@/lib/core/async-jobs' -import { generateRequestId } from '@/lib/core/utils/request' -import { SSE_HEADERS } from '@/lib/core/utils/sse' -import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { preprocessExecution } from '@/lib/execution/preprocessing' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' -import { - agentStreamProtocolResponseHeaders, - createStreamingResponse, -} from '@/lib/workflows/streaming/streaming' +import { handleResumeExecution } from '@/app/api/resume/resume-handler' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import type { ResumeExecutionPayload } from '@/background/resume-execution' -import { ExecutionSnapshot } from '@/executor/execution/snapshot' - -const logger = createLogger('WorkflowResumeAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid' -const INVALID_PAUSED_ATTRIBUTION_ERROR = - 'Paused execution billing attribution is missing or invalid' -const PAUSED_EXECUTION_BINDING_ERROR = - 'Paused execution snapshot does not match the requested workflow or execution' -const PAUSED_ATTRIBUTION_BINDING_ERROR = - 'Paused execution billing attribution does not match its workspace or actor' - -interface PausedExecutionSnapshotSource { - workflowId: string - executionId: string - executionSnapshot: unknown -} - -interface PausedExecutionSnapshotBinding { - snapshot: ExecutionSnapshot - billingAttribution: BillingAttributionSnapshot -} - -function loadPausedExecutionSnapshot( - pausedExecution: PausedExecutionSnapshotSource, - expected: { workflowId: string; executionId: string; workspaceId: string } -): PausedExecutionSnapshotBinding { - if ( - !isRecordLike(pausedExecution.executionSnapshot) || - typeof pausedExecution.executionSnapshot.snapshot !== 'string' - ) { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - let snapshot: ExecutionSnapshot - try { - snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot) - } catch { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - if (!isRecordLike(snapshot.metadata)) { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - let billingAttribution: BillingAttributionSnapshot - try { - billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution) - } catch { - throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR) - } - - if ( - pausedExecution.workflowId !== expected.workflowId || - pausedExecution.executionId !== expected.executionId || - snapshot.metadata.workflowId !== expected.workflowId || - snapshot.metadata.executionId !== expected.executionId - ) { - throw new Error(PAUSED_EXECUTION_BINDING_ERROR) - } - - if ( - snapshot.metadata.workspaceId !== expected.workspaceId || - billingAttribution.workspaceId !== expected.workspaceId || - snapshot.metadata.userId !== billingAttribution.actorUserId - ) { - throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR) - } - - return { snapshot, billingAttribution } -} - export const POST = withRouteHandler( async ( request: NextRequest, @@ -117,11 +30,9 @@ export const POST = withRouteHandler( const parsed = await parseRequest(resumeWorkflowExecutionContextContract, request, context) if (!parsed.success) return parsed.response const { workflowId, executionId, contextId } = parsed.data.params - const requestId = generateRequestId() const workflow = access.workflow if (!workflow?.workspaceId) { - logger.error(`[${requestId}] Authorized workflow has no workspace`, { workflowId }) return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 }) } const userId = access.auth?.userId @@ -129,269 +40,28 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) - if (!pausedExecution) { - return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) - } - - let snapshotBinding: PausedExecutionSnapshotBinding - try { - snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, { - workflowId, - executionId, - workspaceId: workflow.workspaceId, - }) - } catch (error) { - const message = toError(error).message - logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { - workflowId, - executionId, - error: message, - }) - return NextResponse.json({ error: message }, { status: 500 }) - } - - const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding - let payload: unknown = {} try { payload = await request.json() } catch { payload = {} } - const resumeInput = typeof payload === 'object' && payload !== null && 'input' in payload ? payload.input : (payload ?? {}) - const resumeExecutionId = generateId() - logger.info(`[${requestId}] Preprocessing resume execution`, { + return handleResumeExecution({ + request, workflowId, - parentExecutionId: executionId, - resumeExecutionId, - userId, - actorUserId: billingAttribution.actorUserId, - }) - - /** - * This preflight gives synchronous callers current block/usage feedback - * without reserving under a throwaway id. The claimed resume reruns every - * gate and reserves atomically under its persisted resume execution id. - */ - const preprocessResult = await preprocessExecution({ - workflowId, - userId, - triggerType: 'manual', - executionId: resumeExecutionId, - requestId, - checkRateLimit: false, - checkDeployment: false, - skipConcurrencyReservation: true, - logPreprocessingErrors: false, + executionId, + contextId, workspaceId: workflow.workspaceId, - billingAttribution, - }) - - if (!preprocessResult.success) { - logger.warn(`[${requestId}] Preprocessing failed for resume`, { - workflowId, - parentExecutionId: executionId, - error: preprocessResult.error?.message, - statusCode: preprocessResult.error?.statusCode, - }) - - return NextResponse.json( - { - error: - preprocessResult.error?.message || - 'Failed to validate resume execution. Please try again.', - }, - { status: preprocessResult.error?.statusCode || 400 } - ) - } - - logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId, - actorUserId: preprocessResult.actorUserId, + userId, + resumeInput, + isApiCaller: access.auth?.authType === AuthType.API_KEY, + pollingSurface: 'legacy', }) - - try { - const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ - executionId, - workflowId, - contextId, - resumeInput, - userId, - allowedPauseKinds: ['human'], - }) - - if (enqueueResult.status === 'queued') { - return NextResponse.json({ - status: 'queued', - executionId: enqueueResult.resumeExecutionId, - queuePosition: enqueueResult.queuePosition, - message: 'Resume queued. It will run after current resumes finish.', - }) - } - - const resumeArgs = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecution: enqueueResult.pausedExecution, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - } - - const isApiCaller = access.auth?.authType === AuthType.API_KEY - const executionMode = isApiCaller - ? (persistedSnapshot.metadata.executionMode ?? 'sync') - : undefined - const includeThinking = persistedSnapshot.metadata.includeThinking === true - const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true - - if (isApiCaller && executionMode === 'stream') { - const stream = await createStreamingResponse({ - requestId, - streamConfig: { - selectedOutputs: persistedSnapshot.selectedOutputs, - timeoutMs: preprocessResult.executionTimeout?.sync, - includeThinking, - includeToolCalls, - }, - executionId: enqueueResult.resumeExecutionId, - workspaceId: workflow.workspaceId || undefined, - workflowId, - userId: enqueueResult.userId, - allowLargeValueWorkflowScope: true, - requestSignal: request.signal, - requestHeaders: request.headers, - executeFn: async ({ onStream, onBlockComplete, abortSignal }) => - PauseResumeManager.startResumeExecution({ - ...resumeArgs, - onStream, - onBlockComplete, - abortSignal, - }), - }) - - return new NextResponse(stream, { - headers: { - ...SSE_HEADERS, - // Echo the negotiated stream protocol (same as the public chat route). - ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), - 'X-Execution-Id': enqueueResult.resumeExecutionId, - }, - }) - } - - if (isApiCaller && executionMode === 'sync') { - const result = await PauseResumeManager.startResumeExecution(resumeArgs) - - return NextResponse.json({ - success: result.success, - status: result.status ?? (result.success ? 'completed' : 'failed'), - executionId: enqueueResult.resumeExecutionId, - output: result.output, - error: result.error, - metadata: result.metadata - ? { - duration: result.metadata.duration, - startTime: result.metadata.startTime, - endTime: result.metadata.endTime, - } - : undefined, - }) - } - - if (isApiCaller && executionMode === 'async') { - const resumePayload: ResumeExecutionPayload = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecutionId: enqueueResult.pausedExecution.id, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - workflowId, - parentExecutionId: executionId, - } - - let jobId: string - try { - const jobQueue = await getJobQueue() - jobId = await jobQueue.enqueue('resume-execution', resumePayload, { - metadata: { workflowId, workspaceId: workflow.workspaceId, userId }, - }) - logger.info('Enqueued async resume execution', { - jobId, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - } catch (dispatchError) { - logger.error('Failed to dispatch async resume execution', { - error: toError(dispatchError).message, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - await PauseResumeManager.markResumeAttemptFailed({ - resumeEntryId: enqueueResult.resumeEntryId, - pausedExecutionId: enqueueResult.pausedExecution.id, - parentExecutionId: executionId, - contextId: enqueueResult.contextId, - failureReason: 'Failed to queue async resume execution', - }) - await PauseResumeManager.processQueuedResumes(executionId, workflowId) - return NextResponse.json( - { error: 'Failed to queue resume execution. Please try again.' }, - { status: 503 } - ) - } - - return NextResponse.json( - { - success: true, - async: true, - jobId, - executionId: enqueueResult.resumeExecutionId, - message: 'Resume execution queued', - statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`, - }, - { status: 202 } - ) - } - - PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { - logger.error('Failed to start resume execution', { - workflowId, - parentExecutionId: executionId, - resumeExecutionId: enqueueResult.resumeExecutionId, - error, - }) - }) - - return NextResponse.json({ - status: 'started', - executionId: enqueueResult.resumeExecutionId, - message: 'Resume execution started.', - }) - } catch (error) { - logger.error('Resume request failed', { - workflowId, - executionId, - contextId, - error, - }) - const statusCode = - isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : 400 - return NextResponse.json( - { error: toError(error).message || 'Failed to queue resume request' }, - { status: statusCode } - ) - } } ) diff --git a/apps/sim/app/api/resume/resume-handler.ts b/apps/sim/app/api/resume/resume-handler.ts new file mode 100644 index 00000000000..ad148ff3d74 --- /dev/null +++ b/apps/sim/app/api/resume/resume-handler.ts @@ -0,0 +1,374 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { type NextRequest, NextResponse } from 'next/server' +import { + assertBillingAttributionSnapshot, + type BillingAttributionSnapshot, +} from '@/lib/billing/core/billing-attribution' +import { getJobQueue } from '@/lib/core/async-jobs' +import { generateRequestId } from '@/lib/core/utils/request' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { RESUME_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' +import type { ResumeExecutionPayload } from '@/background/resume-execution' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' + +const logger = createLogger('WorkflowResumeAPI') + +const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid' +const INVALID_PAUSED_ATTRIBUTION_ERROR = + 'Paused execution billing attribution is missing or invalid' +const PAUSED_EXECUTION_BINDING_ERROR = + 'Paused execution snapshot does not match the requested workflow or execution' +const PAUSED_ATTRIBUTION_BINDING_ERROR = + 'Paused execution billing attribution does not match its workspace or actor' + +interface PausedExecutionSnapshotSource { + workflowId: string + executionId: string + executionSnapshot: unknown +} + +interface PausedExecutionSnapshotBinding { + snapshot: ExecutionSnapshot + billingAttribution: BillingAttributionSnapshot +} + +interface HandleResumeExecutionOptions { + request: NextRequest + workflowId: string + executionId: string + contextId: string + workspaceId: string + userId: string + resumeInput: unknown + isApiCaller: boolean + pollingSurface: 'legacy' | 'v2' +} + +function loadPausedExecutionSnapshot( + pausedExecution: PausedExecutionSnapshotSource, + expected: { workflowId: string; executionId: string; workspaceId: string } +): PausedExecutionSnapshotBinding { + if ( + !isRecordLike(pausedExecution.executionSnapshot) || + typeof pausedExecution.executionSnapshot.snapshot !== 'string' + ) { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + let snapshot: ExecutionSnapshot + try { + snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot) + } catch { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + if (!isRecordLike(snapshot.metadata)) { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution) + } catch { + throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR) + } + + if ( + pausedExecution.workflowId !== expected.workflowId || + pausedExecution.executionId !== expected.executionId || + snapshot.metadata.workflowId !== expected.workflowId || + snapshot.metadata.executionId !== expected.executionId + ) { + throw new Error(PAUSED_EXECUTION_BINDING_ERROR) + } + + if ( + snapshot.metadata.workspaceId !== expected.workspaceId || + billingAttribution.workspaceId !== expected.workspaceId || + snapshot.metadata.userId !== billingAttribution.actorUserId + ) { + throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR) + } + + return { snapshot, billingAttribution } +} + +/** Executes the shared resume flow while preserving each API surface's polling contract. */ +export async function handleResumeExecution({ + request, + workflowId, + executionId, + contextId, + workspaceId, + userId, + resumeInput, + isApiCaller, + pollingSurface, +}: HandleResumeExecutionOptions): Promise { + const requestId = generateRequestId() + const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ + workflowId, + executionId, + }) + if (!pausedExecution) { + return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) + } + + let snapshotBinding: PausedExecutionSnapshotBinding + try { + snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, { + workflowId, + executionId, + workspaceId, + }) + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { + workflowId, + executionId, + error: message, + }) + return NextResponse.json({ error: message }, { status: 500 }) + } + + const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding + const resumeExecutionId = generateId() + + logger.info(`[${requestId}] Preprocessing resume execution`, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId, + userId, + actorUserId: billingAttribution.actorUserId, + }) + + /** + * This preflight gives synchronous callers current block/usage feedback + * without reserving under a throwaway id. The claimed resume reruns every + * gate and reserves atomically under its persisted resume execution id. + */ + const preprocessResult = await preprocessExecution({ + workflowId, + userId, + triggerType: 'manual', + executionId: resumeExecutionId, + requestId, + checkRateLimit: false, + checkDeployment: false, + skipConcurrencyReservation: true, + logPreprocessingErrors: false, + workspaceId, + billingAttribution, + }) + + if (!preprocessResult.success) { + logger.warn(`[${requestId}] Preprocessing failed for resume`, { + workflowId, + parentExecutionId: executionId, + error: preprocessResult.error?.message, + statusCode: preprocessResult.error?.statusCode, + }) + + return NextResponse.json( + { + error: + preprocessResult.error?.message || + 'Failed to validate resume execution. Please try again.', + }, + { status: preprocessResult.error?.statusCode || 400 } + ) + } + + logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId, + actorUserId: preprocessResult.actorUserId, + }) + + try { + const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ + executionId, + workflowId, + contextId, + resumeInput, + userId, + allowedPauseKinds: ['human'], + }) + + if (enqueueResult.status === 'queued') { + return NextResponse.json({ + status: 'queued', + executionId: enqueueResult.resumeExecutionId, + queuePosition: enqueueResult.queuePosition, + message: 'Resume queued. It will run after current resumes finish.', + }) + } + + const resumeArgs = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecution: enqueueResult.pausedExecution, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + } + + const executionMode = isApiCaller + ? (persistedSnapshot.metadata.executionMode ?? 'sync') + : undefined + const includeThinking = persistedSnapshot.metadata.includeThinking === true + const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true + + if (isApiCaller && executionMode === 'stream') { + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: persistedSnapshot.selectedOutputs, + timeoutMs: preprocessResult.executionTimeout?.sync, + includeThinking, + includeToolCalls, + }, + executionId: enqueueResult.resumeExecutionId, + workspaceId, + workflowId, + userId: enqueueResult.userId, + allowLargeValueWorkflowScope: true, + requestSignal: request.signal, + requestHeaders: request.headers, + executeFn: async ({ onStream, onBlockComplete, abortSignal }) => + PauseResumeManager.startResumeExecution({ + ...resumeArgs, + onStream, + onBlockComplete, + abortSignal, + }), + }) + + return new NextResponse(stream, { + headers: { + ...SSE_HEADERS, + ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), + 'X-Execution-Id': enqueueResult.resumeExecutionId, + }, + }) + } + + if (isApiCaller && executionMode === 'sync') { + const result = await PauseResumeManager.startResumeExecution(resumeArgs) + + return NextResponse.json({ + success: result.success, + status: result.status ?? (result.success ? 'completed' : 'failed'), + executionId: enqueueResult.resumeExecutionId, + output: result.output, + error: result.error, + metadata: result.metadata + ? { + duration: result.metadata.duration, + startTime: result.metadata.startTime, + endTime: result.metadata.endTime, + } + : undefined, + }) + } + + if (isApiCaller && executionMode === 'async') { + const resumePayload: ResumeExecutionPayload = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecutionId: enqueueResult.pausedExecution.id, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + workflowId, + parentExecutionId: executionId, + } + + let jobId: string + try { + const jobQueue = await getJobQueue() + jobId = await jobQueue.enqueue('resume-execution', resumePayload, { + ...(pollingSurface === 'v2' + ? { jobId: `${RESUME_EXECUTION_JOB_ID_PREFIX}${enqueueResult.resumeEntryId}` } + : {}), + metadata: { workflowId, workspaceId, userId }, + }) + logger.info('Enqueued async resume execution', { + jobId, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + } catch (dispatchError) { + logger.error('Failed to dispatch async resume execution', { + error: toError(dispatchError).message, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + await PauseResumeManager.markResumeAttemptFailed({ + resumeEntryId: enqueueResult.resumeEntryId, + pausedExecutionId: enqueueResult.pausedExecution.id, + parentExecutionId: executionId, + contextId: enqueueResult.contextId, + failureReason: 'Failed to queue async resume execution', + }) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) + return NextResponse.json( + { error: 'Failed to queue resume execution. Please try again.' }, + { status: 503 } + ) + } + + return NextResponse.json( + { + success: true, + async: true, + ...(pollingSurface === 'legacy' ? { jobId } : {}), + executionId: enqueueResult.resumeExecutionId, + message: 'Resume execution queued', + statusUrl: + pollingSurface === 'legacy' + ? `${getBaseUrl()}/api/jobs/${jobId}` + : `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${enqueueResult.resumeExecutionId}`, + }, + { status: 202 } + ) + } + + PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { + logger.error('Failed to start resume execution', { + workflowId, + parentExecutionId: executionId, + resumeExecutionId: enqueueResult.resumeExecutionId, + error, + }) + }) + + return NextResponse.json({ + status: 'started', + executionId: enqueueResult.resumeExecutionId, + message: 'Resume execution started.', + }) + } catch (error) { + logger.error('Resume request failed', { + workflowId, + executionId, + contextId, + error, + }) + const statusCode = + isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : 400 + return NextResponse.json( + { error: toError(error).message || 'Failed to queue resume request' }, + { status: statusCode } + ) + } +} diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts new file mode 100644 index 00000000000..5e148d3db93 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts @@ -0,0 +1,170 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHandleResumeExecution, mockResolveV2WorkflowAccess } = vi.hoisted(() => ({ + mockHandleResumeExecution: vi.fn(), + mockResolveV2WorkflowAccess: vi.fn(), +})) + +vi.mock('@/app/api/resume/resume-handler', () => ({ + handleResumeExecution: mockHandleResumeExecution, +})) + +vi.mock('@/app/api/v2/workflows/lib/access', () => ({ + resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://test.sim.ai', +})) + +import { POST } from '@/app/api/v2/workflows/[id]/executions/[executionId]/resume/route' + +const WORKFLOW_ID = 'workflow-1' +const EXECUTION_ID = 'execution-1' + +function makeRequest(body: string) { + return { + request: new NextRequest( + `http://localhost/api/v2/workflows/${WORKFLOW_ID}/executions/${EXECUTION_ID}/resume`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body, + } + ), + context: { params: Promise.resolve({ id: WORKFLOW_ID, executionId: EXECUTION_ID }) }, + } +} + +describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveV2WorkflowAccess.mockResolvedValue({ + ok: true, + userId: 'user-1', + keyType: 'workspace', + workflow: { id: WORKFLOW_ID, workspaceId: 'workspace-1' }, + }) + }) + + it('authenticates before parsing the request body', async () => { + mockResolveV2WorkflowAccess.mockResolvedValueOnce({ + ok: false, + response: NextResponse.json( + { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }, + { status: 401 } + ), + }) + const { request, context } = makeRequest('{') + + const response = await POST(request, context) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + error: { code: 'UNAUTHORIZED', message: 'Unauthorized' }, + }) + expect(mockResolveV2WorkflowAccess).toHaveBeenCalledWith(request, WORKFLOW_ID, 'write') + expect(mockHandleResumeExecution).not.toHaveBeenCalled() + }) + + it('resumes a pause context through the execution-scoped v2 endpoint', async () => { + mockHandleResumeExecution.mockResolvedValueOnce( + NextResponse.json( + { + success: true, + async: true, + executionId: 'resume-execution-1', + message: 'Resume execution queued', + statusUrl: + 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + }, + { status: 202 } + ) + ) + const { request, context } = makeRequest( + JSON.stringify({ contextId: 'context-1', input: { approved: true } }) + ) + + const response = await POST(request, context) + + expect(response.status).toBe(202) + expect(response.headers.get('X-Execution-Id')).toBe('resume-execution-1') + expect(await response.json()).toEqual({ + data: { + executionId: 'resume-execution-1', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + }, + }) + expect(mockHandleResumeExecution).toHaveBeenCalledWith({ + request, + workflowId: WORKFLOW_ID, + executionId: EXECUTION_ID, + contextId: 'context-1', + workspaceId: 'workspace-1', + userId: 'user-1', + resumeInput: { approved: true }, + isApiCaller: true, + pollingSurface: 'v2', + }) + }) + + it('returns queued resumes as a v2 polling receipt', async () => { + mockHandleResumeExecution.mockResolvedValueOnce( + NextResponse.json({ + status: 'queued', + executionId: 'resume-execution-2', + queuePosition: 2, + message: 'Resume queued. It will run after current resumes finish.', + }) + ) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-2' })) + + const response = await POST(request, context) + + expect(response.status).toBe(202) + expect(await response.json()).toEqual({ + data: { + executionId: 'resume-execution-2', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-2', + queuePosition: 2, + }, + }) + }) + + it('wraps synchronous resume results in the canonical v2 execution shape', async () => { + mockHandleResumeExecution.mockResolvedValueOnce( + NextResponse.json({ + success: true, + status: 'completed', + executionId: 'resume-execution-3', + output: { approved: true }, + metadata: { + startTime: '2026-08-05T00:00:00.000Z', + endTime: '2026-08-05T00:00:01.000Z', + duration: 1000, + }, + }) + ) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-3' })) + + const response = await POST(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + executionId: 'resume-execution-3', + workflowId: WORKFLOW_ID, + status: 'completed', + output: { approved: true }, + error: null, + startedAt: '2026-08-05T00:00:00.000Z', + endedAt: '2026-08-05T00:00:01.000Z', + durationMs: 1000, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts new file mode 100644 index 00000000000..e0afaec0393 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts @@ -0,0 +1,147 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type { NextRequest } from 'next/server' +import { v2ResumeWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { WORKFLOW_EXECUTION_ID_HEADER } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { handleResumeExecution } from '@/app/api/resume/resume-handler' +import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' +import { classifyExecutionError } from '@/executor/utils/errors' + +const logger = createLogger('V2WorkflowResumeAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const ERROR_CODE_BY_STATUS: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 402: 'USAGE_LIMIT_EXCEEDED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 409: 'CONFLICT', + 413: 'PAYLOAD_TOO_LARGE', + 423: 'LOCKED', + 429: 'RATE_LIMITED', + 503: 'SERVICE_UNAVAILABLE', +} + +const TERMINAL_RESUME_STATUSES = new Set(['completed', 'failed', 'paused', 'cancelled']) + +function errorMessage(payload: Record): string { + return typeof payload.error === 'string' ? payload.error : 'Resume execution failed' +} + +/** + * POST /api/v2/workflows/[id]/executions/[executionId]/resume resumes one pause + * context on the parent execution. The new resume attempt gets its own + * execution ID, which is the only polling handle exposed by v2. + */ +export const POST = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ id: string; executionId: string }> } + ) => { + const { id: workflowId } = await context.params + const access = await resolveV2WorkflowAccess(request, workflowId, 'write') + if (!access.ok) return access.response + + const parsed = await parseRequest(v2ResumeWorkflowContract, request, context, { + maxBodyBytes: 10 * 1024 * 1024, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { executionId } = parsed.data.params + const { contextId, input } = parsed.data.body + + if (!access.workflow.workspaceId) { + return v2Error('INTERNAL_ERROR', 'Workflow has no associated workspace') + } + + try { + const response = await handleResumeExecution({ + request, + workflowId, + executionId, + contextId, + workspaceId: access.workflow.workspaceId, + userId: access.userId, + resumeInput: input === undefined ? {} : input, + isApiCaller: true, + pollingSurface: 'v2', + }) + + if (response.headers.get('Content-Type')?.startsWith('text/event-stream')) { + return response + } + + const payload: unknown = await response.json() + if (!isRecordLike(payload)) { + return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid response') + } + + if (!response.ok) { + return v2Error( + ERROR_CODE_BY_STATUS[response.status] ?? 'INTERNAL_ERROR', + errorMessage(payload), + { status: response.status } + ) + } + + if (typeof payload.executionId !== 'string') { + return v2Error('INTERNAL_ERROR', 'Resume execution did not return an execution ID') + } + + const statusUrl = `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${payload.executionId}` + const headers = { [WORKFLOW_EXECUTION_ID_HEADER]: payload.executionId } + + if (response.status === 202 || payload.status === 'queued') { + return v2Data( + { + executionId: payload.executionId, + statusUrl, + ...(typeof payload.queuePosition === 'number' + ? { queuePosition: payload.queuePosition } + : {}), + }, + { status: 202, headers } + ) + } + + if (typeof payload.status !== 'string' || !TERMINAL_RESUME_STATUSES.has(payload.status)) { + return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid status') + } + + const metadata = isRecordLike(payload.metadata) ? payload.metadata : undefined + return v2Data( + { + executionId: payload.executionId, + workflowId, + status: payload.status as 'completed' | 'failed' | 'paused' | 'cancelled', + output: payload.output ?? null, + error: + typeof payload.error === 'string' + ? classifyExecutionError(new Error(payload.error)) + : null, + startedAt: + metadata && typeof metadata.startTime === 'string' ? metadata.startTime : undefined, + endedAt: metadata && typeof metadata.endTime === 'string' ? metadata.endTime : undefined, + durationMs: + metadata && typeof metadata.duration === 'number' ? metadata.duration : undefined, + }, + { headers } + ) + } catch (error) { + logger.error('Failed to resume workflow execution', { + workflowId, + executionId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts index c0e96fc8080..bfd1a3896d8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts @@ -4,13 +4,13 @@ import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthenticateV1Request, mockGetJob, mockGetWorkflowExecutionStatus, mockCancel } = - vi.hoisted(() => ({ +const { mockAuthenticateV1Request, mockGetWorkflowExecutionStatus, mockCancel } = vi.hoisted( + () => ({ mockAuthenticateV1Request: vi.fn(), - mockGetJob: vi.fn(), mockGetWorkflowExecutionStatus: vi.fn(), mockCancel: vi.fn(), - })) + }) +) vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request, @@ -28,14 +28,6 @@ vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ cancelWorkflowExecution: mockCancel, })) -vi.mock('@/lib/core/async-jobs', () => ({ - getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), -})) - -vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ - WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', -})) - vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) @@ -100,23 +92,31 @@ describe('v2 executions status + cancel', () => { expect(body.data.durationMs).toBe(5000) }) - it('backfills queued status from the job queue before the log row exists', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue(null) - mockGetJob.mockResolvedValue({ - status: 'pending', - metadata: { workflowId: 'workflow-1' }, + it('returns the queued execution resource before the log row exists', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue({ + executionId: 'exec-1', + workflowId: 'workflow-1', + status: 'queued', + trigger: 'api', + level: 'info', + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: null, + totalDurationMs: null, + paused: null, + cost: null, + error: null, + finalOutput: null, + blockOutputs: null, }) const res = await callStatus() expect(res.status).toBe(200) expect((await res.json()).data.status).toBe('queued') - expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:exec-1') }) it('404s when neither a log row nor a matching job exists', async () => { mockGetWorkflowExecutionStatus.mockResolvedValue(null) - mockGetJob.mockResolvedValue(null) const res = await callStatus() diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts index d38da03a1b5..f32da31bfb3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts @@ -1,18 +1,13 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { - type V2WorkflowExecutionStatus, - v2GetWorkflowExecutionContract, -} from '@/lib/api/contracts/v2/workflows' +import { v2GetWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' -import { getJobQueue } from '@/lib/core/async-jobs' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, FunctionalOutputsUnavailableError, } from '@/lib/logs/execution/functional-outputs' -import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' @@ -22,25 +17,6 @@ const logger = createLogger('V2WorkflowExecutionStatusAPI') export const dynamic = 'force-dynamic' -/** - * Maps the async job's phase onto the execution status enum for the window - * before the worker writes the durable log row. - */ -function jobStatusToExecutionStatus(jobStatus: string): V2WorkflowExecutionStatus['status'] | null { - switch (jobStatus) { - case 'pending': - return 'queued' - case 'processing': - return 'running' - case 'failed': - return 'failed' - case 'completed': - return 'completed' - default: - return null - } -} - /** * GET /api/v2/workflows/[id]/executions/[executionId] — the single status URL * for both sync and async runs. When no log row exists yet, the async job @@ -70,50 +46,23 @@ export const GET = withRouteHandler( selectedOutputs, }) - if (status) { - return v2Data({ - executionId: status.executionId, - workflowId: status.workflowId, - status: status.status, - trigger: status.trigger ?? null, - startedAt: status.startedAt, - endedAt: status.endedAt, - durationMs: status.totalDurationMs, - paused: status.paused, - cost: status.cost, - error: status.error ? classifyExecutionError(new Error(status.error)) : null, - output: status.finalOutput, - blockOutputs: status.blockOutputs, - }) - } - - // No log row yet — a queued/just-started async run. Backfilled from the - // job queue via the deterministic id; authz already ran above. - const jobQueue = await getJobQueue() - const job = await jobQueue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`) - const jobWorkflowId = - job?.metadata && typeof job.metadata === 'object' - ? (job.metadata as { workflowId?: string }).workflowId - : undefined - const mapped = job ? jobStatusToExecutionStatus(job.status) : null - if (!job || jobWorkflowId !== workflowId || !mapped) { + if (!status) { return v2Error('NOT_FOUND', 'Execution not found') } return v2Data({ - executionId, - workflowId, - status: mapped, - trigger: 'api', - startedAt: null, - endedAt: null, - durationMs: null, - paused: null, - cost: null, - error: - mapped === 'failed' && job.error ? classifyExecutionError(new Error(job.error)) : null, - output: null, - blockOutputs: null, + executionId: status.executionId, + workflowId: status.workflowId, + status: status.status, + trigger: status.trigger ?? null, + startedAt: status.startedAt, + endedAt: status.endedAt, + durationMs: status.totalDurationMs, + paused: status.paused, + cost: status.cost, + error: status.error ? classifyExecutionError(new Error(status.error)) : null, + output: status.finalOutput, + blockOutputs: status.blockOutputs, }) } catch (error) { if (error instanceof FunctionalOutputsUnavailableError) { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 72d0f71e04d..f63a9e90b76 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -807,6 +807,7 @@ describe('workflow execute async route', () => { expect(response.status).toBe(202) expect(body.executionId).toBe('execution-123') expect(body.jobId).toBe('job-123') + expect(body.statusUrl).toBe('http://localhost:3000/api/jobs/job-123') expect(mockClaimExecutionId).toHaveBeenCalledWith('execution-123') expect(mockEnqueue).toHaveBeenCalledWith( 'workflow-execution', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index a5950e82f60..aab37cbfa44 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -101,13 +101,9 @@ export function ApiDeploy({ const inputExample = getInputFormatExample ? getInputFormatExample(false) : '' const match = inputExample.match(/-d\s*'([\s\S]*)'/) if (match) { - try { - return JSON.parse(match[1]) as Record - } catch { - return { input: 'your data here' } - } + return JSON.parse(match[1]) as Record } - return { input: 'your data here' } + return { input: {} } } const getStreamPayloadObject = (): Record => { @@ -260,18 +256,23 @@ while (true) { const getAsyncCommand = (): string => { if (!info) return '' + if (info.isPublicApi) throw new Error('Async execution requires an API key') const endpoint = getBaseEndpoint() - const baseUrl = endpoint.split('/api/workflows/')[0] - const payload = getPayloadObject() - const isPublic = info.isPublicApi + const v2WorkflowPrefix = '/api/v2/workflows/' + if (!endpoint.includes(v2WorkflowPrefix) || !endpoint.endsWith('/execute')) { + throw new Error(`Invalid workflow execution endpoint: ${endpoint}`) + } + const baseUrl = endpoint.split(v2WorkflowPrefix)[0] + const statusEndpoint = `${endpoint.slice(0, -'/execute'.length)}/executions/EXECUTION_ID_FROM_EXECUTION` + const payload = { ...getPayloadObject(), async: true } switch (asyncExampleType) { case 'execute': switch (language) { case 'curl': return `curl -X POST \\ -${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ - -H "X-Execution-Mode: async" \\ + -H "X-API-Key: $SIM_API_KEY" \\ + -H "Content-Type: application/json" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -282,40 +283,40 @@ import requests response = requests.post( "${endpoint}", headers={ -${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" + "X-API-Key": os.environ.get("SIM_API_KEY"), + "Content-Type": "application/json", }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -job = response.json() -print(job) # Contains jobId and executionId` +execution = response.json()["data"] +print(execution)` case 'javascript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" + "X-API-Key": process.env.SIM_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const job = await response.json(); -console.log(job); // Contains jobId and executionId` +const { data: execution } = await response.json(); +console.log(execution);` case 'typescript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" + "X-API-Key": process.env.SIM_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const job: { jobId: string; executionId: string } = await response.json(); -console.log(job); // Contains jobId and executionId` +const { data: execution }: { data: { executionId: string; statusUrl: string } } = await response.json(); +console.log(execution);` default: return '' @@ -325,40 +326,41 @@ console.log(job); // Contains jobId and executionId` switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION` + "${statusEndpoint}?includeOutput=true"` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${statusEndpoint}", + params={"includeOutput": "true"}, headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -status = response.json() +status = response.json()["data"] print(status)` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${statusEndpoint}?includeOutput=true", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const status = await response.json(); +const { data: status } = await response.json(); console.log(status);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${statusEndpoint}?includeOutput=true", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const status: Record = await response.json(); +const { data: status }: { data: Record } = await response.json(); console.log(status);` default: @@ -417,13 +419,13 @@ console.log(limits);` const getAsyncExampleTitle = () => { switch (asyncExampleType) { case 'execute': - return 'Execute Job' + return 'Start Execution' case 'status': return 'Check Status' case 'rate-limits': return 'Usage Limits' default: - return 'Execute Job' + return 'Start Execution' } } @@ -537,49 +539,51 @@ console.log(limits);` /> -
-
- -
- - - - - - {copied.async ? 'Copied' : 'Copy'} - - - setAsyncExampleType(value as AsyncExampleType)} - align='end' - dropdownWidth={160} - /> + {!info.isPublicApi && ( +
+
+ +
+ + + + + + {copied.async ? 'Copied' : 'Copy'} + + + setAsyncExampleType(value as AsyncExampleType)} + align='end' + dropdownWidth={160} + /> +
+
- -
+ )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index d46e57a420c..9e5e643733d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -226,7 +226,21 @@ export function DeployModal({ workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY' const getInputFormatExample = (includeStreaming = false) => { - return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs) + const inputFormatExample = getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs) + if (!inputFormatExample) return '' + + const match = inputFormatExample.match(/-d\s*'([\s\S]*)'/) + if (!match) { + throw new Error(`Invalid workflow input example: ${inputFormatExample}`) + } + + const legacyBody = JSON.parse(match[1]) as Record + const { stream, selectedOutputs, ...input } = legacyBody + return ` -d '${JSON.stringify({ + input, + ...(stream === true ? { stream: true } : {}), + ...(Array.isArray(selectedOutputs) ? { selectedOutputs } : {}), + })}'` } const deploymentInfo: WorkflowDeploymentInfoUI | null = (() => { @@ -234,7 +248,7 @@ export function DeployModal({ return null } - const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` + const endpoint = `${getBaseUrl()}/api/v2/workflows/${workflowId}/execute` const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const placeholderKey = getApiHeaderPlaceholder() diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 10e0faa7c45..8fcae222c9a 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -437,6 +437,31 @@ export const v2ExecuteWorkflowContract = defineRouteContract({ }, }) +/** Resume input is scoped to one pause context on the parent execution. */ +export const v2ResumeWorkflowBodySchema = z + .object({ + contextId: z.string().min(1, 'contextId cannot be empty'), + input: z.unknown().optional(), + }) + .strict() +export type V2ResumeWorkflowBody = z.input + +export const v2ResumeWorkflowQueuedSchema = v2ExecuteWorkflowQueuedSchema.extend({ + queuePosition: z.number().int().positive().optional(), +}) +export type V2ResumeWorkflowQueued = z.output + +export const v2ResumeWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/resume', + params: workflowExecutionParamsSchema, + body: v2ResumeWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecuteWorkflowDataSchema), + }, +}) + /** * The polled execution resource. `queued` is backfilled from the async job * queue before the worker writes the durable log row — v1's jobs endpoint 404 diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 8847adc6630..a4e5743211d 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -546,6 +546,7 @@ const pausedWorkflowExecutionDetailSchema = pausedWorkflowExecutionSummarySchema }) const workflowExecutionStatusEnum = z.enum([ + 'queued', 'pending', 'running', 'paused', diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts index b2a46f0b893..b70b87bbc8c 100644 --- a/apps/sim/lib/compare/data/sim.ts +++ b/apps/sim/lib/compare/data/sim.ts @@ -1086,10 +1086,10 @@ export const simProfile: CompetitorProfile = { }, asyncExecution: { value: - 'Yes: a workflow can be triggered in fire-and-forget async mode, returning HTTP 202 with a job ID immediately, then polled via a dedicated jobs endpoint through queued/processing/completed/failed states', + 'Yes: a workflow can be triggered in fire-and-forget async mode, returning HTTP 202 with an execution ID immediately, then polled through the canonical execution resource across queued/running/terminal states', detail: - 'Async jobs are tracked via polling the job endpoint rather than a completion webhook/callback option.', - shortValue: 'Async mode: job ID returned immediately, poll for result', + 'Async runs are tracked by execution ID through the same execution status endpoint used for durable logs rather than a separate queue-job resource.', + shortValue: 'Async mode: execution ID returned immediately, poll for result', confidence: 'verified', sources: [ { @@ -1098,8 +1098,8 @@ export const simProfile: CompetitorProfile = { asOf: '2026-07-02', }, { - url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/app/api/jobs/[jobId]/route.ts', - label: 'Sim codebase: async job status endpoint', + url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts', + label: 'Sim codebase: execution status endpoint', asOf: '2026-07-02', }, ], diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index a1db42f3e4c..cb97bedf8e8 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -31,7 +31,21 @@ import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../para import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { - return `${baseUrl}/api/workflows/${workflowId}/execute` + return `${baseUrl}/api/v2/workflows/${workflowId}/execute` +} + +function buildWorkflowExecutionStatusEndpoint( + baseUrl: string, + apiEndpoint: string, + executionId: string +): string { + if ( + !apiEndpoint.startsWith(`${baseUrl}/api/v2/workflows/`) || + !apiEndpoint.endsWith('/execute') + ) { + throw new Error(`Invalid workflow execution endpoint: ${apiEndpoint}`) + } + return `${apiEndpoint.slice(0, -'/execute'.length)}/executions/${executionId}` } function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { @@ -58,9 +72,12 @@ function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { method: 'POST', transport: 'json', stream: false, - headers: { 'X-Execution-Mode': 'async' }, - body: { input: { key: 'value' } }, - jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`, + body: { async: true, input: { key: 'value' } }, + executionStatusEndpointTemplate: buildWorkflowExecutionStatusEndpoint( + baseUrl, + apiEndpoint, + '{executionId}' + ), }, }, } @@ -79,9 +96,8 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { async: `curl -X POST "${apiEndpoint}" \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ - -H "X-Execution-Mode: async" \\ - -d '{"input":{"key":"value"}}'`, - poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\ + -d '{"async":true,"input":{"key":"value"}}'`, + poll: `curl "${buildWorkflowExecutionStatusEndpoint(baseUrl, apiEndpoint, 'EXECUTION_ID')}" \\ -H "X-API-Key: YOUR_API_KEY"`, } } diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts index 00642c49321..7e3577dab0a 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts @@ -3,22 +3,25 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { MockApiError, mockResolveTriggerRegion, mockTrigger } = vi.hoisted(() => { - class MockApiError extends Error { - constructor( - readonly status: number | undefined, - message: string - ) { - super(message) +const { MockApiError, mockListRuns, mockResolveTriggerRegion, mockRetrieveRun, mockTrigger } = + vi.hoisted(() => { + class MockApiError extends Error { + constructor( + readonly status: number | undefined, + message: string + ) { + super(message) + } } - } - return { - MockApiError, - mockResolveTriggerRegion: vi.fn(), - mockTrigger: vi.fn(), - } -}) + return { + MockApiError, + mockListRuns: vi.fn(), + mockResolveTriggerRegion: vi.fn(), + mockRetrieveRun: vi.fn(), + mockTrigger: vi.fn(), + } + }) vi.mock('@trigger.dev/core/v3', () => ({ taskContext: { isInsideTask: false }, @@ -28,7 +31,8 @@ vi.mock('@trigger.dev/sdk', () => ({ ApiError: MockApiError, runs: { cancel: vi.fn(), - retrieve: vi.fn(), + list: mockListRuns, + retrieve: mockRetrieveRun, }, tasks: { batchTriggerAndWait: vi.fn(), @@ -63,6 +67,7 @@ describe('TriggerDevJobQueue enqueue', () => { expect.objectContaining({ idempotencyKey: 'workflow:1', idempotencyKeyTTL: '14d', + tags: ['jobId:workflow:1'], }) ) }) @@ -113,3 +118,44 @@ describe('TriggerDevJobQueue enqueue', () => { expect(mockTrigger).not.toHaveBeenCalled() }) }) + +describe('TriggerDevJobQueue getJob', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a deterministic job ID through its Trigger.dev tag', async () => { + mockRetrieveRun + .mockRejectedValueOnce(new MockApiError(404, 'run not found')) + .mockResolvedValueOnce({ + id: 'run-1', + taskIdentifier: 'workflow-execution', + payload: { workflowId: 'workflow-1' }, + status: 'COMPLETED', + createdAt: '2026-08-05T12:00:00.000Z', + finishedAt: '2026-08-05T12:00:05.000Z', + attemptCount: 1, + output: { output: { answer: 42 } }, + }) + mockListRuns.mockReturnValueOnce( + (async function* () { + yield { id: 'run-1' } + })() + ) + const queue = new TriggerDevJobQueue() + + const job = await queue.getJob('workflow-execution:execution-1') + + expect(mockListRuns).toHaveBeenCalledWith({ + tag: 'jobId:workflow-execution:execution-1', + limit: 1, + }) + expect(mockRetrieveRun).toHaveBeenNthCalledWith(2, 'run-1') + expect(job).toMatchObject({ + id: 'workflow-execution:execution-1', + status: 'completed', + output: { output: { answer: 42 } }, + metadata: { workflowId: 'workflow-1' }, + }) + }) +}) diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 12f9f15bc88..4059f3066dc 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -189,7 +189,26 @@ export class TriggerDevJobQueue implements JobQueueBackend { async getJob(jobId: string): Promise { try { - const run = await runs.retrieve(jobId) + let run: Awaited> + try { + run = await runs.retrieve(jobId) + } catch (error) { + const isNotFound = + (error instanceof Error && error.message.toLowerCase().includes('not found')) || + (error && typeof error === 'object' && 'status' in error && error.status === 404) + if (!isNotFound) throw error + + let runId: string | undefined + for await (const candidate of runs.list({ tag: `jobId:${jobId}`, limit: 1 })) { + runId = candidate.id + break + } + if (!runId) { + logger.debug('Job not found in trigger.dev', { jobId }) + return null + } + run = await runs.retrieve(runId) + } const payload = run.payload as Record const metadata: JobMetadata = { @@ -270,6 +289,7 @@ function buildTags(options?: EnqueueOptions): string[] { const tags: string[] = [] const meta = options?.metadata + if (options?.jobId) tags.push(`jobId:${options.jobId}`) if (meta?.workspaceId) tags.push(`workspaceId:${meta.workspaceId}`) if (meta?.workflowId) tags.push(`workflowId:${meta.workflowId}`) if (meta?.userId) tags.push(`userId:${meta.userId}`) diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts index 949c98be1dc..4e8992bafd4 100644 --- a/apps/sim/lib/workflows/executor/enqueue-execution.ts +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -11,6 +11,7 @@ const logger = createLogger('WorkflowEnqueueExecution') const ASYNC_ENQUEUE_ATTEMPTS = 2 export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' +export const RESUME_EXECUTION_JOB_ID_PREFIX = 'resume-execution:' export interface EnqueueWorkflowExecutionParams { requestId: string diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts new file mode 100644 index 00000000000..dfbc0719224 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -0,0 +1,222 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetJob } = vi.hoisted(() => ({ + mockGetJob: vi.fn(), +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), +})) + +vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ + RESUME_EXECUTION_JOB_ID_PREFIX: 'resume-execution:', + WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', +})) + +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' + +const input = { + workflowId: 'workflow-1', + executionId: 'execution-1', + includeOutput: false, + selectedOutputs: [], +} + +describe('getWorkflowExecutionStatus queue projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('projects a queued workflow job as an execution resource', async () => { + mockGetJob.mockResolvedValue({ + status: 'pending', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + metadata: { + workflowId: 'workflow-1', + correlation: { triggerType: 'api' }, + }, + }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'queued', + trigger: 'api', + startedAt: '2026-08-05T12:00:00.000Z', + endedAt: null, + error: null, + }) + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:execution-1') + }) + + it('uses the resume entry ID when the queued work is a resume attempt', async () => { + queueTableRows(schemaMock.resumeQueue, [{ id: 'resume-entry-1', status: 'claimed' }]) + mockGetJob.mockResolvedValueOnce({ + status: 'processing', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + startedAt: new Date('2026-08-05T12:00:01.000Z'), + metadata: { workflowId: 'workflow-1' }, + }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'running', + startedAt: '2026-08-05T12:00:01.000Z', + }) + expect(mockGetJob).toHaveBeenCalledWith('resume-execution:resume-entry-1') + }) + + it('projects an active resume ahead of the existing paused log', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'paused', + }, + ]) + queueTableRows(schemaMock.resumeQueue, [{ id: 'resume-entry-1', status: 'claimed' }]) + mockGetJob.mockResolvedValueOnce({ + status: 'pending', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + metadata: { workflowId: 'workflow-1' }, + }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'queued', + paused: null, + }) + }) + + it('keeps an active resume queued while its background job is not yet visible', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + }, + ]) + queueTableRows(schemaMock.resumeQueue, [ + { + id: 'resume-entry-1', + status: 'claimed', + queuedAt: new Date('2026-08-05T12:00:00.000Z'), + claimedAt: new Date('2026-08-05T12:00:01.000Z'), + }, + ]) + mockGetJob.mockResolvedValueOnce(null) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'queued', + trigger: 'api', + startedAt: '2026-08-05T12:00:01.000Z', + paused: null, + }) + }) + + it('projects a pending serialized resume as queued', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + }, + ]) + queueTableRows(schemaMock.resumeQueue, [ + { + id: 'resume-entry-2', + status: 'pending', + queuedAt: new Date('2026-08-05T12:00:02.000Z'), + claimedAt: null, + }, + ]) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'queued', + startedAt: '2026-08-05T12:00:02.000Z', + paused: null, + }) + expect(mockGetJob).not.toHaveBeenCalled() + }) + + it('does not let an orphaned pending resume mask a terminal log', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + level: 'info', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + executionData: null, + costTotal: null, + }, + ]) + queueTableRows(schemaMock.resumeQueue, [ + { + id: 'resume-entry-2', + status: 'pending', + queuedAt: new Date('2026-08-05T12:00:02.000Z'), + claimedAt: null, + }, + ]) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'completed', + }) + expect(mockGetJob).not.toHaveBeenCalled() + }) + + it('returns completed queue output when requested', async () => { + mockGetJob.mockResolvedValueOnce({ + status: 'completed', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + completedAt: new Date('2026-08-05T12:00:05.000Z'), + output: { output: { answer: 42 } }, + metadata: { workflowId: 'workflow-1' }, + }) + + const status = await getWorkflowExecutionStatus({ ...input, includeOutput: true }) + + expect(status).toMatchObject({ + status: 'completed', + finalOutput: { answer: 42 }, + }) + }) + + it('does not expose a queue record belonging to another workflow', async () => { + mockGetJob.mockResolvedValueOnce({ + status: 'pending', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + metadata: { workflowId: 'workflow-2' }, + }) + + await expect(getWorkflowExecutionStatus(input)).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index d608dd3d825..f90ef18e1c6 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -1,12 +1,18 @@ import { db } from '@sim/db' -import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq, inArray, sql } from 'drizzle-orm' import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' +import { getJobQueue } from '@/lib/core/async-jobs' +import type { Job } from '@/lib/core/async-jobs/types' import { collectFunctionalBlockOutputs, type FunctionalExecutionDataSource, } from '@/lib/logs/execution/functional-outputs' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { + RESUME_EXECUTION_JOB_ID_PREFIX, + WORKFLOW_EXECUTION_JOB_ID_PREFIX, +} from '@/lib/workflows/executor/enqueue-execution' import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' import type { PausePoint } from '@/executor/types' @@ -14,7 +20,9 @@ import type { PausePoint } from '@/executor/types' * Reads a single execution's status resource — the log row, the paused-state * overlay, and (when requested) materialized outputs. Extracted so the v1 and * v2 status routes render the identical resource from one read path. - * Auth is the caller's responsibility. Returns `null` when no log row exists. + * Auth is the caller's responsibility. Before a worker writes the durable log + * row, the deterministic queue record is projected as the same execution + * resource so callers never need a separate job identifier or endpoint. */ type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' @@ -79,6 +87,38 @@ function extractError(executionData: unknown): string | null { return null } +function extractJobFinalOutput(output: unknown): unknown | null { + if (!output || typeof output !== 'object' || !('output' in output)) return null + return (output as Record).output ?? null +} + +function projectQueueJob( + job: Job, + input: Pick +): WorkflowExecutionStatusResponse { + const status: WorkflowExecutionStatusResponse['status'] = + job.status === 'pending' ? 'queued' : job.status === 'processing' ? 'running' : job.status + const startedAt = job.startedAt ?? job.createdAt + const endedAt = job.completedAt ?? null + + return { + executionId: input.executionId, + workflowId: input.workflowId, + status, + trigger: job.metadata.correlation?.triggerType ?? 'api', + level: status === 'failed' ? 'error' : 'info', + startedAt: startedAt.toISOString(), + endedAt: endedAt?.toISOString() ?? null, + totalDurationMs: endedAt ? endedAt.getTime() - startedAt.getTime() : null, + paused: null, + cost: null, + error: status === 'failed' ? (job.error ?? 'Execution failed') : null, + finalOutput: + input.includeOutput && status === 'completed' ? extractJobFinalOutput(job.output) : null, + blockOutputs: null, + } +} + export interface GetWorkflowExecutionStatusInput { workflowId: string executionId: string @@ -114,6 +154,63 @@ export async function getWorkflowExecutionStatus( ) .limit(1) + const [activeResume] = await db + .select({ + id: resumeQueue.id, + status: resumeQueue.status, + queuedAt: resumeQueue.queuedAt, + claimedAt: resumeQueue.claimedAt, + }) + .from(resumeQueue) + .where( + and( + eq(resumeQueue.parentExecutionId, executionId), + eq(resumeQueue.newExecutionId, executionId), + inArray(resumeQueue.status, ['pending', 'claimed'] as const) + ) + ) + .orderBy(sql`case when ${resumeQueue.status} = 'claimed' then 0 else 1 end`) + .limit(1) + + const hasTerminalLog = + logRow?.status === 'completed' || logRow?.status === 'failed' || logRow?.status === 'cancelled' + const projectedResume = hasTerminalLog ? undefined : activeResume + + const queueJobIds = [ + ...(projectedResume?.status === 'claimed' + ? [`${RESUME_EXECUTION_JOB_ID_PREFIX}${projectedResume.id}`] + : []), + ...(!logRow ? [`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`] : []), + ] + + if (queueJobIds.length > 0) { + const jobQueue = await getJobQueue() + for (const jobId of queueJobIds) { + const job = await jobQueue.getJob(jobId) + if (!job || job.metadata.workflowId !== workflowId) continue + return projectQueueJob(job, { executionId, includeOutput, workflowId }) + } + } + + if (projectedResume) { + const startedAt = projectedResume.claimedAt ?? projectedResume.queuedAt + return { + executionId, + workflowId, + status: 'queued', + trigger: logRow?.trigger ?? 'api', + level: 'info', + startedAt: startedAt.toISOString(), + endedAt: null, + totalDurationMs: null, + paused: null, + cost: null, + error: null, + finalOutput: null, + blockOutputs: null, + } + } + if (!logRow) return null const [pausedRow] = await db diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 2690f635a17..390649be1cf 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -48,10 +48,10 @@ SimStudioClient(api_key: str, base_url: str = "https://sim.ai") Execute a workflow with optional input data. ```python -# With dict input (spread at root level of request body) +# With dict input (sent as the v2 input object) result = client.execute_workflow("workflow-id", {"message": "Hello, world!"}) -# With primitive input (wrapped as { input: value }) +# With primitive input (sent as { input: { input: value } }) result = client.execute_workflow("workflow-id", "NVDA") # With options (keyword-only arguments) @@ -60,7 +60,7 @@ result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60 **Parameters:** - `workflow_id` (str): The ID of the workflow to execute -- `input` (any, optional): Input data to pass to the workflow. Dicts are spread at the root level, primitives/lists are wrapped in `{ input: value }`. File objects are automatically converted to base64. +- `input` (any, optional): Input data to pass to the workflow. Dicts become the v2 `input` object; primitives and lists become `{ input: value }` inside it. File objects are automatically converted to base64. - `timeout` (float, keyword-only): Timeout in seconds (default: 30.0) - `stream` (bool, keyword-only): Enable streaming responses - `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`) @@ -115,17 +115,35 @@ result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, tim **Returns:** `WorkflowExecutionResult` -##### get_job_status(job_id) +##### get_workflow_execution(workflow_id, execution_id, *, include_output=None, selected_outputs=None) -Get the status of an async job. +Get the status and optional outputs of a workflow execution. Use the execution ID returned by async execution. ```python -status = client.get_job_status("job-id-from-async-execution") -print("Job status:", status) +status = client.get_workflow_execution( + "workflow-id", + "execution-id", + include_output=True, + selected_outputs=["agent.content"] +) +print("Execution status:", status["status"]) ``` **Parameters:** -- `job_id` (str): The job ID returned from async execution +- `workflow_id` (str): The workflow ID +- `execution_id` (str): The execution ID returned from async execution +- `include_output` (bool, keyword-only): Include the final output for completed executions +- `selected_outputs` (list, keyword-only): Block output selectors to include + +**Returns:** `dict` + +##### get_job_status(job_id) + +Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_execution()` with an execution ID. + +```python +status = client.get_job_status("legacy-job-id") +``` **Returns:** `dict` @@ -248,9 +266,8 @@ class SimStudioError(Exception): @dataclass class AsyncExecutionResult: success: bool - job_id: str + execution_id: str status_url: str - execution_id: Optional[str] = None message: str = "" async_execution: bool = True ``` @@ -527,4 +544,4 @@ isort simstudio/ ## License -Apache-2.0 \ No newline at end of file +Apache-2.0 diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py index 0e2609e2f26..e930e2467ba 100644 --- a/packages/python-sdk/simstudio/__init__.py +++ b/packages/python-sdk/simstudio/__init__.py @@ -49,9 +49,8 @@ class WorkflowStatus: class AsyncExecutionResult: """Result of an async workflow execution.""" success: bool - job_id: str + execution_id: str status_url: str - execution_id: Optional[str] = None message: str = "" async_execution: bool = True @@ -159,7 +158,7 @@ def execute_workflow( ) -> Union[WorkflowExecutionResult, AsyncExecutionResult]: """ Execute a workflow with optional input data. - If async_execution is True, returns immediately with a task ID. + If async_execution is True, returns immediately with an execution ID. File objects in input will be automatically detected and converted to base64. @@ -179,31 +178,26 @@ def execute_workflow( Raises: SimStudioError: If the workflow execution fails """ - url = f"{self.base_url}/api/workflows/{workflow_id}/execute" - - # Build headers - async execution uses X-Execution-Mode header + url = f"{self.base_url}/api/v2/workflows/{workflow_id}/execute" headers = self._session.headers.copy() - if async_execution: - headers['X-Execution-Mode'] = 'async' try: - # Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field - body = {} + workflow_input = {} if input is not None: if isinstance(input, dict): - # Dict input: spread at root level (matches curl/API behavior) - body = input.copy() + workflow_input = input.copy() else: - # Primitive or list input: wrap in 'input' field - body = {'input': input} + workflow_input = {'input': input} - # Convert any file objects in the input to base64 format - body = self._convert_files_to_base64(body) + workflow_input = self._convert_files_to_base64(workflow_input) + body = {'input': workflow_input} if stream is not None: body['stream'] = stream if selected_outputs is not None: body['selectedOutputs'] = selected_outputs + if async_execution is not None: + body['async'] = async_execution response = self._session.post( url, @@ -227,35 +221,41 @@ def execute_workflow( if not response.ok: try: error_data = response.json() - error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}') - error_code = error_data.get('code') + error = error_data.get('error', {}) + error_message = error.get('message', f'HTTP {response.status_code}: {response.reason}') + error_code = error.get('code') except (ValueError, KeyError): error_message = f'HTTP {response.status_code}: {response.reason}' error_code = None raise SimStudioError(error_message, error_code, response.status_code) - result_data = response.json() + result = response.json() + if 'data' not in result: + raise SimStudioError('Invalid v2 workflow execution response', 'EXECUTION_ERROR') + result_data = result['data'] - # Check if this is an async execution response (202 status) - if response.status_code == 202 and 'jobId' in result_data: + if response.status_code == 202: + if 'executionId' not in result_data or 'statusUrl' not in result_data: + raise SimStudioError('Invalid v2 async execution response', 'EXECUTION_ERROR') return AsyncExecutionResult( - success=result_data.get('success', True), - job_id=result_data['jobId'], + success=True, + execution_id=result_data['executionId'], status_url=result_data['statusUrl'], - execution_id=result_data.get('executionId'), - message=result_data.get('message', ''), - async_execution=result_data.get('async', True) + message='Workflow execution queued', + async_execution=True ) + execution_error = result_data.get('error') return WorkflowExecutionResult( - success=result_data['success'], + success=result_data.get('status') != 'failed', output=result_data.get('output'), - error=result_data.get('error'), - logs=result_data.get('logs'), - metadata=result_data.get('metadata'), - trace_spans=result_data.get('traceSpans'), - total_duration=result_data.get('totalDuration') + error=execution_error.get('message') if execution_error else None, + metadata={ + 'duration': result_data.get('durationMs'), + 'executionId': result_data['executionId'] + }, + total_duration=result_data.get('durationMs') ) except requests.Timeout: @@ -378,10 +378,10 @@ def close(self) -> None: def get_job_status(self, job_id: str) -> Dict[str, Any]: """ - Get the status of an async job. + Get the status of a legacy async job. Args: - job_id: The job ID returned from async execution + job_id: The job ID returned from legacy async execution Returns: Dictionary containing the job status @@ -412,6 +412,61 @@ def get_job_status(self, job_id: str) -> Dict[str, Any]: except requests.RequestException as e: raise SimStudioError(f'Failed to get job status: {str(e)}', 'STATUS_ERROR') + def get_workflow_execution( + self, + workflow_id: str, + execution_id: str, + *, + include_output: Optional[bool] = None, + selected_outputs: Optional[list] = None + ) -> Dict[str, Any]: + """ + Get a workflow execution's current status and optional outputs from the v2 API. + + Args: + workflow_id: The workflow ID + execution_id: The execution ID returned from async execution + include_output: Include the final output for completed executions + selected_outputs: Block output selectors to include + + Returns: + Dictionary containing the execution status + + Raises: + SimStudioError: If getting the status fails + """ + url = f"{self.base_url}/api/v2/workflows/{workflow_id}/executions/{execution_id}" + params = {} + if include_output is not None: + params['includeOutput'] = str(include_output).lower() + if selected_outputs: + params['selectedOutputs'] = ','.join(selected_outputs) + + try: + response = self._session.get(url, params=params or None) + + self._update_rate_limit_info(response) + + if not response.ok: + try: + error_data = response.json() + error = error_data.get('error', {}) + error_message = error.get('message', f'HTTP {response.status_code}: {response.reason}') + error_code = error.get('code') + except (ValueError, KeyError): + error_message = f'HTTP {response.status_code}: {response.reason}' + error_code = None + + raise SimStudioError(error_message, error_code, response.status_code) + + result = response.json() + if 'data' not in result: + raise SimStudioError('Invalid v2 workflow execution response', 'STATUS_ERROR') + return result['data'] + + except requests.RequestException as e: + raise SimStudioError(f'Failed to get workflow execution: {str(e)}', 'STATUS_ERROR') + def execute_with_retry( self, workflow_id: str, @@ -565,4 +620,4 @@ def __exit__(self, exc_type, exc_val, exc_tb): # For backward compatibility -Client = SimStudioClient \ No newline at end of file +Client = SimStudioClient diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index 814ad7610ef..8473758198b 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -7,6 +7,19 @@ from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus +def v2_execution_response(output=None): + return { + "data": { + "executionId": "execution-123", + "workflowId": "workflow-id", + "status": "completed", + "output": {} if output is None else output, + "error": None, + "durationMs": 10 + } + } + + def test_simstudio_client_initialization(): """Test SimStudioClient initialization.""" client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai") @@ -95,18 +108,16 @@ def test_context_manager(mock_close): @patch('simstudio.requests.Session.post') -def test_async_execution_returns_job_id(mock_post): +def test_async_execution_returns_execution_id(mock_post): """Test async execution returns AsyncExecutionResult.""" mock_response = Mock() mock_response.ok = True mock_response.status_code = 202 mock_response.json.return_value = { - "success": True, - "jobId": "job-123", - "statusUrl": "https://test.sim.ai/api/jobs/job-123", - "executionId": "execution-123", - "message": "Workflow execution started", - "async": True + "data": { + "executionId": "execution-123", + "statusUrl": "https://sim.ai/api/v2/workflows/workflow-id/executions/execution-123" + } } mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -119,13 +130,17 @@ def test_async_execution_returns_job_id(mock_post): ) assert result.success is True - assert result.job_id == "job-123" - assert result.status_url == "https://test.sim.ai/api/jobs/job-123" assert result.execution_id == "execution-123" + assert result.status_url == "https://sim.ai/api/v2/workflows/workflow-id/executions/execution-123" assert result.async_execution is True call_args = mock_post.call_args - assert call_args[1]["headers"]["X-Execution-Mode"] == "async" + assert call_args.args[0] == "https://sim.ai/api/v2/workflows/workflow-id/execute" + assert "X-Execution-Mode" not in call_args.kwargs["headers"] + assert call_args.kwargs["json"] == { + "input": {"message": "Hello"}, + "async": True + } @patch('simstudio.requests.Session.post') @@ -134,11 +149,7 @@ def test_sync_execution_returns_result(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = { - "success": True, - "output": {"result": "completed"}, - "logs": [] - } + mock_response.json.return_value = v2_execution_response({"result": "completed"}) mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -160,7 +171,7 @@ def test_async_header_not_set_when_false(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -173,18 +184,14 @@ def test_async_header_not_set_when_false(mock_post): @patch('simstudio.requests.Session.get') def test_get_job_status_success(mock_get): - """Test getting job status.""" + """Test getting legacy job status.""" mock_response = Mock() mock_response.ok = True mock_response.json.return_value = { "success": True, "taskId": "task-123", "status": "completed", - "metadata": { - "startedAt": "2024-01-01T00:00:00Z", - "completedAt": "2024-01-01T00:01:00Z", - "duration": 60000 - }, + "metadata": {"duration": 60000}, "output": {"result": "done"} } mock_response.headers.get.return_value = None @@ -201,7 +208,7 @@ def test_get_job_status_success(mock_get): @patch('simstudio.requests.Session.get') def test_get_job_status_not_found(mock_get): - """Test job not found error.""" + """Test legacy job not found error.""" mock_response = Mock() mock_response.ok = False mock_response.status_code = 404 @@ -220,6 +227,60 @@ def test_get_job_status_not_found(mock_get): assert "Job not found" in str(exc_info.value) +@patch('simstudio.requests.Session.get') +def test_get_workflow_execution_success(mock_get): + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = { + "data": { + "executionId": "execution-123", + "workflowId": "workflow-123", + "status": "completed", + "output": {"result": "done"} + } + } + mock_response.headers.get.return_value = None + mock_get.return_value = mock_response + + client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai") + result = client.get_workflow_execution( + "workflow-123", + "execution-123", + include_output=True, + selected_outputs=["agent.content"] + ) + + assert result["executionId"] == "execution-123" + assert result["status"] == "completed" + assert result["output"]["result"] == "done" + mock_get.assert_called_once_with( + "https://test.sim.ai/api/v2/workflows/workflow-123/executions/execution-123", + params={"includeOutput": "true", "selectedOutputs": "agent.content"} + ) + + +@patch('simstudio.requests.Session.get') +def test_get_workflow_execution_not_found(mock_get): + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 404 + mock_response.reason = "Not Found" + mock_response.json.return_value = { + "error": { + "code": "NOT_FOUND", + "message": "Execution not found" + } + } + mock_response.headers.get.return_value = None + mock_get.return_value = mock_response + + client = SimStudioClient(api_key="test-api-key") + + with pytest.raises(SimStudioError) as exc_info: + client.get_workflow_execution("workflow-123", "invalid-execution") + assert "Execution not found" in str(exc_info.value) + + @patch('simstudio.requests.Session.post') @patch('simstudio.time.sleep') def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post): @@ -227,10 +288,7 @@ def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = { - "success": True, - "output": {"result": "success"} - } + mock_response.json.return_value = v2_execution_response({"result": "success"}) mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -264,10 +322,7 @@ def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post): success_response = Mock() success_response.ok = True success_response.status_code = 200 - success_response.json.return_value = { - "success": True, - "output": {"result": "success"} - } + success_response.json.return_value = v2_execution_response({"result": "success"}) success_response.headers.get.return_value = None mock_post.side_effect = [rate_limit_response, success_response] @@ -321,8 +376,10 @@ def test_execute_with_retry_no_retry_on_other_errors(mock_post): mock_response.status_code = 500 mock_response.reason = "Internal Server Error" mock_response.json.return_value = { - "error": "Server error", - "code": "INTERNAL_ERROR" + "error": { + "code": "INTERNAL_ERROR", + "message": "Server error" + } } mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -349,7 +406,7 @@ def test_get_rate_limit_info_after_api_call(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.side_effect = lambda h: { 'x-ratelimit-limit': '100', 'x-ratelimit-remaining': '95', @@ -436,7 +493,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -451,7 +508,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["message"] == "test" + assert request_body["input"] == {"message": "test"} assert request_body["stream"] is True assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"] @@ -463,7 +520,7 @@ def test_execute_workflow_with_string_input(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -473,7 +530,7 @@ def test_execute_workflow_with_string_input(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["input"] == "NVDA" + assert request_body["input"] == {"input": "NVDA"} assert "0" not in request_body # Should not spread string characters @@ -483,7 +540,7 @@ def test_execute_workflow_with_number_input(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -493,7 +550,7 @@ def test_execute_workflow_with_number_input(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["input"] == 42 + assert request_body["input"] == {"input": 42} @patch('simstudio.requests.Session.post') @@ -502,7 +559,7 @@ def test_execute_workflow_with_list_input(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -512,17 +569,16 @@ def test_execute_workflow_with_list_input(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["input"] == ["NVDA", "AAPL", "GOOG"] + assert request_body["input"] == {"input": ["NVDA", "AAPL", "GOOG"]} assert "0" not in request_body # Should not spread list @patch('simstudio.requests.Session.post') -def test_execute_workflow_with_dict_input_spreads_at_root(mock_post): - """Test execution with dict input spreads at root level.""" +def test_execute_workflow_with_dict_input_uses_v2_input_field(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -532,6 +588,4 @@ def test_execute_workflow_with_dict_input_spreads_at_root(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["ticker"] == "NVDA" - assert request_body["quantity"] == 100 - assert "input" not in request_body # Should not wrap in input field \ No newline at end of file + assert request_body["input"] == {"ticker": "NVDA", "quantity": 100} diff --git a/packages/ts-sdk/README.md b/packages/ts-sdk/README.md index 0ce547f6e51..2e2831ea93b 100644 --- a/packages/ts-sdk/README.md +++ b/packages/ts-sdk/README.md @@ -52,12 +52,12 @@ new SimStudioClient(config: SimStudioConfig) Execute a workflow with optional input data. ```typescript -// With object input (spread at root level of request body) +// With object input (sent as the v2 input object) const result = await client.executeWorkflow('workflow-id', { message: 'Hello, world!' }); -// With primitive input (wrapped as { input: value }) +// With primitive input (sent as { input: { input: value } }) const result = await client.executeWorkflow('workflow-id', 'NVDA'); // With options @@ -68,7 +68,7 @@ const result = await client.executeWorkflow('workflow-id', { message: 'Hello' }, **Parameters:** - `workflowId` (string): The ID of the workflow to execute -- `input` (any, optional): Input data to pass to the workflow. Objects are spread at the root level, primitives/arrays are wrapped in `{ input: value }`. File objects are automatically converted to base64. +- `input` (any, optional): Input data to pass to the workflow. Objects become the v2 `input` object; primitives and arrays become `{ input: value }` inside it. File objects are automatically converted to base64. - `options` (ExecutionOptions, optional): - `timeout` (number): Timeout in milliseconds (default: 30000) - `stream` (boolean): Enable streaming responses @@ -125,19 +125,35 @@ const result = await client.executeWorkflowSync('workflow-id', { data: 'some inp **Returns:** `Promise` -##### getJobStatus(jobId) +##### getWorkflowExecution(workflowId, executionId, options?) -Get the status of an async job. +Get the status and optional outputs of a workflow execution. Use the `executionId` returned by async execution. ```typescript -const status = await client.getJobStatus('job-id-from-async-execution'); -console.log('Job status:', status); +const status = await client.getWorkflowExecution('workflow-id', 'execution-id', { + includeOutput: true, + selectedOutputs: ['agent.content'] +}); +console.log('Execution status:', status.status); ``` **Parameters:** -- `jobId` (string): The job ID returned from async execution +- `workflowId` (string): The workflow ID +- `executionId` (string): The execution ID returned from async execution +- `options.includeOutput` (boolean, optional): Include the final output for completed executions +- `options.selectedOutputs` (string[], optional): Block output selectors to include + +**Returns:** `Promise` + +##### getJobStatus(jobId) -**Returns:** `Promise` +Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowExecution()` with an execution ID. + +```typescript +const status = await client.getJobStatus('legacy-job-id'); +``` + +**Returns:** `Promise` ##### executeWithRetry(workflowId, input?, options?, retryOptions?) @@ -228,7 +244,7 @@ interface WorkflowExecutionResult { ### LargeValueRef -Oversized execution values may be returned as a versioned reference inside `output`, `logs`, streaming events, or async job status responses. +Oversized execution values may be returned as a versioned reference inside `output`, `logs`, streaming events, or execution status responses. The `key` field is an opaque execution-scoped server storage pointer, not a client-readable download URL. ```typescript @@ -268,9 +284,8 @@ class SimStudioError extends Error { ```typescript interface AsyncExecutionResult { success: boolean; - jobId: string; + executionId: string; statusUrl: string; - executionId?: string; message: string; async: true; } @@ -533,4 +548,4 @@ bun run dev ## License -Apache-2.0 \ No newline at end of file +Apache-2.0 diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts index c5066442f99..95137c9c23e 100644 --- a/packages/ts-sdk/src/index.test.ts +++ b/packages/ts-sdk/src/index.test.ts @@ -4,6 +4,19 @@ import { SimStudioClient, SimStudioError } from './index' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) +function v2ExecutionResponse(output: unknown = {}) { + return { + data: { + executionId: 'execution-123', + workflowId: 'workflow-id', + status: 'completed', + output, + error: null, + durationMs: 10, + }, + } +} + describe('SimStudioClient', () => { let client: SimStudioClient @@ -100,11 +113,10 @@ describe('SimStudioClient', () => { ok: true, status: 202, json: vi.fn().mockResolvedValue({ - success: true, - jobId: 'job-123', - statusUrl: 'https://test.sim.ai/api/jobs/job-123', - message: 'Workflow execution queued', - async: true, + data: { + executionId: 'execution-123', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123', + }, }), headers: { get: vi.fn().mockReturnValue(null), @@ -118,14 +130,19 @@ describe('SimStudioClient', () => { { async: true } ) - expect(result).toHaveProperty('jobId', 'job-123') - expect(result).toHaveProperty('statusUrl', 'https://test.sim.ai/api/jobs/job-123') + expect(result).toHaveProperty('executionId', 'execution-123') + expect(result).toHaveProperty( + 'statusUrl', + 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123' + ) expect(result).toHaveProperty('async', true) - // Verify headers were set correctly const calls = vi.mocked(mockFetch).mock.calls - expect(calls[0][1]?.headers).toMatchObject({ - 'X-Execution-Mode': 'async', + expect(calls[0][0]).toBe('https://test.sim.ai/api/v2/workflows/workflow-id/execute') + expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode') + expect(JSON.parse(calls[0][1]?.body as string)).toEqual({ + input: { message: 'Hello' }, + async: true, }) }) @@ -133,11 +150,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'completed' }, - logs: [], - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'completed' })), headers: { get: vi.fn().mockReturnValue(null), }, @@ -159,10 +172,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -177,18 +187,14 @@ describe('SimStudioClient', () => { }) describe('getJobStatus', () => { - it('should fetch job status with correct endpoint', async () => { + it('should fetch legacy job status with the correct endpoint', async () => { const mockResponse = { ok: true, json: vi.fn().mockResolvedValue({ success: true, taskId: 'task-123', status: 'completed', - metadata: { - startedAt: '2024-01-01T00:00:00Z', - completedAt: '2024-01-01T00:01:00Z', - duration: 60000, - }, + metadata: { duration: 60000 }, output: { result: 'done' }, }), headers: { @@ -202,13 +208,10 @@ describe('SimStudioClient', () => { expect(result).toHaveProperty('taskId', 'task-123') expect(result).toHaveProperty('status', 'completed') expect(result).toHaveProperty('output') - - // Verify correct endpoint was called - const calls = vi.mocked(mockFetch).mock.calls - expect(calls[0][0]).toBe('https://test.sim.ai/api/jobs/task-123') + expect(vi.mocked(mockFetch).mock.calls[0][0]).toBe('https://test.sim.ai/api/jobs/task-123') }) - it('should handle job not found error', async () => { + it('should handle legacy job not found errors', async () => { const mockResponse = { ok: false, status: 404, @@ -223,20 +226,75 @@ describe('SimStudioClient', () => { } vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) - await expect(client.getJobStatus('invalid-task')).rejects.toThrow(SimStudioError) await expect(client.getJobStatus('invalid-task')).rejects.toThrow('Job not found') }) }) + describe('getWorkflowExecution', () => { + it('should fetch execution status and outputs from the v2 execution resource', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: { + executionId: 'execution-123', + workflowId: 'workflow-123', + status: 'completed', + output: { result: 'done' }, + }, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) + + const result = await client.getWorkflowExecution('workflow-123', 'execution-123', { + includeOutput: true, + selectedOutputs: ['agent.content'], + }) + + expect(result).toHaveProperty('executionId', 'execution-123') + expect(result).toHaveProperty('status', 'completed') + expect(result).toHaveProperty('output') + + const calls = vi.mocked(mockFetch).mock.calls + expect(calls[0][0]).toBe( + 'https://test.sim.ai/api/v2/workflows/workflow-123/executions/execution-123?includeOutput=true&selectedOutputs=agent.content' + ) + }) + + it('should handle execution not found errors', async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: 'Not Found', + json: vi.fn().mockResolvedValue({ + error: { + code: 'NOT_FOUND', + message: 'Execution not found', + }, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) + + await expect( + client.getWorkflowExecution('workflow-123', 'invalid-execution') + ).rejects.toThrow(SimStudioError) + await expect( + client.getWorkflowExecution('workflow-123', 'invalid-execution') + ).rejects.toThrow('Execution not found') + }) + }) + describe('executeWithRetry', () => { it('should succeed on first attempt when no rate limit', async () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'success' }, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'success' })), headers: { get: vi.fn().mockReturnValue(null), }, @@ -273,10 +331,7 @@ describe('SimStudioClient', () => { const successResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'success' }, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'success' })), headers: { get: vi.fn().mockReturnValue(null), }, @@ -334,8 +389,10 @@ describe('SimStudioClient', () => { status: 500, statusText: 'Internal Server Error', json: vi.fn().mockResolvedValue({ - error: 'Server error', - code: 'INTERNAL_ERROR', + error: { + code: 'INTERNAL_ERROR', + message: 'Server error', + }, }), headers: { get: vi.fn().mockReturnValue(null), @@ -362,7 +419,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ success: true, output: {} }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn((header: string) => { if (header === 'x-ratelimit-limit') return '100' @@ -468,10 +525,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -488,7 +542,7 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('message', 'test') + expect(requestBody.input).toEqual({ message: 'test' }) expect(requestBody).toHaveProperty('stream', true) expect(requestBody).toHaveProperty('selectedOutputs') expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content']) @@ -500,10 +554,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -516,7 +567,7 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('input', 'NVDA') + expect(requestBody.input).toEqual({ input: 'NVDA' }) expect(requestBody).not.toHaveProperty('0') // Should not spread string characters }) @@ -524,10 +575,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -540,17 +588,14 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('input', 42) + expect(requestBody.input).toEqual({ input: 42 }) }) it('should wrap array input in input field', async () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -563,8 +608,7 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('input') - expect(requestBody.input).toEqual(['NVDA', 'AAPL', 'GOOG']) + expect(requestBody.input).toEqual({ input: ['NVDA', 'AAPL', 'GOOG'] }) expect(requestBody).not.toHaveProperty('0') // Should not spread array }) @@ -572,10 +616,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -588,19 +629,14 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('ticker', 'NVDA') - expect(requestBody).toHaveProperty('quantity', 100) - expect(requestBody).not.toHaveProperty('input') // Should not wrap in input field + expect(requestBody.input).toEqual({ ticker: 'NVDA', quantity: 100 }) }) it('should handle null input as no input (empty body)', async () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -613,8 +649,7 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - // null treated as "no input" - sends empty body (consistent with Python SDK) - expect(requestBody).toEqual({}) + expect(requestBody).toEqual({ input: {} }) }) }) }) diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts index d1538ff5e84..4d8777867f5 100644 --- a/packages/ts-sdk/src/index.ts +++ b/packages/ts-sdk/src/index.ts @@ -45,9 +45,8 @@ export interface ExecutionOptions { export interface AsyncExecutionResult { success: boolean - jobId: string + executionId: string statusUrl: string - executionId?: string message: string async: true } @@ -60,6 +59,32 @@ export interface JobStatusResult { error?: string } +export interface WorkflowExecutionError { + code: string + message: string + details?: unknown +} + +export interface WorkflowExecutionStatus { + executionId: string + workflowId: string + status: 'queued' | 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled' + trigger: string | null + startedAt: string | null + endedAt: string | null + durationMs: number | null + paused: Record | null + cost: { total: number } | null + error: WorkflowExecutionError | null + output: unknown | null + blockOutputs: Record | null +} + +export interface GetWorkflowExecutionOptions { + includeOutput?: boolean + selectedOutputs?: string[] +} + export interface RateLimitInfo { limit: number remaining: number @@ -215,7 +240,7 @@ export class SimStudioClient { input?: any, options: ExecutionOptions = {} ): Promise { - const url = `${this.baseUrl}/api/workflows/${workflowId}/execute` + const url = `${this.baseUrl}/api/v2/workflows/${workflowId}/execute` const { timeout = 30000, stream, selectedOutputs, async } = options try { @@ -227,20 +252,18 @@ export class SimStudioClient { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey, } - if (async) { - headers['X-Execution-Mode'] = 'async' - } - let jsonBody: any = {} + let workflowInput: any = {} if (input !== undefined && input !== null) { if (typeof input === 'object' && input !== null && !Array.isArray(input)) { - jsonBody = { ...input } + workflowInput = { ...input } } else { - jsonBody = { input } + workflowInput = { input } } } - jsonBody = await this.convertFilesToBase64(jsonBody) + workflowInput = await this.convertFilesToBase64(workflowInput) + const jsonBody: Record = { input: workflowInput } if (stream !== undefined) { jsonBody.stream = stream @@ -248,6 +271,9 @@ export class SimStudioClient { if (selectedOutputs !== undefined) { jsonBody.selectedOutputs = selectedOutputs } + if (async !== undefined) { + jsonBody.async = async + } const fetchPromise = fetch(url, { method: 'POST', @@ -269,16 +295,53 @@ export class SimStudioClient { } if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as unknown as any + const errorData = (await response.json().catch(() => ({}))) as { + error?: { code?: string; message?: string } + } throw new SimStudioError( - errorData.error || `HTTP ${response.status}: ${response.statusText}`, - errorData.code, + errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`, + errorData.error?.code, response.status ) } - const result = await response.json() - return result as WorkflowExecutionResult | AsyncExecutionResult + const result = (await response.json()) as { + data?: { + executionId: string + statusUrl?: string + status?: 'completed' | 'failed' | 'paused' | 'cancelled' + output?: unknown + error?: WorkflowExecutionError | null + durationMs?: number + } + } + if (!result.data) { + throw new SimStudioError('Invalid v2 workflow execution response', 'EXECUTION_ERROR') + } + + if (response.status === 202) { + if (!result.data.statusUrl) { + throw new SimStudioError('Invalid v2 async execution response', 'EXECUTION_ERROR') + } + return { + success: true, + executionId: result.data.executionId, + statusUrl: result.data.statusUrl, + message: 'Workflow execution queued', + async: true, + } + } + + return { + success: result.data.status !== 'failed', + output: result.data.output, + error: result.data.error?.message, + metadata: { + duration: result.data.durationMs, + executionId: result.data.executionId, + }, + totalDuration: result.data.durationMs, + } } catch (error: any) { if (error instanceof SimStudioError) { throw error @@ -310,7 +373,10 @@ export class SimStudioClient { }) if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as unknown as any + const errorData = (await response.json().catch(() => ({}))) as { + error?: string + code?: string + } throw new SimStudioError( errorData.error || `HTTP ${response.status}: ${response.statusText}`, errorData.code, @@ -374,8 +440,8 @@ export class SimStudioClient { } /** - * Get the status of an async job - * @param taskId The job ID returned from async execution + * Get the status of a legacy async job. + * @param taskId The job ID returned from legacy async execution */ async getJobStatus(taskId: string): Promise { const url = `${this.baseUrl}/api/jobs/${taskId}` @@ -410,6 +476,62 @@ export class SimStudioClient { } } + /** + * Get a workflow execution's current status and optional outputs from the v2 API. + */ + async getWorkflowExecution( + workflowId: string, + executionId: string, + options: GetWorkflowExecutionOptions = {} + ): Promise { + const query = new URLSearchParams() + if (options.includeOutput !== undefined) { + query.set('includeOutput', String(options.includeOutput)) + } + if (options.selectedOutputs?.length) { + query.set('selectedOutputs', options.selectedOutputs.join(',')) + } + const queryString = query.toString() + const url = `${this.baseUrl}/api/v2/workflows/${workflowId}/executions/${executionId}${queryString ? `?${queryString}` : ''}` + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'X-API-Key': this.apiKey, + }, + }) + + this.updateRateLimitInfo(response) + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + error?: { code?: string; message?: string } + } + throw new SimStudioError( + errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`, + errorData.error?.code, + response.status + ) + } + + const result = (await response.json()) as { data?: WorkflowExecutionStatus } + if (!result.data) { + throw new SimStudioError('Invalid v2 workflow execution response', 'STATUS_ERROR') + } + return result.data + } catch (error: any) { + if (error instanceof SimStudioError) { + throw error + } + + throw new SimStudioError( + describeError(error) || 'Failed to get workflow execution', + 'STATUS_ERROR' + ) + } + } + /** * Execute workflow with automatic retry on rate limit * @param workflowId - The ID of the workflow to execute