diff --git a/apps/docs/content/docs/api-reference/(generated)/workflow-runs/meta.json b/apps/docs/content/docs/api-reference/(generated)/workflow-runs/meta.json index 66d2d156e60..f719fdfe6f5 100644 --- a/apps/docs/content/docs/api-reference/(generated)/workflow-runs/meta.json +++ b/apps/docs/content/docs/api-reference/(generated)/workflow-runs/meta.json @@ -1,6 +1,7 @@ { "pages": [ "listWorkflowRunsV2", + "listPausedWorkflowRunsV2", "getWorkflowRunV2", "downloadWorkflowRunFileV2", "resumeWorkflowRunV2", diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 701b4b62b4d..d2bbff09eab 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -7525,6 +7525,31 @@ sim workflows runs get [options] API request and response schemas: [getWorkflowRun](/api-reference/workflow-runs/getWorkflowRunV2). +### sim workflows runs paused + +List runs waiting on human input across the workspace + +```bash +sim workflows runs paused [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-output` | No | Include each pause point's reviewer `display` content when true. | +| `--no-include-output` | No | Send --include-output as false. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Defaults to "pausedAt". Accepted values: `pausedAt`. | +| `--sort-order ` | No | Sort direction. Defaults to "desc". Accepted values: `asc`, `desc`. | + + + +API request and response schemas: [listPausedRuns](/api-reference/workflow-runs/listPausedWorkflowRunsV2). + ### sim workflows runs list List runs for a workflow diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 4f71f1f4c2b..2bb27df712c 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -806,6 +806,29 @@ sim workflows runs list [options] API request and response schemas: [listWorkflowRuns](/api-reference/workflow-runs/listWorkflowRunsV2). +### List runs waiting on human input across the workspace + +```bash +sim workflows runs paused [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-output` | No | Include each pause point's reviewer `display` content when true. | +| `--no-include-output` | No | Send --include-output as false. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Defaults to "pausedAt". Accepted values: `pausedAt`. | +| `--sort-order ` | No | Sort direction. Defaults to "desc". Accepted values: `asc`, `desc`. | + + + +API request and response schemas: [listPausedRuns](/api-reference/workflow-runs/listPausedWorkflowRunsV2). + ### Show run status Requested outputs are included in JSON or YAML output. diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 8fc511f96a9..e56e81b0ced 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -39,7 +39,7 @@ "get": { "operationId": "getBillingStatus", "summary": "Get billing status", - "description": "Get the current plan, billing standing, credit allowance, and storage quota. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both. Use List billing logs for credit history.\n\nOAuth scope: `api:read`.", + "description": "Get the current plan, billing standing, credit allowance, storage quota, and plan limits on runs. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both, but every caller reads `limits`. Use List billing logs for credit history.\n\nOAuth scope: `api:read`.", "x-sim-operation": "billing.status.read", "x-oauth-scope": "api:read", "tags": ["Billing"], @@ -105,6 +105,11 @@ "usedBytes": 5242880, "limitBytes": 1073741824, "percentUsed": 0.48828125 + }, + "limits": { + "maxSyncExecutionTimeoutSeconds": 3000, + "maxAsyncExecutionTimeoutSeconds": 5400, + "maxConcurrentExecutions": 50 } } } @@ -717,12 +722,61 @@ } ], "description": "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing. Always null for a workspace API key." + }, + "limits": { + "type": "object", + "properties": { + "maxSyncExecutionTimeoutSeconds": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "description": "Longest a synchronous run may execute, in seconds, or null when synchronous runs are not time-limited." + }, + "maxAsyncExecutionTimeoutSeconds": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "description": "Longest an asynchronous run may execute, in seconds, or null when asynchronous runs are not time-limited. `executionTimeoutSeconds` on Execute workflow can lower it, never raise it." + }, + "maxConcurrentExecutions": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ], + "description": "Most runs the payer can have executing at once, across every workspace and member it funds, or null when concurrency is not limited." + } + }, + "required": [ + "maxSyncExecutionTimeoutSeconds", + "maxAsyncExecutionTimeoutSeconds", + "maxConcurrentExecutions" + ], + "additionalProperties": false, + "description": "The payer's plan caps on workflow runs. Visible to every caller that can read the plan, including workspace API keys." } }, - "required": ["workspaceId", "period", "plan", "status", "credits", "storage"], + "required": ["workspaceId", "period", "plan", "status", "credits", "storage", "limits"], "additionalProperties": false, "title": "Billing status", - "description": "Current billing standing, credit allowance, and storage quota." + "description": "Current billing standing, credit allowance, storage quota, and plan limits." }, "V2BillingStatusResponse": { "type": "object", @@ -755,6 +809,11 @@ "usedBytes": 5242880, "limitBytes": 1073741824, "percentUsed": 0.48828125 + }, + "limits": { + "maxSyncExecutionTimeoutSeconds": 3000, + "maxAsyncExecutionTimeoutSeconds": 5400, + "maxConcurrentExecutions": 50 } } } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 13e9f54f691..69e97afd39c 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1340,7 +1340,7 @@ "get": { "operationId": "getWorkflow", "summary": "Get workflow", - "description": "Get workflow metadata, variables, and trigger input fields from the saved draft. Requires workspace read access. Input fields may differ from the live deployment. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "description": "Get workflow metadata, variables, and trigger input fields from the saved draft. Requires workspace read access. Input fields may differ from the live deployment; `inputSchema` describes the `input` an API run of the active deployment accepts. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", "x-sim-operation": "workflows.read", "x-oauth-scope": "api:read", "tags": ["Workflows"], @@ -1403,7 +1403,21 @@ "createdAt": "2026-05-01T09:00:00.000Z", "updatedAt": "2026-08-09T18:04:11.000Z", "variables": {}, - "inputs": [] + "inputs": [ + { + "name": "ticket", + "type": "string" + } + ], + "inputSchema": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + } + }, + "required": ["ticket"] + } } } } @@ -4379,7 +4393,8 @@ "error": null, "startedAt": "2026-08-09T18:04:10.000Z", "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000 + "durationMs": 1000, + "executionTimeoutSeconds": 3000 } } } @@ -4424,7 +4439,8 @@ "value": { "data": { "runId": "run_8f14e45f-ceea-467f-a", - "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" + "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a", + "executionTimeoutSeconds": 5400 } } } @@ -4651,6 +4667,179 @@ } } }, + "/api/v2/workflows/runs/paused": { + "get": { + "operationId": "listPausedWorkflowRunsV2", + "summary": "List paused workflow runs", + "description": "List a workspace's runs waiting on human input, with workspace read access. Each run lists the human pause points it still waits at, exactly as Get workflow run shows them; resume one with Resume workflow run. Request each point's reviewer content with `includeOutput`. Organization permission groups can hide pause fields. A page ends early, with a cursor, once it holds 100 pause points.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.runs.list_paused", + "x-oauth-scope": "api:read", + "tags": ["Workflow Runs"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose paused runs should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose paused runs should be listed." + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "Include each pause point's reviewer `display` content when true.", + "schema": { + "description": "Include each pause point's reviewer `display` content when true.", + "type": "boolean" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum runs to return per page. A page also ends early once it holds 100 pause points, so it can return fewer runs while `nextCursor` is set. Must be a whole number from 1 to 25. Defaults to 10.", + "schema": { + "default": 10, + "description": "Maximum runs to return per page. A page also ends early once it holds 100 pause points, so it can return fewer runs while `nextCursor` is set. Must be a whole number from 1 to 25. Defaults to 10.", + "type": "integer", + "minimum": 1, + "maximum": 25 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "pausedAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["pausedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + } + ], + "responses": { + "200": { + "description": "A page of paused workflow runs.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedRunListResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": [ + { + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowName": "Customer support triage", + "pausedAt": "2026-08-09T18:04:11.000Z", + "points": [ + { + "contextId": "blk_approval", + "blockId": "blk_approval", + "blockName": "Manager approval", + "kind": "human", + "status": "paused", + "registeredAt": "2026-08-09T18:04:11.000Z", + "resumeAt": null, + "parallel": null, + "loop": null, + "inputFields": [ + { + "name": "approved", + "label": "Approved", + "type": "boolean", + "required": true + } + ], + "display": null, + "queuePosition": null + } + ] + } + ], + "nextCursor": null + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/workflows/{workflowId}/runs/{runId}": { "get": { "operationId": "getWorkflowRunV2", @@ -11414,6 +11603,39 @@ "title": "Workflow input field", "description": "An input field from an input-definition trigger in the saved draft." }, + "WorkflowApiInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type of the `input` object; always `object`." + }, + "properties": { + "description": "Input field schemas keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "JSON Schema for one input field." + } + }, + "required": { + "description": "Names of the input fields an API run requires.", + "type": "array", + "items": { + "type": "string", + "description": "Name of a required input field." + } + } + }, + "required": ["type"], + "additionalProperties": { + "description": "Additional JSON Schema keyword." + }, + "title": "Workflow API input schema", + "description": "JSON Schema for the `input` an API run of the active deployment accepts." + }, "WorkflowDetail": { "type": "object", "properties": { @@ -11513,7 +11735,18 @@ "items": { "$ref": "#/components/schemas/WorkflowInputField" }, - "description": "Input fields from the saved draft's input-definition trigger; they may differ from the live deployment." + "description": "Input fields from the saved draft's input-definition trigger; they may differ from the live deployment. Use `inputSchema` for what an API run accepts." + }, + "inputSchema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkflowApiInputSchema" + }, + { + "type": "null" + } + ], + "description": "JSON Schema for the `input` an API run of the active deployment accepts, including `required` fields. Null when the workflow is not deployed or its active deployment has no API entry point." } }, "required": [ @@ -11530,11 +11763,12 @@ "createdAt", "updatedAt", "variables", - "inputs" + "inputs", + "inputSchema" ], "additionalProperties": false, "title": "Workflow detail", - "description": "Workflow metadata, variables, and saved draft trigger input fields." + "description": "Workflow metadata, variables, saved draft trigger input fields, and the input schema of the active deployment." }, "WorkflowDetailResponse": { "type": "object", @@ -11564,7 +11798,21 @@ "createdAt": "2026-05-01T09:00:00.000Z", "updatedAt": "2026-08-09T18:04:11.000Z", "variables": {}, - "inputs": [] + "inputs": [ + { + "name": "ticket", + "type": "string" + } + ], + "inputSchema": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + } + }, + "required": ["ticket"] + } } } ] @@ -15291,15 +15539,15 @@ "description": "Stable machine-readable execution failure code. `BLOCK_EXECUTION_FAILED` and `CHILD_WORKFLOW_FAILED` are reported only where block attribution is available; elsewhere a block-level failure is reported as `EXECUTION_FAILED`." }, "blockId": { - "description": "Identifier of the failing block. Present on the synchronous execute response only; the polled run resource and the resume response cannot attribute a block.", + "description": "Identifier of the failing block, when attributable. Get workflow run reads it from the run trace, so it is absent there when the trace is unavailable or hidden by organization permission groups, and for a timeout, cancellation, usage-limit, or invalid-input failure.", "type": "string" }, "blockName": { - "description": "Display name of the failing block. Present on the synchronous execute response only.", + "description": "Display name of the failing block, when attributable. Present whenever `blockId` is.", "type": "string" }, "blockType": { - "description": "Integration or block type that failed. Present on the synchronous execute response only.", + "description": "Integration or block type that failed, when attributable. Present whenever `blockId` is.", "type": "string" } }, @@ -15373,9 +15621,29 @@ "description": "Execution duration in milliseconds.", "type": "number", "minimum": 0 + }, + "executionTimeoutSeconds": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "description": "Execution timeout applied to this run, in seconds: the plan's timeout, lowered by `executionTimeoutSeconds` when one was requested. Null when the run has no execution timeout." } }, - "required": ["runId", "workflowId", "status", "output", "blockOutputs", "error"], + "required": [ + "runId", + "workflowId", + "status", + "output", + "blockOutputs", + "error", + "executionTimeoutSeconds" + ], "additionalProperties": false, "title": "Workflow run result", "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`." @@ -15405,7 +15673,8 @@ "error": null, "startedAt": "2026-08-09T18:04:10.000Z", "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000 + "durationMs": 1000, + "executionTimeoutSeconds": 3000 } } ] @@ -15425,9 +15694,21 @@ "type": "string", "format": "uri", "description": "Absolute URL of the workflow run resource." + }, + "executionTimeoutSeconds": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "description": "Execution timeout applied to this run, in seconds: the plan's timeout, lowered by `executionTimeoutSeconds` when one was requested. Null when the run has no execution timeout." } }, - "required": ["runId", "statusUrl"], + "required": ["runId", "statusUrl", "executionTimeoutSeconds"], "additionalProperties": false, "title": "Queued workflow run", "description": "Receipt returned when a workflow run is queued, or is still running when `waitTimeoutSeconds` elapsed." @@ -15448,7 +15729,8 @@ { "data": { "runId": "run_8f14e45f-ceea-467f-a", - "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" + "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a", + "executionTimeoutSeconds": 5400 } } ] @@ -15801,6 +16083,43 @@ } ] }, + "PausedRun": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + }, + "workflowId": { + "type": "string", + "description": "Workflow that produced the run." + }, + "workflowName": { + "type": "string", + "description": "Name of the workflow that produced the run." + }, + "pausedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the run entered the paused state.", + "format": "date-time" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowRunPausePoint" + }, + "description": "Human pause points still waiting for a resume, earliest first, at most 100. Resume one with Resume workflow run, naming its `contextId` and supplying `input`. Get workflow run lists every point of the run, including time pauses and points already resuming." + } + }, + "required": ["runId", "workflowId", "workflowName", "pausedAt", "points"], + "additionalProperties": false, + "title": "Paused run", + "description": "A run waiting on human input, with the points it waits at." + }, "WorkflowRunPausePoint": { "type": "object", "properties": { @@ -16017,6 +16336,69 @@ "title": "Pause input field", "description": "One field a human pause asks the resumer to fill." }, + "PausedRunListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedRun" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there are no more pages. Do not construct a cursor yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Paused run list response", + "description": "A cursor-paginated page of runs waiting on human input.", + "examples": [ + { + "data": [ + { + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowName": "Customer support triage", + "pausedAt": "2026-08-09T18:04:11.000Z", + "points": [ + { + "contextId": "blk_approval", + "blockId": "blk_approval", + "blockName": "Manager approval", + "kind": "human", + "status": "paused", + "registeredAt": "2026-08-09T18:04:11.000Z", + "resumeAt": null, + "parallel": null, + "loop": null, + "inputFields": [ + { + "name": "approved", + "label": "Approved", + "type": "boolean", + "required": true + } + ], + "display": null, + "queuePosition": null + } + ] + } + ], + "nextCursor": null + } + ] + }, "V2RunFile": { "type": "object", "properties": { @@ -16229,7 +16611,7 @@ "items": { "$ref": "#/components/schemas/WorkflowRunPausePoint" }, - "description": "Every point the run is paused at, earliest first, at most 100. Resume one with Resume workflow run, naming its `contextId` and supplying `input`." + "description": "Every point the run is paused at, earliest first, at most 100; past 100, points not yet resumed are kept in preference to resumed ones. Resume one with Resume workflow run, naming its `contextId` and supplying `input`." } }, "required": [ @@ -16279,7 +16661,7 @@ "type": "null" } ], - "description": "Structured execution failure, or null when none occurred. Reclassified from the persisted error message, so `blockId`/`blockName`/`blockType` are absent and a block-level failure reports `EXECUTION_FAILED` here even when the same run reported `BLOCK_EXECUTION_FAILED` on its synchronous execute response." + "description": "Structured execution failure, or null when none occurred. The failing block is read from the run trace, so `blockId`/`blockName`/`blockType` are absent, and a block-level failure reports `EXECUTION_FAILED`, when the trace is unavailable or hidden by organization permission groups." }, "output": { "anyOf": [ @@ -16390,12 +16772,84 @@ } ] }, + "WorkflowResumeResult": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + }, + "workflowId": { + "type": "string", + "description": "Workflow that produced the run." + }, + "status": { + "type": "string", + "enum": ["completed", "failed", "paused", "cancelled"], + "description": "Terminal or paused run status." + }, + "output": { + "description": "Workflow output, including partial output on failure." + }, + "blockOutputs": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Output value produced by one workflow block." + } + }, + { + "type": "null" + } + ], + "description": "Outputs of the blocks named by `selectedOutputs`, keyed by those selector strings exactly as sent, or null when none were requested. `output` stays the final workflow output regardless. Selectors whose block did not run or whose path is absent are omitted (an unknown block is a `400` instead); failed runs include the outputs of the blocks that did run." + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExecutionError" + }, + { + "type": "null" + } + ], + "description": "Structured execution failure, or null when none occurred." + }, + "startedAt": { + "description": "ISO 8601 timestamp when execution started.", + "format": "date-time", + "type": "string" + }, + "endedAt": { + "description": "ISO 8601 timestamp when execution ended.", + "format": "date-time", + "type": "string" + }, + "durationMs": { + "description": "Execution duration in milliseconds.", + "type": "number", + "minimum": 0 + } + }, + "required": ["runId", "workflowId", "status", "output", "blockOutputs", "error"], + "additionalProperties": false, + "title": "Workflow resume result", + "description": "Synchronous resumed run output and in-band execution status. Run failures are reported in band, not as HTTP errors, so branch on `status`." + }, "ResumeWorkflowSyncResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowRunResult" + "$ref": "#/components/schemas/WorkflowResumeResult" } }, "required": ["data"], diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json index cf8b74775e3..0b72b79d282 100644 --- a/apps/docs/scripts/fixtures/content-baseline.json +++ b/apps/docs/scripts/fixtures/content-baseline.json @@ -12846,166 +12846,173 @@ "bytes": 40, "line": 7465 }, + { + "lang": "bash", + "meta": null, + "sha256": "11658c406271a4f22a346aab51f697bc949342fbaaaabb2a786de824561bc4c3", + "bytes": 35, + "line": 7500 + }, { "lang": "bash", "meta": null, "sha256": "d683e8c6e5a1d063ea9f6c74fd9020d48aede0d5af7031f6f48bd5a173ad2d3d", "bytes": 33, - "line": 7500 + "line": 7525 }, { "lang": "bash", "meta": null, "sha256": "96abc89ee23cb4a7e2fc3caca2afbd2dd57d2734ca9e73816c412a71c5c5eb9d", "bytes": 43, - "line": 7527 + "line": 7552 }, { "lang": "bash", "meta": null, "sha256": "d63bf37625acc32ec1ee6e59a56f57469b9cb4dab03e23067a54d2f7d8abd3a3", "bytes": 41, - "line": 7559 + "line": 7584 }, { "lang": "bash", "meta": null, "sha256": "eb79c561c688b305a39f834bb39d74f65095db957e337d8e6081bbc00545bb08", "bytes": 53, - "line": 7592 + "line": 7617 }, { "lang": "bash", "meta": null, "sha256": "f30977b7b952e6a369b72b828256fac3f9ede78ce6e7c45560a8d09989bd4db5", "bytes": 49, - "line": 7623 + "line": 7648 }, { "lang": "bash", "meta": null, "sha256": "7d00d2a238c8794c5479e28ef14e6e104bcaff1aaaef70a094a2a51c55b6420c", "bytes": 50, - "line": 7644 + "line": 7669 }, { "lang": "bash", "meta": null, "sha256": "f34921d79bc4b608cb3e3c3f882729808afe1d8cf95ca45c63cbdf00ab494a3e", "bytes": 62, - "line": 7675 + "line": 7700 }, { "lang": "bash", "meta": null, "sha256": "ebfb60766394d803a01326b040ac37c5f122134555f1b968aeb6a79b10d74fc7", "bytes": 30, - "line": 7707 + "line": 7732 }, { "lang": "bash", "meta": null, "sha256": "432348582be57741628ca2b6a762c385ed57bbbfd4e4794b914409b7a89db582", "bytes": 35, - "line": 7729 + "line": 7754 }, { "lang": "bash", "meta": null, "sha256": "25d75743717b8e1c96f6b78e334e3e3cbc6dac5e395068308a4e358e06a0bac6", "bytes": 45, - "line": 7751 + "line": 7776 }, { "lang": "bash", "meta": null, "sha256": "2fc4fd97c2a62f635b42d646e481b18b0402ec0d051e7532988c026d838f0534", "bytes": 36, - "line": 7782 + "line": 7807 }, { "lang": "bash", "meta": null, "sha256": "3da31453cf86b97920f8d013f00122dee830af847c064e30ed3e158bd8f642cb", "bytes": 47, - "line": 7807 + "line": 7832 }, { "lang": "bash", "meta": null, "sha256": "d4de05e9142f532574133568e3fe2965c7fd82338e5b54ba0d8ae5713639eaca", "bytes": 43, - "line": 7832 + "line": 7857 }, { "lang": "bash", "meta": null, "sha256": "e63db5a4bab57e3229127bb086d0c5429dfe20de83eaf5097e8943f90520b9b3", "bytes": 51, - "line": 7864 + "line": 7889 }, { "lang": "bash", "meta": null, "sha256": "b130d4ed01c735074275b1505e7959e6dd3ae89c6b7ac159d17ccd495bec8f38", "bytes": 38, - "line": 7894 + "line": 7919 }, { "lang": "bash", "meta": null, "sha256": "32709306f0caabb4378fce9efba6b9c60a3985bb3500402d9a7f9e89fe35e00e", "bytes": 49, - "line": 7918 + "line": 7943 }, { "lang": "bash", "meta": null, "sha256": "a09f283134986b066a49a41c5ef250ff1502ae2adbc3316baacf92712112cc2d", "bytes": 43, - "line": 7960 + "line": 7985 }, { "lang": "bash", "meta": null, "sha256": "d941e4f91a379820ba5d640e7ceffd42c6fdd70b5b19da9c508c259b40ce14f7", "bytes": 53, - "line": 7991 + "line": 8016 }, { "lang": "bash", "meta": null, "sha256": "fb10d8958f9f1b03ab76713459abf552755dae3334103a451c387c9d1c7b3605", "bytes": 40, - "line": 8022 + "line": 8047 }, { "lang": "bash", "meta": null, "sha256": "b5dc1e771889fb1bf5f4d81d340cfd35d3a2263c87a725341d1d5c70405eb78d", "bytes": 43, - "line": 8071 + "line": 8096 }, { "lang": "bash", "meta": null, "sha256": "736889b5b5a59bc4fe7965922999b5cbec295acad8313c1e486d5668c7da961e", "bytes": 30, - "line": 8103 + "line": 8128 }, { "lang": "bash", "meta": null, "sha256": "4d6c0bc0130fd4a83c7b2fcac6f5b4b7bc2f275c05fa6101551ac61ce1405aca", "bytes": 44, - "line": 8123 + "line": 8148 }, { "lang": "bash", "meta": null, "sha256": "62d28b9be74c32c4ba00fefe878c492a6a5c671cc4a500373d7f2bdba9f7ef79", "bytes": 54, - "line": 8143 + "line": 8168 }, { "lang": "bash", @@ -13019,308 +13026,308 @@ "meta": null, "sha256": "7115696baaba8ca1ecd8f7cb22f215b7a53bf13d5757bd6932267644a0377807", "bytes": 36, - "line": 8173 + "line": 8198 }, { "lang": "bash", "meta": null, "sha256": "10330e610f59c2fb169fcff11cb8147563efbcc109f875222ecbfaaa48c05f31", "bytes": 50, - "line": 8195 + "line": 8220 }, { "lang": "bash", "meta": null, "sha256": "e88ab81833a4d529271c80e2cdf0e7ff5037a60066a918a9702a28ceca73a846", "bytes": 30, - "line": 8232 + "line": 8257 }, { "lang": "bash", "meta": null, "sha256": "f094df6732656b52792e5e766a69079c3e8bd929326bda7284e0bcb760bedd79", "bytes": 28, - "line": 8262 + "line": 8287 }, { "lang": "bash", "meta": null, "sha256": "7579bd6ac197afa0afa5f62d9b412a7e265725bd507c1c63b438a60fd72ce1d0", "bytes": 40, - "line": 8289 + "line": 8314 }, { "lang": "bash", "meta": null, "sha256": "63aac11606dc0eeb6cc452c20c405806840960425372cf2cf7c8b4b90a7de878", "bytes": 62, - "line": 8309 + "line": 8334 }, { "lang": "bash", "meta": null, "sha256": "52be4758f4289e9f75875d8401cf57fda54283b2df00e413921d07303e728638", "bytes": 28, - "line": 8340 + "line": 8365 }, { "lang": "bash", "meta": null, "sha256": "4c51312bac2ca9ea2dba0e7cf27f06d5e99502bab35ab569064057a85514cc38", "bytes": 38, - "line": 8361 + "line": 8386 }, { "lang": "bash", "meta": null, "sha256": "2b26aa6f404ab19bda2d846871eb04b732f6397d1899fc28d89a29706fc0a670", "bytes": 34, - "line": 8387 + "line": 8412 }, { "lang": "bash", "meta": null, "sha256": "47af2a240e0fa4bca92d786d1fea2d4b6cd7ee8810c4540b619271457739f829", "bytes": 60, - "line": 8409 + "line": 8434 }, { "lang": "bash", "meta": null, "sha256": "f4a5d9b54707e03cc845a3ffc99493f9990d7c0a0aa96a48a492b303ef1959b4", "bytes": 45, - "line": 8442 + "line": 8467 }, { "lang": "bash", "meta": null, "sha256": "4a4ff033e75c32373422638e4f8079487d9cc37fa2b250cc2907584e690e2699", "bytes": 45, - "line": 8475 + "line": 8500 }, { "lang": "bash", "meta": null, "sha256": "66d1f7b4fc772fcb1ac84bcba2c1aa0e1a26105dda64b564c40dec35fc5fdaf2", "bytes": 43, - "line": 8505 + "line": 8530 }, { "lang": "bash", "meta": null, "sha256": "5fc8d60c19e8e3fc1c640ea87e091d0b95642a210a7e1a7d9d19e527b6f11207", "bytes": 38, - "line": 8537 + "line": 8562 }, { "lang": "bash", "meta": null, "sha256": "a7acb92552eae9b29dd7b973751bb7f3c2f7eb1dc00f36e84c594cb88dd9fc1c", "bytes": 33, - "line": 8558 + "line": 8583 }, { "lang": "bash", "meta": null, "sha256": "bb84857c237d2bfae0c6f63653597fe55263147428a5e8d36ceacfcda6bd6246", "bytes": 26, - "line": 8589 + "line": 8614 }, { "lang": "bash", "meta": null, "sha256": "3e634421662d08fe73882dd6a0d428545c33597790333f83b4c5086c8d494a1e", "bytes": 49, - "line": 8615 + "line": 8640 }, { "lang": "bash", "meta": null, "sha256": "c48dfec9df0cebb16aa976f859770a8b5beb67e2cf01dc5472dd8b3727ea164d", "bytes": 47, - "line": 8635 + "line": 8660 }, { "lang": "bash", "meta": null, "sha256": "8fd3a890425cf05e630bcd969b2aff57b3993d6632d905cab5b23cfce60e425c", "bytes": 49, - "line": 8656 + "line": 8681 }, { "lang": "bash", "meta": null, "sha256": "bd32d826b077176044eabbf175d64a747ba1a826bb36c95efb46925525f75ba9", "bytes": 45, - "line": 8682 + "line": 8707 }, { "lang": "bash", "meta": null, "sha256": "ee94be8e7fcb3c1bdb0abd6991992563371ba113b80b433cce0c71ac499f1d48", "bytes": 43, - "line": 8706 + "line": 8731 }, { "lang": "bash", "meta": null, "sha256": "bbaecaea783d60d4e8cbaad1c6951d2aa5fde5772f050166a24415162d9de3bc", "bytes": 29, - "line": 8728 + "line": 8753 }, { "lang": "bash", "meta": null, "sha256": "627ab83bbee7d7a608e8771c98556a00cb9df200a40fe9b68d79f93c322de59d", "bytes": 18, - "line": 8753 + "line": 8778 }, { "lang": "bash", "meta": null, "sha256": "9396c5d2aba2c0ff0d484a0a778dc38368154eb19edca9372cf576f8abedac90", "bytes": 32, - "line": 8763 + "line": 8788 }, { "lang": "bash", "meta": null, "sha256": "456a3638df3f1208cc5620fe2f7744e60215930e90cdada8c6d3f3041844b81c", "bytes": 22, - "line": 8773 + "line": 8798 }, { "lang": "bash", "meta": null, "sha256": "80c6f38f04b6c653c8de67e79ba7b6ca1a5ec208e8765a036048fd0adc4b4522", "bytes": 37, - "line": 8783 + "line": 8808 }, { "lang": "bash", "meta": null, "sha256": "3be0a5bf2f996779b39f57c35c08a046709ad91ffbb1ecd700c2d0f8a1c9b554", "bytes": 40, - "line": 8808 + "line": 8833 }, { "lang": "bash", "meta": null, "sha256": "94cdaecf523029bf20f25cf278f76142041e4ad9ce6980a0de225f6dd0b93a8f", "bytes": 43, - "line": 8830 + "line": 8855 }, { "lang": "bash", "meta": null, "sha256": "1731734821194e9c9ab5d0e866031dff102eab3c67b81c83780dce706461c802", "bytes": 40, - "line": 8850 + "line": 8875 }, { "lang": "bash", "meta": null, "sha256": "466187fc1a5788ab09d7700cf7cd19c9c541a571d0bc86d70fe52a1eb831b851", "bytes": 54, - "line": 8872 + "line": 8897 }, { "lang": "bash", "meta": null, "sha256": "e10bc7e084ad3b9231262208e0ac216aee47dc0ec7b1507214690ea9c098d5c9", "bytes": 32, - "line": 8904 + "line": 8929 }, { "lang": "bash", "meta": null, "sha256": "6bb0cf09f6bd8ba02e1f24a5eed89b35c0226ec7eefce07502015a455520fd0f", "bytes": 33, - "line": 8914 + "line": 8939 }, { "lang": "bash", "meta": null, "sha256": "23acce8016e243ab651bcc860cd8f1b1d236e6a5b7951abc76282cd674b2a25e", "bytes": 39, - "line": 8937 + "line": 8962 }, { "lang": "bash", "meta": null, "sha256": "210e8ab3d8af173ce648501ca50c828325ac1f4452006ad4485cb7a24d760ae4", "bytes": 37, - "line": 8961 + "line": 8986 }, { "lang": "bash", "meta": null, "sha256": "cdff2d4272d5bc3a18729e63e5ece9053d33ac0f8faf35cfaa3a289baa2dccc1", "bytes": 48, - "line": 8983 + "line": 9008 }, { "lang": "bash", "meta": null, "sha256": "c307203dd609208b90cbc0372e3401a75e943421bdf77e4e10779ed2c89ca4ff", "bytes": 48, - "line": 9013 + "line": 9038 }, { "lang": "bash", "meta": null, "sha256": "f6340c3d5dc4eedb98435df593ff277d0e44fbcb4c61e71a05123267b5352e78", "bytes": 29, - "line": 9043 + "line": 9068 }, { "lang": "bash", "meta": null, "sha256": "ca6e405dc70365db67ec66e6c2499fedf89ae3d7a90f4b5d044a2aab13bb8f37", "bytes": 37, - "line": 9065 + "line": 9090 }, { "lang": "bash", "meta": null, "sha256": "52918df5b4d4003d22322d5785d8a0202a2b66d39a3c71171f92a65545e28788", "bytes": 37, - "line": 9086 + "line": 9111 }, { "lang": "bash", "meta": null, "sha256": "28e77f2a35930390cc7de2fc5abdce7809311c33b10eb749941d5c44b9de0cfa", "bytes": 37, - "line": 9111 + "line": 9136 }, { "lang": "bash", "meta": null, "sha256": "088f9018d5639eac73c710bf1ef4fd134b0cd4c290587a161af1322b415a013f", "bytes": 29, - "line": 9138 + "line": 9163 }, { "lang": "bash", "meta": null, "sha256": "67384a12d7642ea8a82e807a58e42f921ea37cb496e2315ac87368ae0e297e4d", "bytes": 29, - "line": 9170 + "line": 9195 }, { "lang": "bash", "meta": null, "sha256": "4c9e46c0b1c48cb348bfb37e4c705e4dfe1cd5f221d667ebd02dd7325fce7e29", "bytes": 38, - "line": 9202 + "line": 9227 }, { "lang": "bash", "meta": null, "sha256": "20fbb381c0b4ef786f755f485789ecb9e76538ec6394956bb6153ee4a79547ca", "bytes": 31, - "line": 9225 + "line": 9250 }, { "lang": "bash", @@ -13334,7 +13341,7 @@ "meta": null, "sha256": "54ffdfd98a1fc3aad38e602c1d02336a81fe97d48e6732402a7980d0f55cb20d", "bytes": 40, - "line": 9246 + "line": 9271 } ], "links": [ @@ -17262,196 +17269,203 @@ "meta": null, "sha256": "7579bd6ac197afa0afa5f62d9b412a7e265725bd507c1c63b438a60fd72ce1d0", "bytes": 40, - "line": 436 + "line": 432 }, { "lang": "bash", "meta": null, "sha256": "63aac11606dc0eeb6cc452c20c405806840960425372cf2cf7c8b4b90a7de878", "bytes": 62, - "line": 454 + "line": 450 }, { "lang": "bash", "meta": null, "sha256": "7d00d2a238c8794c5479e28ef14e6e104bcaff1aaaef70a094a2a51c55b6420c", "bytes": 50, - "line": 432 + "line": 479 }, { "lang": "bash", "meta": null, "sha256": "f30977b7b952e6a369b72b828256fac3f9ede78ce6e7c45560a8d09989bd4db5", "bytes": 49, - "line": 461 + "line": 508 }, { "lang": "bash", "meta": null, "sha256": "f34921d79bc4b608cb3e3c3f882729808afe1d8cf95ca45c63cbdf00ab494a3e", "bytes": 62, - "line": 480 + "line": 527 }, { "lang": "bash", "meta": null, "sha256": "eb79c561c688b305a39f834bb39d74f65095db957e337d8e6081bbc00545bb08", "bytes": 53, - "line": 510 + "line": 557 }, { "lang": "bash", "meta": null, "sha256": "5ed096b4dcf0efad18c54d322d528b7bb0e80336d061ee7eaa5d39d3bd505a4d", "bytes": 62, - "line": 543 + "line": 590 }, { "lang": "bash", "meta": null, "sha256": "47af2a240e0fa4bca92d786d1fea2d4b6cd7ee8810c4540b619271457739f829", "bytes": 60, - "line": 576 + "line": 623 }, { "lang": "bash", "meta": null, "sha256": "f4a5d9b54707e03cc845a3ffc99493f9990d7c0a0aa96a48a492b303ef1959b4", "bytes": 45, - "line": 609 + "line": 656 }, { "lang": "bash", "meta": null, "sha256": "4a4ff033e75c32373422638e4f8079487d9cc37fa2b250cc2907584e690e2699", "bytes": 45, - "line": 642 + "line": 689 }, { "lang": "bash", "meta": null, "sha256": "fb10d8958f9f1b03ab76713459abf552755dae3334103a451c387c9d1c7b3605", "bytes": 40, - "line": 672 + "line": 719 }, { "lang": "bash", "meta": null, "sha256": "d683e8c6e5a1d063ea9f6c74fd9020d48aede0d5af7031f6f48bd5a173ad2d3d", "bytes": 33, - "line": 717 + "line": 764 + }, + { + "lang": "bash", + "meta": null, + "sha256": "11658c406271a4f22a346aab51f697bc949342fbaaaabb2a786de824561bc4c3", + "bytes": 35, + "line": 789 }, { "lang": "bash", "meta": null, "sha256": "bfb90a10455a59ef26068d176221816b9c374c4001d386ffdbb0140c4cfe40ca", "bytes": 40, - "line": 744 + "line": 814 }, { "lang": "bash", "meta": null, "sha256": "d63bf37625acc32ec1ee6e59a56f57469b9cb4dab03e23067a54d2f7d8abd3a3", "bytes": 41, - "line": 777 + "line": 847 }, { "lang": "bash", "meta": null, "sha256": "96abc89ee23cb4a7e2fc3caca2afbd2dd57d2734ca9e73816c412a71c5c5eb9d", "bytes": 43, - "line": 810 + "line": 880 }, { "lang": "bash", "meta": null, "sha256": "653a412a8eeaffac8afbbbb2533375f02df67fe7ef882ca53fc7ca2755d0bcec", "bytes": 43, - "line": 842 + "line": 912 }, { "lang": "bash", "meta": null, "sha256": "b130d4ed01c735074275b1505e7959e6dd3ae89c6b7ac159d17ccd495bec8f38", "bytes": 38, - "line": 874 + "line": 944 }, { "lang": "bash", "meta": null, "sha256": "32709306f0caabb4378fce9efba6b9c60a3985bb3500402d9a7f9e89fe35e00e", "bytes": 49, - "line": 898 + "line": 968 }, { "lang": "bash", "meta": null, "sha256": "e63db5a4bab57e3229127bb086d0c5429dfe20de83eaf5097e8943f90520b9b3", "bytes": 51, - "line": 942 + "line": 1012 }, { "lang": "bash", "meta": null, "sha256": "b5dc1e771889fb1bf5f4d81d340cfd35d3a2263c87a725341d1d5c70405eb78d", "bytes": 43, - "line": 974 + "line": 1044 }, { "lang": "bash", "meta": null, "sha256": "4c51312bac2ca9ea2dba0e7cf27f06d5e99502bab35ab569064057a85514cc38", "bytes": 38, - "line": 1004 + "line": 1074 }, { "lang": "bash", "meta": null, "sha256": "e88ab81833a4d529271c80e2cdf0e7ff5037a60066a918a9702a28ceca73a846", "bytes": 30, - "line": 1028 + "line": 1098 }, { "lang": "bash", "meta": null, "sha256": "a7acb92552eae9b29dd7b973751bb7f3c2f7eb1dc00f36e84c594cb88dd9fc1c", "bytes": 33, - "line": 1058 + "line": 1128 }, { "lang": "bash", "meta": null, "sha256": "2fc4fd97c2a62f635b42d646e481b18b0402ec0d051e7532988c026d838f0534", "bytes": 36, - "line": 1087 + "line": 1157 }, { "lang": "bash", "meta": null, "sha256": "432348582be57741628ca2b6a762c385ed57bbbfd4e4794b914409b7a89db582", "bytes": 35, - "line": 1110 + "line": 1180 }, { "lang": "bash", "meta": null, "sha256": "25d75743717b8e1c96f6b78e334e3e3cbc6dac5e395068308a4e358e06a0bac6", "bytes": 45, - "line": 1130 + "line": 1200 }, { "lang": "bash", "meta": null, "sha256": "3da31453cf86b97920f8d013f00122dee830af847c064e30ed3e158bd8f642cb", "bytes": 47, - "line": 1159 + "line": 1229 }, { "lang": "bash", "meta": null, "sha256": "bb84857c237d2bfae0c6f63653597fe55263147428a5e8d36ceacfcda6bd6246", "bytes": 26, - "line": 1180 + "line": 1250 } ], "links": [ diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index 45e9ce4748f..f2cb5c0acd4 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -386,6 +386,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { startedAt: '2026-07-31T00:00:00.000Z', endedAt: '2026-07-31T00:00:01.000Z', durationMs: 42, + executionTimeoutSeconds: 60, }, }) expect((await reader.read()).done).toBe(true) @@ -491,6 +492,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { expect(body.data).toEqual({ runId: 'execution-123', statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/runs/execution-123', + executionTimeoutSeconds: 300, }) expect(mockPreprocessExecution).toHaveBeenCalledWith( expect.objectContaining({ rateLimitCounter: 'async' }) @@ -1135,9 +1137,11 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { endTime: '2026-07-31T00:00:01.000Z', }, } + /** A detached run is admitted under asynchronous limits, so it reports the async timeout. */ const receipt = { runId: 'execution-123', statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/runs/execution-123', + executionTimeoutSeconds: 300, } /** A run the test settles by hand, recording the signal the executor was handed. */ diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts index a97fe7c4d0a..39ec3bc305a 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts @@ -25,6 +25,7 @@ import { getWorkspaceBilledAccountUserId } from '@/lib/billing/core/attribution' import { tryAdmit } from '@/lib/core/admission/gate' import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' import type { ForbiddenDetailCode } from '@/lib/core/application' +import { executionTimeoutSeconds } from '@/lib/core/execution-limits/types' import { acceptsMediaType } from '@/lib/core/utils/media-types' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -133,12 +134,17 @@ function presentRun(result: ExecuteWorkflowServiceRun) { startedAt: result.startedAt, endedAt: result.endedAt, durationMs: result.durationMs, + executionTimeoutSeconds: executionTimeoutSeconds(result.executionTimeoutMs), } } /** Where a run that continues past its response is followed: the v2 runs resource. */ -function runReceipt(workflowId: string, runId: string) { - return { runId, statusUrl: `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${runId}` } +function runReceipt(workflowId: string, runId: string, executionTimeoutMs: number) { + return { + runId, + statusUrl: `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${runId}`, + executionTimeoutSeconds: executionTimeoutSeconds(executionTimeoutMs), + } } /** A run that may outlive its response: past `afterMs` (never, when null) only its receipt is sent. */ @@ -633,7 +639,10 @@ export const POST = withRouteHandler( if ('pending' in result) { const detachment: RunDetachment | undefined = detachable - ? { afterMs: detachAfterMs, receipt: runReceipt(workflowId, result.executionId) } + ? { + afterMs: detachAfterMs, + receipt: runReceipt(workflowId, result.executionId, result.executionTimeoutMs), + } : undefined if (resultStream) { const response = streamPendingRun(result, requestId, ticket.release, detachment) @@ -665,7 +674,7 @@ export const POST = withRouteHandler( } if ('queued' in result) { - return v2Data(runReceipt(workflowId, result.executionId), { + return v2Data(runReceipt(workflowId, result.executionId, result.executionTimeoutMs), { status: 202, headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId }, }) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/route.ts index ea42f70f681..1a0240d34b1 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/route.ts @@ -34,7 +34,7 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.workflowId }), useCase: readWorkflow, - present: ({ workflow, workspaceId, folderPath, inputs }) => ({ + present: ({ workflow, workspaceId, folderPath, inputs, inputSchema }) => ({ data: { id: workflow.id, webUrl: workspaceResourceWebUrl(getBaseUrl(), workspaceId, 'workflow', workflow.id), @@ -48,6 +48,7 @@ export const GET = defineV2JsonRoute({ lastRunAt: workflow.lastRunAt?.toISOString() ?? null, variables: (workflow.variables as Record | null) ?? {}, inputs, + inputSchema, createdAt: workflow.createdAt.toISOString(), updatedAt: workflow.updatedAt.toISOString(), }, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts index 9bbf5c353a6..dffdb4e639a 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts @@ -6,7 +6,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { workflowOperations } from '@/lib/workflows/application/operations' import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' -import { classifyExecutionError } from '@/executor/utils/errors' +import { classifyRecordedExecutionError } from '@/executor/utils/errors' export const dynamic = 'force-dynamic' @@ -53,7 +53,7 @@ export const GET = defineV2JsonRoute({ }) : null, cost: status.cost, - error: status.error ? classifyExecutionError(new Error(status.error)) : null, + error: status.error ? classifyRecordedExecutionError(status.error, status.failedBlock) : null, output: status.finalOutput, blockOutputs: status.blockOutputs, files: status.files, diff --git a/apps/sim/app/api/v2/workflows/runs/paused/route.ts b/apps/sim/app/api/v2/workflows/runs/paused/route.ts new file mode 100644 index 00000000000..12ec6a64e53 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/runs/paused/route.ts @@ -0,0 +1,60 @@ +import { type V2PausedRun, v2ListPausedRunsContract } from '@/lib/api/contracts/v2/workflows' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listPausedRuns } from '@/lib/workflows/application/list-paused-runs' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Every param that changes which paused runs, in which order, this list returns. */ +function pausedRunCursorFilters(query: { workspaceId: string }) { + return cursorScopeKey(cursorRoute(v2ListPausedRunsContract), { + workspaceId: query.workspaceId, + }) +} + +/** List a workspace's runs waiting on human input, with the points they wait at. */ +export const GET = defineV2JsonRoute({ + contract: v2ListPausedRunsContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.listPausedRuns, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + includeOutput: query.includeOutput, + sortOrder: query.sortOrder, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + pausedRunCursorFilters(query) + ), + limit: query.limit, + }), + useCase: listPausedRuns, + present: ({ runs, nextCursorKeys }, { query }) => ({ + data: runs.map( + (run): V2PausedRun => ({ + runId: run.runId, + workflowId: run.workflowId, + workflowName: run.workflowName, + pausedAt: run.pausedAt.toISOString(), + points: run.points, + }) + ), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + pausedRunCursorFilters(query) + ), + }), +}) diff --git a/apps/sim/executor/utils/errors.test.ts b/apps/sim/executor/utils/errors.test.ts index e318f923574..b7392cc39ac 100644 --- a/apps/sim/executor/utils/errors.test.ts +++ b/apps/sim/executor/utils/errors.test.ts @@ -5,6 +5,7 @@ import type { ExecutionResult } from '@/executor/types' import { buildBlockExecutionError, classifyExecutionError, + classifyRecordedExecutionError, getExecutionErrorStatus, type WorkflowExecutionErrorCode, } from '@/executor/utils/errors' @@ -239,3 +240,44 @@ describe('classifyExecutionError', () => { ) }) }) + +describe('classifyRecordedExecutionError', () => { + const lookup = { blockId: 'block-1', blockName: 'Lookup', blockType: 'api' } + + it('attributes a block failure to the block its trace names', () => { + expect(classifyRecordedExecutionError('Lookup: upstream returned 500', lookup)).toEqual({ + message: 'upstream returned 500', + code: 'BLOCK_EXECUTION_FAILED', + ...lookup, + }) + }) + + it('reports a child workflow block failure as CHILD_WORKFLOW_FAILED', () => { + const runner = { blockId: 'block-2', blockName: 'Run child', blockType: 'workflow' } + expect(classifyRecordedExecutionError('Run child: child failed', runner).code).toBe( + 'CHILD_WORKFLOW_FAILED' + ) + }) + + it('leaves a run-level timeout unattributed even when a block span errored', () => { + const error = classifyRecordedExecutionError('Execution timed out after 300 seconds', lookup) + expect(error.code).toBe('TIMEOUT') + expect(error.blockId).toBeUndefined() + }) + + it("attributes a block's own failure even when its name reads as a run-level failure", () => { + const timeoutBlock = { blockId: 'block-3', blockName: 'Timeout', blockType: 'api' } + expect(classifyRecordedExecutionError('Timeout: upstream returned 500', timeoutBlock)).toEqual({ + message: 'upstream returned 500', + code: 'BLOCK_EXECUTION_FAILED', + ...timeoutBlock, + }) + }) + + it('reports EXECUTION_FAILED when the trace names no block', () => { + expect(classifyRecordedExecutionError('Lookup: upstream returned 500', null)).toEqual({ + message: 'Lookup: upstream returned 500', + code: 'EXECUTION_FAILED', + }) + }) +}) diff --git a/apps/sim/executor/utils/errors.ts b/apps/sim/executor/utils/errors.ts index 805e54bcfd9..3ef3165b81f 100644 --- a/apps/sim/executor/utils/errors.ts +++ b/apps/sim/executor/utils/errors.ts @@ -1,5 +1,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { HttpError } from '@/lib/core/utils/http-error' +import type { FailedTraceBlock } from '@/lib/logs/execution/trace-spans/trace-spans' import type { ExecutionContext, ExecutionResult } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' @@ -141,6 +142,16 @@ function isRecordedThrown(value: unknown): value is object { return (typeof value === 'object' || typeof value === 'function') && value !== null } +/** How a block failure's message names its block: `Block name: message`. */ +function blockErrorPrefix(blockName: string): string { + return `${blockName}: ` +} + +/** Whether an error message is the one a failure in `blockName` records. */ +export function errorNamesBlock(message: string, blockName: string): boolean { + return message === blockName || message.startsWith(blockErrorPrefix(blockName)) +} + export interface BlockExecutionErrorDetails { block: SerializedBlock error: Error | string @@ -159,7 +170,7 @@ export function buildBlockExecutionError(details: BlockExecutionErrorDetails): E const blockName = details.block.metadata?.name || details.block.id const blockType = details.block.metadata?.id || 'unknown' - const error = new Error(`${blockName}: ${errorMessage}`, { + const error = new Error(`${blockErrorPrefix(blockName)}${errorMessage}`, { cause: details.error instanceof Error ? details.error : undefined, }) @@ -338,8 +349,8 @@ export function classifyExecutionError( executionResult?.error ?? fromLog.error ?? 'Execution failed' - if (blockName && message.startsWith(`${blockName}: `)) { - message = message.slice(blockName.length + 2) + if (blockName && message.startsWith(blockErrorPrefix(blockName))) { + message = message.slice(blockErrorPrefix(blockName).length) } const statusCode = error instanceof Error ? getExecutionErrorStatus(error) : undefined @@ -362,3 +373,22 @@ export function classifyExecutionError( return { message, code, blockId, blockName, blockType } } + +/** + * Classifies a run's persisted error message, attributing it to the failing + * block its recorded trace names. A message headed by that block's name is the + * block's own failure and is classified with the block attached, whatever the + * name says. A message that does not name it stays unattributed when its text + * classifies as run-level (a timeout, cancellation, usage limit, or invalid + * input), and without a block this reports what the message alone supports. + */ +export function classifyRecordedExecutionError( + message: string, + failedBlock: FailedTraceBlock | null +): StructuredExecutionError { + const unattributed = classifyExecutionError(new Error(message)) + if (!failedBlock) return unattributed + return errorNamesBlock(message, failedBlock.blockName) || unattributed.code === 'EXECUTION_FAILED' + ? classifyExecutionError(Object.assign(new Error(message), failedBlock)) + : unattributed +} diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index d95924c4cdf..3bb5a66ec39 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -119,11 +119,39 @@ export const v2BillingStatusDataSchema = z .describe( "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing. Always null for a workspace API key." ), + limits: z + .object({ + maxSyncExecutionTimeoutSeconds: z + .number() + .positive() + .nullable() + .describe( + 'Longest a synchronous run may execute, in seconds, or null when synchronous runs are not time-limited.' + ), + maxAsyncExecutionTimeoutSeconds: z + .number() + .positive() + .nullable() + .describe( + 'Longest an asynchronous run may execute, in seconds, or null when asynchronous runs are not time-limited. `executionTimeoutSeconds` on Execute workflow can lower it, never raise it.' + ), + maxConcurrentExecutions: z + .number() + .int() + .positive() + .nullable() + .describe( + 'Most runs the payer can have executing at once, across every workspace and member it funds, or null when concurrency is not limited.' + ), + }) + .describe( + "The payer's plan caps on workflow runs. Visible to every caller that can read the plan, including workspace API keys." + ), }) .meta({ id: 'V2BillingStatus', title: 'Billing status', - description: 'Current billing standing, credit allowance, and storage quota.', + description: 'Current billing standing, credit allowance, storage quota, and plan limits.', }) export type V2BillingStatusData = z.output diff --git a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts index 1ef82d8425e..31c06b539b4 100644 --- a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts @@ -86,6 +86,7 @@ const PAGED_LISTS = [ 'GET /api/v2/workflows', 'GET /api/v2/workflows/[workflowId]/runs', 'GET /api/v2/workflows/[workflowId]/versions', + 'GET /api/v2/workflows/runs/paused', 'GET /api/v2/workflow-mcp-servers', 'GET /api/v2/workspaces/[workspaceId]/members', 'GET /api/v2/workspaces/[workspaceId]/fork/children', @@ -342,6 +343,7 @@ const CURSOR_BINDINGS: Record = { ], 'GET /api/v2/workflows/[workflowId]/runs': ['status', 'trigger', 'startDate', 'endDate', 'order'], 'GET /api/v2/workflows/[workflowId]/versions': [], + 'GET /api/v2/workflows/runs/paused': ['workspaceId', 'sortBy', 'sortOrder'], 'GET /api/v2/workflow-mcp-servers': ['workspaceId', 'sortBy', 'sortOrder'], 'GET /api/v2/chat-deployments': ['workspaceId', 'workflowId', 'isActive', 'sortBy', 'sortOrder'], 'GET /api/v2/workspaces/[workspaceId]/members': [], @@ -455,6 +457,10 @@ const UNBOUND_PARAMS: Record> = { includeTraceSpans: 'Response shaping only; the row set and its order are unchanged.', includeFinalOutput: 'Response shaping only; the row set and its order are unchanged.', }, + 'GET /api/v2/workflows/runs/paused': { + includeOutput: + 'Response shaping only; the run set, its order, and where a page ends (counted in pause points, not bytes) are unchanged.', + }, 'GET /api/v2/tables/[tableId]/rows': { workspaceId: 'Asserted scope, not a filter: the sequence is one table, named by the path. A mismatched workspace is refused by authorization before paging.', diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index b0bc54c6900..593cbaac5c2 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -40,6 +40,11 @@ const BILLING_STATUS_EXAMPLE = { limitBytes: 1_073_741_824, percentUsed: 0.48828125, }, + limits: { + maxSyncExecutionTimeoutSeconds: 3_000, + maxAsyncExecutionTimeoutSeconds: 5_400, + maxConcurrentExecutions: 50, + }, }, } as const @@ -83,7 +88,7 @@ const routes = [ operationId: 'getBillingStatus', summary: 'Get billing status', description: - "Get the current plan, billing standing, credit allowance, and storage quota. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both. Use List billing logs for credit history.", + "Get the current plan, billing standing, credit allowance, storage quota, and plan limits on runs. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both, but every caller reads `limits`. Use List billing logs for credit history.", errors: RESOURCE_ERRORS, success: { description: 'The current billing and storage status.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 69c387b56da..fae096a3268 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -29,6 +29,7 @@ import { import { workspaceSyncOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/workspace-sync' import { EXECUTE_OPTION_CONSTRAINTS, + V2_MAX_PAUSED_RUN_POINTS_PER_PAGE, v2ActivateWorkflowVersionContract, v2ApplyWorkflowOperationsContract, v2ApplyWorkflowVariablesContract, @@ -52,6 +53,7 @@ import { v2GetWorkflowStateContract, v2GetWorkflowVersionContract, v2ImportWorkflowContract, + v2ListPausedRunsContract, v2ListWorkflowFoldersContract, v2ListWorkflowRunsContract, v2ListWorkflowsContract, @@ -236,7 +238,7 @@ const executeSyncResponseSchema = documentedSchema( 'ExecuteWorkflowSyncResponse', 'Synchronous workflow execution response', 'Completed, failed, paused, or cancelled synchronous workflow run.', - [RUN_RESULT_EXAMPLE] + [{ data: { ...RUN_RESULT_EXAMPLE.data, executionTimeoutSeconds: 3_000 } }] ) const executeQueuedResponseSchema = documentedSchema( @@ -244,7 +246,7 @@ const executeQueuedResponseSchema = documentedSchema( 'ExecuteWorkflowQueuedResponse', 'Queued workflow execution response', 'Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.', - [QUEUED_RUN_EXAMPLE] + [{ data: { ...QUEUED_RUN_EXAMPLE.data, executionTimeoutSeconds: 5_400 } }] ) const resumeSyncResponseSchema = documentedSchema( @@ -615,7 +617,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.read, operationId: 'getWorkflow', summary: 'Get workflow', - description: `Get workflow metadata, variables, and trigger input fields from the saved draft. Requires workspace read access. Input fields may differ from the live deployment. ${FOLDER_TREE_TOO_LARGE}`, + description: `Get workflow metadata, variables, and trigger input fields from the saved draft. Requires workspace read access. Input fields may differ from the live deployment; \`inputSchema\` describes the \`input\` an API run of the active deployment accepts. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The requested workflow.'), }), @@ -627,7 +629,20 @@ const declaredRoutes = [ 'WorkflowDetailResponse', 'Workflow detail response', 'Detailed workflow metadata, variables, and trigger inputs.', - [{ data: { ...WORKFLOW_EXAMPLE, variables: {}, inputs: [] } }] + [ + { + data: { + ...WORKFLOW_EXAMPLE, + variables: {}, + inputs: [{ name: 'ticket', type: 'string' }], + inputSchema: { + type: 'object', + properties: { ticket: { type: 'string' } }, + required: ['ticket'], + }, + }, + }, + ] ), } ), @@ -1445,6 +1460,57 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListPausedRunsContract, + workflowRunOperation({ + applicationOperation: workflowOperations.listPausedRuns, + operationId: 'listPausedWorkflowRunsV2', + summary: 'List paused workflow runs', + description: `List a workspace's runs waiting on human input, with workspace read access. Each run lists the human pause points it still waits at, exactly as Get workflow run shows them; resume one with Resume workflow run. Request each point's reviewer content with \`includeOutput\`. Organization permission groups can hide pause fields. A page ends early, with a cursor, once it holds ${V2_MAX_PAUSED_RUN_POINTS_PER_PAGE} pause points.`, + errors: RESOURCE_ERRORS, + success: jsonSuccess('A page of paused workflow runs.'), + }), + { + query: v2ListPausedRunsContract.query, + response: documentedSchema( + v2ListPausedRunsContract.response.schema, + 'PausedRunListResponse', + 'Paused run list response', + 'A cursor-paginated page of runs waiting on human input.', + [ + { + data: [ + { + runId: RUN_ID, + workflowId: WORKFLOW_ID, + workflowName: 'Customer support triage', + pausedAt: '2026-08-09T18:04:11.000Z', + points: [ + { + contextId: 'blk_approval', + blockId: 'blk_approval', + blockName: 'Manager approval', + kind: 'human', + status: 'paused', + registeredAt: '2026-08-09T18:04:11.000Z', + resumeAt: null, + parallel: null, + loop: null, + inputFields: [ + { name: 'approved', label: 'Approved', type: 'boolean', required: true }, + ], + display: null, + queuePosition: null, + }, + ], + }, + ], + nextCursor: null, + }, + ] + ), + } + ), defineOpenApiRoute( v2GetWorkflowRunContract, workflowRunOperation({ diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index a4c17155fba..af4c5ff12d1 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -263,6 +263,31 @@ const v2WorkflowInputFieldSchema = z description: 'An input field from an input-definition trigger in the saved draft.', }) +/** + * JSON Schema built from the active deployment's API start block. Open below the + * `object` wrapper: a field's schema follows its declared input type, and an + * event-shaped start block describes its payload rather than declared fields. + */ +export const v2WorkflowApiInputSchema = z + .object({ + type: z.string().describe('JSON Schema type of the `input` object; always `object`.'), + properties: z + .record(z.string(), z.unknown().describe('JSON Schema for one input field.')) + .optional() + .describe('Input field schemas keyed by field name.'), + required: z + .array(z.string().describe('Name of a required input field.')) + .optional() + .describe('Names of the input fields an API run requires.'), + }) + .catchall(z.unknown().describe('Additional JSON Schema keyword.')) + .meta({ + id: 'WorkflowApiInputSchema', + title: 'Workflow API input schema', + description: 'JSON Schema for the `input` an API run of the active deployment accepts.', + }) +export type V2WorkflowApiInputSchema = z.output + export const v2WorkflowDetailSchema = v2WorkflowListItemSchema .extend({ /** @@ -278,13 +303,19 @@ export const v2WorkflowDetailSchema = v2WorkflowListItemSchema inputs: z .array(v2WorkflowInputFieldSchema) .describe( - "Input fields from the saved draft's input-definition trigger; they may differ from the live deployment." + "Input fields from the saved draft's input-definition trigger; they may differ from the live deployment. Use `inputSchema` for what an API run accepts." + ), + inputSchema: v2WorkflowApiInputSchema + .nullable() + .describe( + 'JSON Schema for the `input` an API run of the active deployment accepts, including `required` fields. Null when the workflow is not deployed or its active deployment has no API entry point.' ), }) .meta({ id: 'WorkflowDetail', title: 'Workflow detail', - description: 'Workflow metadata, variables, and saved draft trigger input fields.', + description: + 'Workflow metadata, variables, saved draft trigger input fields, and the input schema of the active deployment.', }) export type V2WorkflowDetail = z.output @@ -1516,32 +1547,34 @@ export const v2ExecutionErrorSchema = z /** * Failing block, when attributable. Deliberately crosses the workspace boundary for shared/child workflows — the runId + block context is the reproducible handle a caller hands the workflow provider. * - * Attribution is produced at execution time from the failing block's throw - * site, so it reaches the caller only on the synchronous execute response. - * The polled run resource and the resume response reclassify a persisted - * error *string* — the log row keeps no structured error — so these three - * fields are absent there and the code collapses to `EXECUTION_FAILED`. - * That is a known asymmetry, documented rather than silently promised: - * a caller that needs the failing block reads the run's trace spans, which - * do carry `blockId`, `name`, and `type`. + * The synchronous execute and resume responses attribute the block at + * execution time. The polled run resource keeps only the error *string*, so + * it reads the block back from the run's recorded trace: the unhandled + * failure the stored error names, else the one that ended first, and none + * when concurrent failures cannot be told apart. When that trace is unavailable (not + * recorded, compacted, or withheld by the viewer's permission group) the + * three fields are absent and a block-level failure reports + * `EXECUTION_FAILED`. A failure its message classifies as run-level + * (timeout, cancellation, usage limit, invalid input) is not attributed + * there. */ blockId: z .string() .optional() .describe( - 'Identifier of the failing block. Present on the synchronous execute response only; the polled run resource and the resume response cannot attribute a block.' + 'Identifier of the failing block, when attributable. Get workflow run reads it from the run trace, so it is absent there when the trace is unavailable or hidden by organization permission groups, and for a timeout, cancellation, usage-limit, or invalid-input failure.' ), blockName: z .string() .optional() .describe( - 'Display name of the failing block. Present on the synchronous execute response only.' + 'Display name of the failing block, when attributable. Present whenever `blockId` is.' ), blockType: z .string() .optional() .describe( - 'Integration or block type that failed. Present on the synchronous execute response only.' + 'Integration or block type that failed, when attributable. Present whenever `blockId` is.' ), }) .meta({ @@ -1771,6 +1804,18 @@ export const v2ExecuteWorkflowBodySchema = z }) export type V2ExecuteWorkflowBody = z.input +/** + * The execution timeout a run was admitted under, in seconds. Execute workflow + * only: the resume responses are built from these schemas with it omitted. + */ +const v2AppliedExecutionTimeoutSchema = z + .number() + .positive() + .nullable() + .describe( + "Execution timeout applied to this run, in seconds: the plan's timeout, lowered by `executionTimeoutSeconds` when one was requested. Null when the run has no execution timeout." + ) + /** * The run result resource. In-band run failures are `status: 'failed'` * with a structured `error` — never an HTTP error: **a `runId` means 200/202 + @@ -1805,6 +1850,7 @@ export const v2ExecuteWorkflowDataSchema = z .describe('ISO 8601 timestamp when execution ended.') .meta({ format: 'date-time' }), durationMs: z.number().nonnegative().optional().describe('Execution duration in milliseconds.'), + executionTimeoutSeconds: v2AppliedExecutionTimeoutSchema, }) .meta({ id: 'WorkflowRunResult', @@ -1822,6 +1868,7 @@ export const v2ExecuteWorkflowQueuedSchema = z .object({ runId: v2WorkflowRunIdSchema, statusUrl: z.string().url().describe('Absolute URL of the workflow run resource.'), + executionTimeoutSeconds: v2AppliedExecutionTimeoutSchema, }) .meta({ id: 'QueuedWorkflowRun', @@ -1880,6 +1927,7 @@ export const v2ResumeWorkflowBodySchema = z export type V2ResumeWorkflowBody = z.input export const v2ResumeWorkflowQueuedSchema = v2ExecuteWorkflowQueuedSchema + .omit({ executionTimeoutSeconds: true }) .extend({ queuePosition: z .number() @@ -1895,7 +1943,16 @@ export const v2ResumeWorkflowQueuedSchema = v2ExecuteWorkflowQueuedSchema }) export type V2ResumeWorkflowQueued = z.output -export const v2ResumeWorkflowSyncResponseSchema = v2DataResponse(v2ExecuteWorkflowDataSchema) +export const v2ResumeWorkflowDataSchema = v2ExecuteWorkflowDataSchema + .omit({ executionTimeoutSeconds: true }) + .meta({ + id: 'WorkflowResumeResult', + title: 'Workflow resume result', + description: + 'Synchronous resumed run output and in-band execution status. Run failures are reported in band, not as HTTP errors, so branch on `status`.', + }) + +export const v2ResumeWorkflowSyncResponseSchema = v2DataResponse(v2ResumeWorkflowDataSchema) export const v2ResumeWorkflowQueuedResponseSchema = v2DataResponse(v2ResumeWorkflowQueuedSchema) export const v2ResumeWorkflowResponseSchema = z @@ -2184,8 +2241,8 @@ const v2WorkflowRunPausePointSchema = z * The polled run resource. `queued` is backfilled from the async job * queue before the worker writes the durable log row — v1's jobs endpoint 404 * window doesn't exist here. `error` is the same *shape* the execute response - * carries, but not the same content: this resource reclassifies the persisted - * error string, so it never attributes a block. See `v2ExecutionErrorSchema`. + * carries; its block is read back from the run's recorded trace rather than the + * throw site, so it is absent when that trace is. See `v2ExecutionErrorSchema`. */ export const v2WorkflowRunStatusSchema = z .object({ @@ -2236,7 +2293,7 @@ export const v2WorkflowRunStatusSchema = z points: z .array(v2WorkflowRunPausePointSchema) .describe( - 'Every point the run is paused at, earliest first, at most 100. Resume one with Resume workflow run, naming its `contextId` and supplying `input`.' + 'Every point the run is paused at, earliest first, at most 100; past 100, points not yet resumed are kept in preference to resumed ones. Resume one with Resume workflow run, naming its `contextId` and supplying `input`.' ), }) .nullable() @@ -2250,7 +2307,7 @@ export const v2WorkflowRunStatusSchema = z error: v2ExecutionErrorSchema .nullable() .describe( - 'Structured execution failure, or null when none occurred. Reclassified from the persisted error message, so `blockId`/`blockName`/`blockType` are absent and a block-level failure reports `EXECUTION_FAILED` here even when the same run reported `BLOCK_EXECUTION_FAILED` on its synchronous execute response.' + 'Structured execution failure, or null when none occurred. The failing block is read from the run trace, so `blockId`/`blockName`/`blockType` are absent, and a block-level failure reports `EXECUTION_FAILED`, when the trace is unavailable or hidden by organization permission groups.' ), /** Populated only with `includeOutput=true` on completed runs. */ output: z @@ -2401,6 +2458,65 @@ export const v2GetWorkflowRunContract = defineRouteContract({ }, }) +/** Most pause points one page of the paused-run list carries across all of its runs. */ +export const V2_MAX_PAUSED_RUN_POINTS_PER_PAGE = 100 + +const v2PausedRunSortFields = ['pausedAt'] as const + +const v2ListPausedRunsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace whose paused runs should be listed.'), + includeOutput: booleanQueryFlagSchema + .optional() + .default(false) + .describe("Include each pause point's reviewer `display` content when true."), + ...v2PaginationFields({ + max: 25, + fallback: 10, + description: `Maximum runs to return per page. A page also ends early once it holds ${V2_MAX_PAUSED_RUN_POINTS_PER_PAGE} pause points, so it can return fewer runs while \`nextCursor\` is set.`, + }), + ...v2SortFields(v2PausedRunSortFields, { sortBy: 'pausedAt', sortOrder: 'desc' }), + }) + .strict() + .meta({ + id: 'ListPausedRunsQuery', + title: 'List paused runs query', + description: 'Workspace, output, sorting, and pagination controls.', + }) + +const v2PausedRunSchema = z + .object({ + runId: v2WorkflowRunIdSchema, + workflowId: z.string().describe('Workflow that produced the run.'), + workflowName: z.string().describe('Name of the workflow that produced the run.'), + pausedAt: z + .string() + .describe('ISO 8601 timestamp when the run entered the paused state.') + .meta({ format: 'date-time' }), + points: z + .array(v2WorkflowRunPausePointSchema) + .describe( + 'Human pause points still waiting for a resume, earliest first, at most 100. Resume one with Resume workflow run, naming its `contextId` and supplying `input`. Get workflow run lists every point of the run, including time pauses and points already resuming.' + ), + }) + .meta({ + id: 'PausedRun', + title: 'Paused run', + description: 'A run waiting on human input, with the points it waits at.', + }) + +export type V2PausedRun = z.output + +export const v2ListPausedRunsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/runs/paused', + query: v2ListPausedRunsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2PausedRunSchema), + }, +}) + export const v2CancelWorkflowRunDataSchema = z .object({ success: z diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 8a23a8ce148..1b04a5f6016 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -315,6 +315,7 @@ import { v2GetWorkflowStateContract, v2GetWorkflowVersionContract, v2ImportWorkflowContract, + v2ListPausedRunsContract, v2ListWorkflowFoldersContract, v2ListWorkflowRunsContract, v2ListWorkflowsContract, @@ -1300,7 +1301,7 @@ export const V2_MCP_OPERATIONS = { contract: v2GetBillingStatusContract, summary: 'Get billing status', description: - "Get the current plan, billing standing, credit allowance, and storage quota. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both. Use List billing logs for credit history.\n\nOAuth scope: `api:read`.", + "Get the current plan, billing standing, credit allowance, storage quota, and plan limits on runs. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both, but every caller reads `limits`. Use List billing logs for credit history.\n\nOAuth scope: `api:read`.", handler: () => import('@/app/api/v2/billing/status/route').then((route) => route.GET), }, getBlock: { @@ -1633,7 +1634,7 @@ export const V2_MCP_OPERATIONS = { contract: v2GetWorkflowContract, summary: 'Get workflow', description: - 'Get workflow metadata, variables, and trigger input fields from the saved draft. Requires workspace read access. Input fields may differ from the live deployment. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + 'Get workflow metadata, variables, and trigger input fields from the saved draft. Requires workspace read access. Input fields may differ from the live deployment; `inputSchema` describes the `input` an API run of the active deployment accepts. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/workflows/[workflowId]/route').then((route) => route.GET), }, getWorkflowChatDeployment: { @@ -2064,6 +2065,13 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + listPausedRuns: { + contract: v2ListPausedRunsContract, + summary: 'List paused workflow runs', + description: + "List a workspace's runs waiting on human input, with workspace read access. Each run lists the human pause points it still waits at, exactly as Get workflow run shows them; resume one with Resume workflow run. Request each point's reviewer content with `includeOutput`. Organization permission groups can hide pause fields. A page ends early, with a cursor, once it holds 100 pause points.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/workflows/runs/paused/route').then((route) => route.GET), + }, listPermissionGroupMembers: { contract: v2ListPermissionGroupMembersContract, summary: 'List permission group members', diff --git a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts index cbcddae01a5..2419cea30ab 100644 --- a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts +++ b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts @@ -899,6 +899,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/workflows/move', load: () => import('@/app/api/v2/workflows/move/route'), }, + { + pattern: '/api/v2/workflows/runs/paused', + load: () => import('@/app/api/v2/workflows/runs/paused/route'), + }, { pattern: '/api/v2/workspaces', load: () => import('@/app/api/v2/workspaces/route'), diff --git a/apps/sim/lib/billing/application/get-billing-status.ts b/apps/sim/lib/billing/application/get-billing-status.ts index b11b3bee4b1..2bb14139939 100644 --- a/apps/sim/lib/billing/application/get-billing-status.ts +++ b/apps/sim/lib/billing/application/get-billing-status.ts @@ -6,8 +6,10 @@ import { checkBillingEntityBlocked, checkUsageStatus, } from '@/lib/billing/calculations/usage-monitor' +import { getBillingConcurrencyLimit } from '@/lib/billing/concurrency-limits' import { checkAttributedBillingBlocks, + readEnterpriseLimitOverrides, resolveBillingAttribution, resolveSystemBillingAttribution, toUsageLimitSubscription, @@ -28,6 +30,8 @@ import { getUserStorageUsage, resolveStorageBillingContext, } from '@/lib/billing/storage' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { executionTimeoutSeconds, getExecutionTimeout } from '@/lib/core/execution-limits/types' export interface GetBillingStatusInput { workspaceId?: string @@ -45,6 +49,19 @@ export interface BillingStorageStatus { percentUsed: number } +/** + * The payer's plan caps on a run. Plan caps, not consumption, so every caller + * that reads the plan reads them too. A null value is not enforced. + */ +interface BillingPlanLimits { + /** Longest a synchronous run may execute, in seconds. */ + maxSyncExecutionTimeoutSeconds: number | null + /** Longest an asynchronous run may execute, in seconds; a request can only lower it. */ + maxAsyncExecutionTimeoutSeconds: number | null + /** Most runs the payer can have executing at once, across everything it funds. */ + maxConcurrentExecutions: number | null +} + /** * `credits` and `storage` describe the resolved payer's pooled allowances, not * the caller's own consumption, so they are only projected to a caller who may @@ -60,6 +77,35 @@ export interface BillingStatusResult { status: 'active' | 'limit_exceeded' | 'billing_blocked' credits: BillingCreditsStatus | null storage: BillingStorageStatus | null + limits: BillingPlanLimits +} + +/** + * The caps execution admission applies for a payer on `plan`: the same + * resolvers preprocessing and usage reservation call, so the two cannot drift. + * Concurrency is enforced only by hosted billing. + */ +function planLimits( + plan: string | undefined, + overrides: { + enterpriseConcurrencyLimit?: number | null + enterpriseWorkflowExecutionTimeoutSeconds?: number | null + } +): BillingPlanLimits { + return { + maxSyncExecutionTimeoutSeconds: executionTimeoutSeconds(getExecutionTimeout(plan, 'sync')), + maxAsyncExecutionTimeoutSeconds: executionTimeoutSeconds( + getExecutionTimeout( + plan, + 'async', + overrides.enterpriseWorkflowExecutionTimeoutSeconds ?? undefined + ) + ), + maxConcurrentExecutions: + isHosted && isBillingEnabled + ? getBillingConcurrencyLimit(plan, overrides.enterpriseConcurrencyLimit) + : null, + } } function storageStatus(usedBytes: number, limitBytes: number): BillingStorageStatus { @@ -179,6 +225,10 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', credits: canViewPayerPool ? creditsStatus(usage) : null, storage, + limits: planLimits( + attribution.payerSubscription?.plan, + attribution.payerSubscription ?? {} + ), } } @@ -209,6 +259,10 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ : 'active', credits: canViewPayerPool ? creditsStatus(usage) : null, storage, + limits: planLimits( + subscription?.plan, + subscription ? readEnterpriseLimitOverrides(subscription) : {} + ), } }, }) diff --git a/apps/sim/lib/billing/core/attribution.ts b/apps/sim/lib/billing/core/attribution.ts index 0ed9a8da747..67681fccd49 100644 --- a/apps/sim/lib/billing/core/attribution.ts +++ b/apps/sim/lib/billing/core/attribution.ts @@ -147,19 +147,33 @@ type ResolvedPayerSubscription = | Awaited> | Awaited> +/** + * The concurrency and async execution-timeout overrides an Enterprise + * subscription's metadata sets, or null for each one it does not. + */ +export function readEnterpriseLimitOverrides(subscription: { plan: string; metadata: unknown }): { + enterpriseConcurrencyLimit: number | null + enterpriseWorkflowExecutionTimeoutSeconds: number | null +} { + const { metadata } = subscription + if (!isEnterprise(subscription.plan) || !isRecordLike(metadata)) { + return { enterpriseConcurrencyLimit: null, enterpriseWorkflowExecutionTimeoutSeconds: null } + } + return { + enterpriseConcurrencyLimit: parseBillingConcurrencyLimit(metadata.concurrencyLimit), + enterpriseWorkflowExecutionTimeoutSeconds: parseWorkflowExecutionTimeoutSeconds( + metadata.workflowExecutionTimeoutSeconds + ), + } +} + function serializeSubscription( subscription: NonNullable | null ): PayerSubscriptionSnapshot | null { if (!subscription) return null - const enterpriseConcurrencyLimit = - isEnterprise(subscription.plan) && isRecordLike(subscription.metadata) - ? parseBillingConcurrencyLimit(subscription.metadata.concurrencyLimit) - : null - const enterpriseWorkflowExecutionTimeoutSeconds = - isEnterprise(subscription.plan) && isRecordLike(subscription.metadata) - ? parseWorkflowExecutionTimeoutSeconds(subscription.metadata.workflowExecutionTimeoutSeconds) - : null + const { enterpriseConcurrencyLimit, enterpriseWorkflowExecutionTimeoutSeconds } = + readEnterpriseLimitOverrides(subscription) const billingInterval = subscription.billingInterval === 'month' || subscription.billingInterval === 'year' ? subscription.billingInterval diff --git a/apps/sim/lib/core/execution-limits/types.ts b/apps/sim/lib/core/execution-limits/types.ts index ade84a929e6..a9e4809ef05 100644 --- a/apps/sim/lib/core/execution-limits/types.ts +++ b/apps/sim/lib/core/execution-limits/types.ts @@ -114,6 +114,11 @@ export function getMaxExecutionTimeout(): number { return MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS * 1000 } +/** A timeout in milliseconds as the API reports it: seconds, or null when untimed (`0`). */ +export function executionTimeoutSeconds(timeoutMs: number): number | null { + return timeoutMs > 0 ? timeoutMs / 1000 : null +} + /** Applies an async API request cap without allowing it to exceed account policy. */ export function resolveAsyncExecutionTimeout( policyTimeoutMs: number, diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 4c4a8211d40..92d947b6c37 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildTraceSpans, hasUnhandledError } from '@/lib/logs/execution/trace-spans/trace-spans' +import { + buildTraceSpans, + findFailedTraceBlock, + hasUnhandledError, +} from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' import type { ExecutionResult } from '@/executor/types' @@ -1481,3 +1485,160 @@ describe('child span trees that were compacted into references', () => { expect(traceSpans[0].children?.[0].output).toEqual({ result: 'done' }) }) }) + +describe('findFailedTraceBlock', () => { + const span = (overrides: Partial): TraceSpan => ({ + id: overrides.blockId ?? 'span', + name: overrides.blockId ?? 'span', + type: 'function', + duration: 1, + startTime: '2024-01-01T10:00:00.000Z', + endTime: '2024-01-01T10:00:01.000Z', + ...overrides, + }) + const root = (children: TraceSpan[]): TraceSpan[] => [ + span({ + id: 'workflow-execution', + name: 'Workflow Execution', + type: 'workflow', + status: 'error', + children, + }), + ] + + it('skips a failure an error path handled', () => { + const spans = root([ + span({ blockId: 'handled', status: 'error', errorHandled: true, executionOrder: 2 }), + span({ blockId: 'failed', name: 'Lookup', type: 'api', status: 'error', executionOrder: 1 }), + ]) + + expect(findFailedTraceBlock(spans)).toEqual({ + blockId: 'failed', + blockName: 'Lookup', + blockType: 'api', + }) + }) + + /** + * Parallel branches: A started first, B later, and A's failure settled first, so the run + * stored A's error. B, still in flight, failed after it. + */ + const concurrentFailures = () => + root([ + span({ + blockId: 'a', + name: 'Agent A', + status: 'error', + executionOrder: 2, + endTime: '2024-01-01T10:00:01.000Z', + }), + span({ + blockId: 'b', + name: 'Agent B', + status: 'error', + executionOrder: 3, + endTime: '2024-01-01T10:00:02.000Z', + }), + ]) + + it('names the failure the stored run error belongs to', () => { + expect(findFailedTraceBlock(concurrentFailures(), 'Agent A: 429')?.blockId).toBe('a') + expect(findFailedTraceBlock(concurrentFailures(), 'Agent B: timeout')?.blockId).toBe('b') + }) + + it('falls back to the failure that ended first when the error names no block', () => { + expect(findFailedTraceBlock(concurrentFailures(), 'upstream returned 500')?.blockId).toBe('a') + }) + + it('names no block when concurrent failures cannot be told apart', () => { + const spans = root([ + span({ blockId: 'a', name: 'Agent A', status: 'error' }), + span({ blockId: 'b', name: 'Agent B', status: 'error' }), + ]) + + expect(findFailedTraceBlock(spans, 'upstream returned 500')).toBeNull() + }) + + it('names the block inside a loop rather than the loop container', () => { + const spans = root([ + span({ + blockId: 'loop-1', + type: 'loop', + status: 'error', + children: [span({ blockId: 'inner', status: 'error', executionOrder: 4 })], + }), + ]) + + expect(findFailedTraceBlock(spans)?.blockId).toBe('inner') + }) + + it('finds an unhandled failure nested under a block that succeeded', () => { + const spans = root([ + span({ + blockId: 'loop-1', + type: 'loop', + status: 'success', + children: [span({ blockId: 'inner', status: 'error', executionOrder: 4 })], + }), + ]) + + expect(findFailedTraceBlock(spans)?.blockId).toBe('inner') + }) + + it("does not attribute a successful boundary block's children", () => { + const spans = root([ + span({ + blockId: 'agent-run', + type: 'mothership', + status: 'success', + children: [span({ blockId: 'inner', status: 'error' })], + }), + ]) + + expect(findFailedTraceBlock(spans)).toBeNull() + }) + + it("attributes a child workflow's failure to the workflow block that ran it", () => { + const spans = root([ + span({ + blockId: 'child-runner', + type: 'workflow', + status: 'error', + executionOrder: 1, + children: [span({ blockId: 'child-block', status: 'error', executionOrder: 7 })], + }), + ]) + + expect(findFailedTraceBlock(spans)).toEqual({ + blockId: 'child-runner', + blockName: 'child-runner', + blockType: 'workflow', + }) + }) + + it("never names a block from a custom block's source workflow", () => { + const spans = root([ + span({ + blockId: 'custom-1', + name: 'Enrich lead', + type: 'custom_block_abc123', + status: 'error', + executionOrder: 1, + childExecutionId: 'source-run', + children: [span({ blockId: 'source-block', status: 'error', executionOrder: 9 })], + }), + ]) + + expect(findFailedTraceBlock(spans)).toEqual({ + blockId: 'custom-1', + blockName: 'Enrich lead', + blockType: 'custom_block_abc123', + }) + }) + + it('names no block when the trace is absent or unreadable', () => { + expect(findFailedTraceBlock(undefined)).toBeNull() + expect(findFailedTraceBlock({ ref: 'externalized' })).toBeNull() + expect(findFailedTraceBlock(root([span({ blockId: 'ok', status: 'success' })]))).toBeNull() + }) +}) diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index f0c8dde8f82..3407531f18e 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -1,8 +1,11 @@ +import { isRecordLike, toArray } from '@sim/utils/object' import { isUserFile } from '@/lib/core/utils/user-file' import { createSpanFromLog } from '@/lib/logs/execution/trace-spans/factory' import { groupIterationBlocks } from '@/lib/logs/execution/trace-spans/iteration-grouping' import type { TraceSpan } from '@/lib/logs/types' +import { isSubExecutionBlockType } from '@/executor/constants' import type { BlockLog, ExecutionResult } from '@/executor/types' +import { errorNamesBlock } from '@/executor/utils/errors' /** * Keys that should be recursively filtered from output display. @@ -173,6 +176,15 @@ function addRelativeTimestamps(spans: TraceSpan[], workflowStartMs: number): voi } } +/** A block that succeeded and owns its children's errors, so none of them is the run's failure. */ +function isSuccessfulErrorBoundary(status: unknown, type: unknown): boolean { + return ( + status === 'success' && + typeof type === 'string' && + SUCCESSFUL_CHILD_ERROR_BOUNDARY_BLOCK_TYPES.has(type) + ) +} + /** Options for {@link hasUnhandledError}. */ export interface UnhandledErrorOptions { /** @@ -191,9 +203,7 @@ export interface UnhandledErrorOptions { */ export function hasUnhandledError(span: TraceSpan, options?: UnhandledErrorOptions): boolean { if (span.status === 'error' && !span.errorHandled) return true - if (span.status === 'success' && SUCCESSFUL_CHILD_ERROR_BOUNDARY_BLOCK_TYPES.has(span.type)) { - return false - } + if (isSuccessfulErrorBoundary(span.status, span.type)) return false if (span.children?.length) { return span.children.some((child) => hasUnhandledError(child, options)) } @@ -211,6 +221,75 @@ export function traceSpansIndicateFailure( return spans?.some((span) => hasUnhandledError(span, options)) ?? false } +/** The block a recorded run failed in, as its trace names it. */ +export interface FailedTraceBlock { + blockId: string + blockName: string + blockType: string +} + +interface FailedSpanCandidate { + block: FailedTraceBlock + endedAt: number +} + +/** The innermost unhandled-error block spans: a container whose failure came from a block inside it yields that block. */ +function collectFailedBlockSpans(nodes: unknown, into: FailedSpanCandidate[]): void { + for (const span of toArray(nodes)) { + if (!isRecordLike(span)) continue + const isError = span.status === 'error' + const blockId = typeof span.blockId === 'string' ? span.blockId : null + const blockType = typeof span.type === 'string' ? span.type : undefined + const before = into.length + const isBoundary = + isSubExecutionBlockType(blockType) || isSuccessfulErrorBoundary(span.status, blockType) + if (!blockId || !isBoundary) collectFailedBlockSpans(span.children, into) + if (isError && span.errorHandled !== true && blockId && blockType && into.length === before) { + into.push({ + block: { + blockId, + blockName: typeof span.name === 'string' ? span.name : blockId, + blockType, + }, + endedAt: typeof span.endTime === 'string' ? Date.parse(span.endTime) : Number.NaN, + }) + } + } +} + +/** + * The block a recorded run failed in, read back from its persisted trace spans. + * + * A run stores the error of the failure that settled first, while branches already + * in flight may fail after it. So the span whose block name heads the stored error + * (`Block name: message`, as a block failure is recorded) is the one named; failing + * that, the failure that ended first; and when neither singles one out, none, so a + * message is never paired with another block's identity. + * + * Spans are walked as {@link hasUnhandledError} walks them, including under a block + * that succeeded, and the same boundaries hold: a successful boundary block's + * children are not its failure. The spans under a workflow or custom block are + * another workflow's and are not descended into, so its failure is attributed to the + * block that ran it, as it is at execution time, and a custom block's source workflow + * is never named to its consumer. + * + * Reads stored, untyped spans structurally and never their input or output. Null when + * the trace has no such span, including when it was not recorded, was compacted, or is + * unavailable. + */ +export function findFailedTraceBlock(spans: unknown, runError?: string): FailedTraceBlock | null { + const candidates: FailedSpanCandidate[] = [] + collectFailedBlockSpans(spans, candidates) + const named = runError + ? candidates.filter((span) => errorNamesBlock(runError, span.block.blockName)) + : [] + const pool = named.length > 0 ? named : candidates + if (pool.length <= 1) return pool[0]?.block ?? null + if (!pool.every((span) => Number.isFinite(span.endedAt))) return null + const [first, second] = pool.sort((a, b) => a.endedAt - b.endedAt) + return first.endedAt < second.endedAt ? first.block : null +} + interface DurationBearingSpan { duration?: number durationMs?: number | null diff --git a/apps/sim/lib/workflows/application/list-paused-runs.ts b/apps/sim/lib/workflows/application/list-paused-runs.ts new file mode 100644 index 00000000000..c02c7f7d2d8 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-paused-runs.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { V2_MAX_PAUSED_RUN_POINTS_PER_PAGE } from '@/lib/api/contracts/v2/workflows' +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' +import { logProjectionSubjectUserId, resolveLogFieldProjection } from '@/lib/logs/projection' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { isWaitingHumanPausePoint } from '@/lib/workflows/executor/execution-state' +import { + type PausedRunPoint, + projectPausedRunPoints, + readPausedRunPoints, +} from '@/lib/workflows/executor/execution-status' +import { listWorkspacePausedRuns, pausedRunCursorKeys } from '@/lib/workflows/executor/paused-runs' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' + +const logger = createLogger('ListPausedRuns') + +/** Runs whose points are read together; each read is two short queries. */ +const POINT_READ_CONCURRENCY = 5 + +interface ListPausedRunsInput { + workspaceId: string + includeOutput: boolean + sortOrder: ListSortOrder + cursorKeys?: CursorKey[] + limit: number +} + +interface PausedRun { + runId: string + workflowId: string + workflowName: string + pausedAt: Date + points: PausedRunPoint[] +} + +/** + * Lists a workspace's runs waiting on a human, each with the human pause points + * it still waits at. + * + * Each point is read and projected as Get workflow run reads and projects it, + * so the list never shows a viewer more of a point than the run detail would. A + * run lists only the human points it still waits at, selected before the + * per-run cap so they match the predicate the run was listed by. A page ends early, with a cursor, rather than carry more than + * {@link V2_MAX_PAUSED_RUN_POINTS_PER_PAGE} points; its first run is always + * included, and a run's own points are already capped. + */ +export const listPausedRuns = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.listPausedRuns, + resolveContext: ({ input }: { input: ListPausedRunsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const [page, projection] = await Promise.all([ + listWorkspacePausedRuns({ + workspaceId: context.workspaceId, + sortOrder: input.sortOrder, + cursorKeys: input.cursorKeys, + limit: input.limit, + }), + resolveLogFieldProjection( + logProjectionSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ), + ]) + + /** + * Points are read a wave of runs at a time rather than one run at a time, and no + * further wave starts once the page's point budget is spent, so a page cut short + * reads at most one wave it then drops. + */ + const runs: PausedRun[] = [] + let pointCount = 0 + let nextCursorKeys = page.nextCursorKeys + let cut = false + for (let start = 0; start < page.data.length && !cut; start += POINT_READ_CONCURRENCY) { + const wave = page.data.slice(start, start + POINT_READ_CONCURRENCY) + const pointsByRun = await Promise.all( + wave.map((row) => + readPausedRunPoints({ + workflowId: row.workflowId, + executionId: row.executionId, + pausePoints: row.pausePoints, + only: isWaitingHumanPausePoint, + }) + ) + ) + for (const [offset, row] of wave.entries()) { + const points = projectPausedRunPoints(pointsByRun[offset] ?? [], { + projection, + includeOutput: input.includeOutput, + }) + if (runs.length > 0 && pointCount + points.length > V2_MAX_PAUSED_RUN_POINTS_PER_PAGE) { + nextCursorKeys = pausedRunCursorKeys(page.data[start + offset - 1]) + cut = true + break + } + pointCount += points.length + runs.push({ + runId: row.executionId, + workflowId: row.workflowId, + workflowName: row.workflowName, + pausedAt: row.pausedAt, + points, + }) + } + } + + logger.info('Listed paused runs', { + workspaceId: context.workspaceId, + count: runs.length, + principalKind: principal.kind, + }) + return { runs, nextCursorKeys } + }, +}) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 4f2e9bfb0fb..17fb9b4b2a1 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -535,6 +535,16 @@ export const workflowOperations = { contentDelivery: 'sourced', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: listing a workspace's paused runs is governed by workspace role, as reading one run is; logs.trace_spans withholds the pause points' resolved fields inside the list, not the right to read it + listPausedRuns: defineWorkspaceOperation({ + id: 'workflows.runs.list_paused', + oauthScope: 'api:read', + minimumRole: 'read', + workspaceApiKey: 'allow', + capability: 'none', + contentDelivery: 'none', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), // permission-group-exempt: a paused execution's detail is pause points and resume state, not the run's execution data — the fields logs.cost and logs.trace_spans withhold never appear here readPausedExecution: defineWorkspaceOperation({ id: 'workflows.paused_executions.read', diff --git a/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts b/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts index 8675835f357..9d1a2dd89fe 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts @@ -2,7 +2,8 @@ * A deployed run that pauses at a human-in-the-loop block, executed in-process against real * PostgreSQL: the pause's reviewer content is stored and read back with the run's resolved * secret redacted, and the run detail withholds that content on the same terms as the run's - * output, while the pause's input fields stay readable. + * output, while the pause's input fields stay readable. The workspace's paused-run list shows + * each point on exactly the same terms. */ import { db } from '@sim/db' import { @@ -29,6 +30,7 @@ import { eq, inArray } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { encryptSecret } from '@/lib/core/security/encryption' import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow' +import { listPausedRuns } from '@/lib/workflows/application/list-paused-runs' import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import type { PausePoint } from '@/executor/types' @@ -314,3 +316,162 @@ describe('a deployed run paused at a human-in-the-loop block', () => { ]) }) }) + +describe("the workspace's paused-run list", () => { + function listRuns( + principal: typeof owner, + options: { includeOutput: boolean; limit?: number; cursorKeys?: (string | number)[] } + ) { + return listPausedRuns.execute({ + principal, + input: { + workspaceId, + includeOutput: options.includeOutput, + sortOrder: 'desc', + limit: options.limit ?? 25, + cursorKeys: options.cursorKeys, + }, + }) + } + + it('lists a run waiting on a human with the points its run detail shows', async () => { + const runId = await pausedRun() + + const [page, run] = await Promise.all([ + listRuns(owner, { includeOutput: true }), + readRun(owner, runId, { includeOutput: true }), + ]) + const listed = page.runs.find((entry) => entry.runId === runId) + expect(listed).toMatchObject({ workflowId, workflowName: 'Paused run fixture' }) + expect(listed?.points).toEqual(run.pausePoints) + expect(JSON.stringify(page)).not.toContain(SECRET) + }) + + it('withholds reviewer content and resolved input fields from a viewer whose group hides execution data', async () => { + const runId = await pausedRun() + + const page = await listRuns(restricted, { includeOutput: true }) + const listed = page.runs.find((entry) => entry.runId === runId) + expect(listed?.points).toEqual([ + expect.objectContaining({ contextId: 'approval', display: null, inputFields: null }), + ]) + }) + + it('lists a legacy run whose pause points are stored as an array', async () => { + const runId = generateId() + await db.insert(pausedExecutions).values({ + id: generateId(), + workflowId, + executionId: runId, + executionSnapshot: {}, + pausePoints: [ + { + contextId: 'legacy-approval', + blockId: 'approval', + registeredAt: new Date().toISOString(), + resumeStatus: 'paused', + snapshotReady: true, + response: {}, + }, + ], + totalPauseCount: 1, + status: 'paused', + }) + + const page = await listRuns(owner, { includeOutput: false }) + const listed = page.runs.find((entry) => entry.runId === runId) + expect(listed?.points).toEqual([ + expect.objectContaining({ contextId: 'legacy-approval', kind: 'human', status: 'paused' }), + ]) + }) + + it('leaves out time-only pauses, archived workflows, and other workspaces', async () => { + const humanPoint = { + contextId: 'approval', + blockId: 'approval', + registeredAt: new Date().toISOString(), + resumeStatus: 'paused', + snapshotReady: true, + response: {}, + } + const pausedRow = (rowWorkflowId: string, pausePoints: unknown) => ({ + id: generateId(), + workflowId: rowWorkflowId, + executionId: generateId(), + executionSnapshot: {}, + pausePoints, + totalPauseCount: 1, + status: 'paused', + }) + const now = new Date() + const archivedWorkflowId = generateId() + const otherWorkspaceId = generateId() + const otherWorkflowId = generateId() + await insertWorkspaceFixture(db, { + id: otherWorkspaceId, + name: 'Other paused run fixture', + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + }) + await db.insert(workflow).values([ + { + id: archivedWorkflowId, + userId: ownerId, + workspaceId, + name: 'Archived paused run fixture', + archivedAt: now, + lastSynced: now, + createdAt: now, + updatedAt: now, + }, + { + id: otherWorkflowId, + userId: ownerId, + workspaceId: otherWorkspaceId, + name: 'Other workspace paused run fixture', + lastSynced: now, + createdAt: now, + updatedAt: now, + }, + ]) + const timeOnly = pausedRow(workflowId, { + wait: { ...humanPoint, contextId: 'wait', pauseKind: 'time', resumeAt: now.toISOString() }, + }) + const archived = pausedRow(archivedWorkflowId, { approval: humanPoint }) + const otherWorkspace = pausedRow(otherWorkflowId, { approval: humanPoint }) + try { + await db.insert(pausedExecutions).values([timeOnly, archived, otherWorkspace]) + + const page = await listRuns(owner, { includeOutput: false }) + const listedIds = page.runs.map((entry) => entry.runId) + expect(listedIds).not.toContain(timeOnly.executionId) + expect(listedIds).not.toContain(archived.executionId) + expect(listedIds).not.toContain(otherWorkspace.executionId) + } finally { + await db + .delete(pausedExecutions) + .where(inArray(pausedExecutions.workflowId, [archivedWorkflowId, otherWorkflowId])) + await db.delete(workflow).where(inArray(workflow.id, [archivedWorkflowId, otherWorkflowId])) + await deleteWorkspaceFixture(db, eq(workspace.id, otherWorkspaceId)) + } + }) + + it('pages through the waiting runs without repeating one', async () => { + await pausedRun() + await pausedRun() + + const first = await listRuns(owner, { includeOutput: false, limit: 1 }) + expect(first.runs).toHaveLength(1) + expect(first.nextCursorKeys).not.toBeNull() + const second = await listRuns(owner, { + includeOutput: false, + limit: 1, + cursorKeys: first.nextCursorKeys ?? undefined, + }) + expect(second.runs).toHaveLength(1) + expect(second.runs[0].runId).not.toBe(first.runs[0].runId) + expect(second.runs[0].pausedAt.getTime()).toBeLessThanOrEqual(first.runs[0].pausedAt.getTime()) + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index c23d1f4a91b..d0f1dc53767 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -17,6 +17,7 @@ import { } from '@/lib/workflows/executor/execution-run-files' import { getProjectedWorkflowExecutionStatus, + projectPausedRunPoints, readPausedRunPoints, } from '@/lib/workflows/executor/execution-status' @@ -85,7 +86,13 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ viewerUserId: logProjectionSubjectUserId(principal), }) if (!projected) throw new OrchestrationError('not_found', 'Run not found') - const { status, projection, stateExecutionId, pausePoints: loadedPausePoints } = projected + const { + status, + projection, + stateExecutionId, + pausePoints: loadedPausePoints, + failedBlock, + } = projected /** * A run whose `blockOutputs` the viewer's group withholds joins the same @@ -156,31 +163,19 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ * `contextId`, not only the earliest one `paused` summarizes. Read from the * execution whose state answered the status, so a resume attempt reporting its * parent's pause lists the parent's points. - * - * A point's `display` is what the block rendered from upstream outputs, so it - * follows `finalOutput`'s gating: withheld unless output was asked for and the - * viewer's group shows execution data. `inputFields` are resolved before the - * pause is captured, so a label or description can carry upstream values too: - * they are withheld from a viewer whose group hides execution data. - * - * permission-group-enforced: logs.trace_spans */ - const showsDisplay = input.includeOutput && !projection.hideTraceSpans const pausePoints = status.paused - ? ( + ? projectPausedRunPoints( (await readPausedRunPoints({ workflowId: context.workflowId, executionId: stateExecutionId, pausePoints: loadedPausePoints ?? undefined, - })) ?? [] - ).map((point) => ({ - ...point, - display: showsDisplay ? point.display : null, - inputFields: projection.hideTraceSpans ? null : point.inputFields, - })) + })) ?? [], + { projection, includeOutput: input.includeOutput } + ) : null - return { ...status, files, pausePoints } + return { ...status, files, pausePoints, failedBlock } } catch (error) { if (error instanceof FunctionalOutputsUnavailableError) { throw new OrchestrationError('conflict', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) diff --git a/apps/sim/lib/workflows/application/read-workflow.ts b/apps/sim/lib/workflows/application/read-workflow.ts index d224c13e19a..02fa6f35e1c 100644 --- a/apps/sim/lib/workflows/application/read-workflow.ts +++ b/apps/sim/lib/workflows/application/read-workflow.ts @@ -1,4 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + type V2WorkflowApiInputSchema, + v2WorkflowApiInputSchema, +} from '@/lib/api/contracts/v2/workflows' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' @@ -7,7 +12,12 @@ import { workflowFolderPathForId } from '@/lib/workflows/application/folders' import { workflowOperations } from '@/lib/workflows/application/operations' import { resolvePrincipalWorkflowContext } from '@/lib/workflows/application/principal-scope' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { + loadDeployedWorkflowState, + NoActiveDeploymentError, +} from '@/lib/workflows/persistence/utils' import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' +import { resolveApiRunInputSchema } from '@/lib/workflows/triggers/run-options' const logger = createLogger('ReadWorkflow') @@ -16,6 +26,45 @@ export interface ReadWorkflowInput { assertedWorkspaceId?: string } +/** + * JSON Schema for the `input` an API run of the active deployment accepts, or + * null when the workflow has no active deployment or it has no API entry point. + * + * Read from the active version row rather than gated on the workflow's + * `isDeployed` flag, so the two can never disagree; an undeployed workflow + * costs one indexed lookup that finds no active version. + * + * Reading a workflow never depended on its deployment, so a deployment that + * cannot be read, or a schema of an unexpected shape, reports null rather than + * failing the read. + */ +async function loadDeployedApiInputSchema( + workflowId: string, + workspaceId: string +): Promise { + let schema: Record | null + try { + schema = resolveApiRunInputSchema( + (await loadDeployedWorkflowState(workflowId, workspaceId)).blocks + ) + } catch (error) { + if (!(error instanceof NoActiveDeploymentError)) { + logger.warn('Deployed input schema unavailable', { + workflowId, + error: getErrorMessage(error), + }) + } + return null + } + if (!schema) return null + const parsed = v2WorkflowApiInputSchema.safeParse(schema) + if (!parsed.success) { + logger.warn('Deployed input schema has an unexpected shape', { workflowId }) + return null + } + return parsed.data +} + async function loadWorkflowFolderPath(workspaceId: string, folderId: string | null) { const index = await loadActiveFolderPathIndex(workspaceId, 'workflow', undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE, @@ -44,7 +93,10 @@ export const readWorkflow = defineAuthorizedWorkflowUseCase({ if (!workflow || workflow.archivedAt || workflow.workspaceId !== context.workspaceId) { throw new OrchestrationError('not_found', 'Workflow not found') } - const folderPath = await loadWorkflowFolderPath(context.workspaceId, workflow.folderId) + const [folderPath, inputSchema] = await Promise.all([ + loadWorkflowFolderPath(context.workspaceId, workflow.folderId), + loadDeployedApiInputSchema(context.workflowId, context.workspaceId), + ]) const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) logger.info('Read workflow', { workspaceId: context.workspaceId, @@ -55,6 +107,7 @@ export const readWorkflow = defineAuthorizedWorkflowUseCase({ workflow, workspaceId: context.workspaceId, inputs, + inputSchema, folderPath, } }, diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index cab18957181..92c394a4ac3 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -175,13 +175,22 @@ export interface ExecuteWorkflowServiceRun { startedAt?: string endedAt?: string durationMs?: number + /** The execution timeout applied to the run in milliseconds; `0` means untimed. */ + executionTimeoutMs: number } +/** A run's result before the applied timeout is stamped onto it. */ +type UnstampedTerminalResult = + | Omit + | ExecuteWorkflowServiceFailureResult + export interface ExecuteWorkflowServiceQueued { ok: true queued: true executionId: string jobId: string + /** The execution timeout the queued run will run under in milliseconds; `0` means untimed. */ + executionTimeoutMs: number } export interface ExecuteWorkflowServiceStream { @@ -201,6 +210,8 @@ export interface ExecuteWorkflowServicePendingRun { /** Resolves once the run can be found by its id; never, when its log could not start. */ logged: Promise cancel: () => void + /** The execution timeout applied to the run in milliseconds; `0` means untimed. */ + executionTimeoutMs: number } export interface ExecuteWorkflowServiceFailureResult { @@ -495,7 +506,13 @@ export async function executeWorkflowService( executionId, }) } - return { ok: true, queued: true, executionId, jobId: enqueue.jobId } + return { + ok: true, + queued: true, + executionId, + jobId: enqueue.jobId, + executionTimeoutMs: preprocessResult.executionTimeout.async, + } } const processedInput = input @@ -696,9 +713,8 @@ export async function executeWorkflowService( executionMode: 'sync', } - const timeoutController = createTimeoutAbortController( - preprocessResult.executionTimeout?.[executionType] - ) + const executionTimeoutMs = preprocessResult.executionTimeout?.[executionType] ?? 0 + const timeoutController = createTimeoutAbortController(executionTimeoutMs) let requestAborted = false const abortFromRequest = () => { requestAborted = true @@ -719,7 +735,7 @@ export async function executeWorkflowService( rejectLargeInlineOutput, } - const runSynchronousWorkflow = async (): Promise => { + const runSynchronousWorkflow = async (): Promise => { if (detachable) registerManualExecutionAborter(executionId, timeoutController.abort) try { const snapshot = new ExecutionSnapshot( @@ -938,7 +954,10 @@ export async function executeWorkflowService( } } - const pending = runSynchronousWorkflow() + const pending = runSynchronousWorkflow().then( + (result): ExecuteWorkflowServiceTerminalResult => + result.ok ? { ...result, executionTimeoutMs } : result + ) if (mode === 'sync-result-stream') { /** Keep the claim until the pending run can prove whether durable ownership exists. */ executionIdClaimTransferred = true @@ -948,6 +967,7 @@ export async function executeWorkflowService( pending: pending.finally(settleExecutionIdClaim), logged: loggingSession.whenLogStarted(), cancel: abortFromRequest, + executionTimeoutMs, } } diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index aff65c87840..91547af2bc7 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -192,6 +192,14 @@ export async function getWorkflowExecutionCopilotToolCallId( } /** A paused execution's pause points, stored keyed by context id (older rows: an array). */ +/** + * A point still waiting on a human: not a time pause, and not yet resumed or resuming. The + * paused-run list admits runs by this rule; `paused-runs.ts` spells it in SQL. + */ +export function isWaitingHumanPausePoint(point: PausePoint): boolean { + return point.pauseKind !== 'time' && point.resumeStatus === 'paused' +} + export function normalizePausePoints(raw: unknown): PausePoint[] { if (!raw) return [] if (Array.isArray(raw)) return raw as PausePoint[] @@ -220,9 +228,7 @@ export async function getHumanPauseResumeUrl( if (!paused || (paused.status !== 'paused' && paused.status !== 'partially_resumed')) { return null } - const waitsOnPerson = normalizePausePoints(paused.pausePoints).some( - (point) => point.resumeStatus === 'paused' && point.pauseKind !== 'time' - ) + const waitsOnPerson = normalizePausePoints(paused.pausePoints).some(isWaitingHumanPausePoint) return waitsOnPerson ? buildResumeUiUrl(getBaseUrl(), workflowId, executionId) : null } diff --git a/apps/sim/lib/workflows/executor/execution-status-projection.test.ts b/apps/sim/lib/workflows/executor/execution-status-projection.test.ts index 51126e500a4..9e06255aa16 100644 --- a/apps/sim/lib/workflows/executor/execution-status-projection.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status-projection.test.ts @@ -36,7 +36,10 @@ vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ })) import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' -import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { + getProjectedWorkflowExecutionStatus, + getWorkflowExecutionStatus, +} from '@/lib/workflows/executor/execution-status' const mockGetJob = asyncJobsMockFns.mockJobQueue.getJob const mockMaterializeForDisplayWithBlockOutputs = @@ -80,6 +83,62 @@ function readRun(viewerUserId: string | null | undefined) { }) } +function queueFailedRun(): void { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'failed', + level: 'error', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + executionData: { executionState: {} }, + costTotal: '0.75', + }, + ]) + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.pausedExecutions, []) + mockMaterializeForDisplayWithBlockOutputs.mockResolvedValueOnce({ + executionData: { + error: 'Lookup: upstream returned 500', + traceSpans: [ + { + id: 'workflow-execution', + name: 'Workflow Execution', + type: 'workflow', + status: 'error', + children: [ + { + id: 'span-1', + name: 'Lookup', + type: 'api', + status: 'error', + blockId: BLOCK_ID, + executionOrder: 1, + }, + ], + }, + ], + }, + blockOutputs: new Map(), + }) +} + +function readProjectedRun(viewerUserId: string | null | undefined) { + return getProjectedWorkflowExecutionStatus({ + workflowId: 'workflow-1', + executionId: 'execution-1', + includeOutput: false, + selectedOutputs: [], + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + viewerUserId, + }) +} + describe('run-detail field projection', () => { beforeEach(() => { resetDbChainMock() @@ -142,6 +201,32 @@ describe('run-detail field projection', () => { expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() }) + it("names a failed run's failing block from its recorded trace", async () => { + queueFailedRun() + + const projected = await readProjectedRun(undefined) + + expect(projected?.status.error).toBe('Lookup: upstream returned 500') + expect(projected?.failedBlock).toEqual({ + blockId: BLOCK_ID, + blockName: 'Lookup', + blockType: 'api', + }) + }) + + it('withholds the failing block from a member whose group hides trace spans', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTraceSpans: true, + }) + queueFailedRun() + + const projected = await readProjectedRun('user-1') + + expect(projected?.status.error).toBe('Lookup: upstream returned 500') + expect(projected?.failedBlock).toBeNull() + }) + /** The queue branch answers before any log row exists, and is projected too. */ it('withholds a queued run output from a member whose group hides trace spans', async () => { permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index f72c3feb4be..fa3e92c1770 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -14,7 +14,11 @@ vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ })) import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs' -import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { isWaitingHumanPausePoint } from '@/lib/workflows/executor/execution-state' +import { + getWorkflowExecutionStatus, + readPausedRunPoints, +} from '@/lib/workflows/executor/execution-status' const mockGetJob = asyncJobsMockFns.mockJobQueue.getJob const mockMaterializeForDisplayWithBlockOutputs = @@ -660,3 +664,76 @@ describe('getWorkflowExecutionStatus polls that read no execution data', () => { ).rejects.toBeInstanceOf(FunctionalOutputsUnavailableError) }) }) + +describe('readPausedRunPoints cap', () => { + beforeEach(() => { + resetDbChainMock() + }) + + /** + * A run that pauses again after resuming keeps its resumed points. Capping by + * registration time alone dropped the newest point, the one still waiting. + */ + it('keeps a waiting point when resumed history exceeds the cap', async () => { + const pausePoints: Record = {} + for (let i = 0; i < 100; i++) { + pausePoints[`old-${i}`] = { + contextId: `old-${i}`, + blockId: 'approval', + registeredAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(), + resumeStatus: 'resumed', + pauseKind: 'human', + } + } + pausePoints.current = { + contextId: 'current', + blockId: 'approval', + registeredAt: '2026-02-01T00:00:00.000Z', + resumeStatus: 'paused', + pauseKind: 'human', + } + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.workflowExecutionLogs, []) + + const points = await readPausedRunPoints({ + workflowId: 'workflow-1', + executionId: 'execution-1', + pausePoints, + }) + + expect(points).toHaveLength(100) + expect(points?.at(-1)).toMatchObject({ contextId: 'current', status: 'paused' }) + expect(points?.filter((point) => point.status === 'paused')).toHaveLength(1) + }) + + it('selects the points a caller lists before capping, so time pauses cannot crowd them out', async () => { + const pausePoints: Record = {} + for (let i = 0; i < 100; i++) { + pausePoints[`timer-${i}`] = { + contextId: `timer-${i}`, + blockId: 'wait', + registeredAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(), + resumeStatus: 'paused', + pauseKind: 'time', + } + } + pausePoints.approval = { + contextId: 'approval', + blockId: 'approval', + registeredAt: '2026-02-01T00:00:00.000Z', + resumeStatus: 'paused', + pauseKind: 'human', + } + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.workflowExecutionLogs, []) + + const points = await readPausedRunPoints({ + workflowId: 'workflow-1', + executionId: 'execution-1', + pausePoints, + only: isWaitingHumanPausePoint, + }) + + expect(points).toEqual([expect.objectContaining({ contextId: 'approval', kind: 'human' })]) + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index 11cee921d2b..16309c385ba 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -10,6 +10,10 @@ 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 { + type FailedTraceBlock, + findFailedTraceBlock, +} from '@/lib/logs/execution/trace-spans/trace-spans' import { materializeExecutionDataForDisplayWithBlockOutputs } from '@/lib/logs/execution/trace-store' import { type LogFieldProjection, @@ -41,6 +45,7 @@ interface ExecutionDataShape { finalOutput?: { error?: string } & Record error?: { message?: string } | string completionFailure?: string + traceSpans?: unknown } function resolvePath(value: unknown, path: string[]): unknown { @@ -261,6 +266,13 @@ export interface ProjectedWorkflowExecutionStatus { * {@link readPausedRunPoints} need not read them again; null when the run is not paused. */ pausePoints: unknown + /** + * The block a failed run's recorded trace names as its failure, or null when the run did not + * fail, its trace names none, or the viewer's group withholds execution data. + * + * permission-group-enforced: logs.trace_spans + */ + failedBlock: FailedTraceBlock | null } /** @@ -292,6 +304,7 @@ export async function getProjectedWorkflowExecutionStatus( projection, stateExecutionId: resolved.stateExecutionId, pausePoints: resolved.pausePoints, + failedBlock: projection.hideTraceSpans ? null : resolved.failedBlock, } } @@ -354,6 +367,8 @@ interface ResolvedExecutionStatus { stateExecutionId: string /** The state execution's raw `pause_points`, loaded while it is paused; null otherwise. */ pausePoints: unknown + /** The failing block the recorded trace names, when the answered `error` is the run's own. */ + failedBlock: FailedTraceBlock | null } async function readWorkflowExecutionStatus( @@ -442,6 +457,7 @@ async function readWorkflowExecutionStatus( status: projectQueueJob(job, { executionId, includeOutput, workflowId }, deploymentVersion), stateExecutionId: executionId, pausePoints: null, + failedBlock: null, } } } @@ -464,7 +480,7 @@ async function readWorkflowExecutionStatus( finalOutput: null, blockOutputs: null, } - return { status, stateExecutionId: executionId, pausePoints: null } + return { status, stateExecutionId: executionId, pausePoints: null, failedBlock: null } } if (!logRow) { @@ -473,13 +489,15 @@ async function readWorkflowExecutionStatus( ...input, executionId: resumeAttempt.parentExecutionId, }) - return run - ? { - status: projectSettledResumeAttempt(executionId, resumeAttempt, run.status), - stateExecutionId: run.stateExecutionId, - pausePoints: run.pausePoints, - } - : null + if (!run) return null + const status = projectSettledResumeAttempt(executionId, resumeAttempt, run.status) + return { + status, + stateExecutionId: run.stateExecutionId, + pausePoints: run.pausePoints, + failedBlock: + status.status === 'failed' && run.status.status === 'failed' ? run.failedBlock : null, + } } const [pausedRow] = await db @@ -555,6 +573,7 @@ async function readWorkflowExecutionStatus( status: { ...resource, error: null, finalOutput: null, blockOutputs: null }, stateExecutionId: executionId, pausePoints: paused ? pausedRow?.pausePoints : null, + failedBlock: null, } } @@ -588,12 +607,27 @@ async function readWorkflowExecutionStatus( status: { ...resource, error, finalOutput, blockOutputs }, stateExecutionId: executionId, pausePoints: paused ? pausedRow?.pausePoints : null, + failedBlock: error ? findFailedTraceBlock(executionData?.traceSpans, error) : null, } } -/** Most pause points a run detail lists; a run over it lists its earliest registered points. */ +/** + * Most pause points a run detail lists. A run that pauses again after resuming keeps its + * resumed points, so a run over the cap keeps every point not yet resumed before any resumed + * one: the points a caller can still act on are never crowded out by history. + */ const MAX_PAUSED_RUN_POINTS = 100 +/** At most {@link MAX_PAUSED_RUN_POINTS}, keeping unresolved points in preference to resumed ones, earliest registered first. */ +function capPausePoints

(points: P[]): P[] { + const byRegistration = (a: P, b: P) => compareStrings(a.registeredAt ?? '', b.registeredAt ?? '') + const sorted = [...points].sort(byRegistration) + if (sorted.length <= MAX_PAUSED_RUN_POINTS) return sorted + const unresolved = sorted.filter((point) => point.resumeStatus !== 'resumed') + const resumed = sorted.filter((point) => point.resumeStatus === 'resumed') + return [...unresolved, ...resumed].slice(0, MAX_PAUSED_RUN_POINTS).sort(byRegistration) +} + /** One pause point of a paused run, as a caller reading the run may see it. */ export interface PausedRunPoint { contextId: string @@ -654,7 +688,8 @@ async function readRunBlockNames( /** * The pause points of a paused run, earliest registered first and capped at - * {@link MAX_PAUSED_RUN_POINTS}, or null when the run has no paused record. + * {@link MAX_PAUSED_RUN_POINTS} (unresolved points kept first), or null when the run has no + * paused record. * * Reads only `pause_points`, never the execution snapshot. `display` and * `inputFields` come only from points scrubbed of the run's resolved secrets when @@ -663,12 +698,15 @@ async function readRunBlockNames( * part of a point. * * A caller that already loaded the paused record's `pause_points` passes them as - * `pausePoints` and the record is not read again. + * `pausePoints` and the record is not read again. A caller that lists only some + * points passes `only`, applied before the cap so the points it keeps are never + * crowded out by ones it would discard. */ export async function readPausedRunPoints(input: { workflowId: string executionId: string pausePoints?: unknown + only?: (point: PausePoint) => boolean }): Promise { const { workflowId, executionId } = input const row = @@ -707,9 +745,8 @@ export async function readPausedRunPoints(input: { })) ) - const points = PauseResumeManager.mapPausePoints(row.pausePoints, queuePositions) - .sort((a, b) => compareStrings(a.registeredAt ?? '', b.registeredAt ?? '')) - .slice(0, MAX_PAUSED_RUN_POINTS) + const mapped = PauseResumeManager.mapPausePoints(row.pausePoints, queuePositions) + const points = capPausePoints(input.only ? mapped.filter(input.only) : mapped) const baseBlockId = (point: PausePoint) => extractBaseBlockId(point.blockId ?? point.contextId) const names = await readRunBlockNames(workflowId, executionId, [ ...new Set(points.map(baseBlockId)), @@ -741,3 +778,29 @@ export async function readPausedRunPoints(input: { } }) } + +/** + * Pause points as a viewer may see them. A point's `display` is what the block + * rendered from upstream outputs, so it follows `finalOutput`'s gating: withheld + * unless output was asked for and the viewer's group shows execution data. + * `inputFields` are resolved before the pause is captured, so a label or + * description can carry upstream values too: they are withheld from a viewer + * whose group hides execution data. + * + * The one copy of this rule, so every surface that lists pause points reveals + * no more than the run detail does. + * + * permission-group-enforced: logs.trace_spans + */ +export function projectPausedRunPoints( + points: readonly PausedRunPoint[], + options: { projection: LogFieldProjection; includeOutput: boolean } +): PausedRunPoint[] { + const hidesExecutionData = options.projection.hideTraceSpans + const showsDisplay = options.includeOutput && !hidesExecutionData + return points.map((point) => ({ + ...point, + display: showsDisplay ? point.display : null, + inputFields: hidesExecutionData ? null : point.inputFields, + })) +} diff --git a/apps/sim/lib/workflows/executor/paused-runs.ts b/apps/sim/lib/workflows/executor/paused-runs.ts new file mode 100644 index 00000000000..91a90dbf37b --- /dev/null +++ b/apps/sim/lib/workflows/executor/paused-runs.ts @@ -0,0 +1,98 @@ +import { db } from '@sim/db' +import { pausedExecutions, workflow } from '@sim/db/schema' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { + type CursorKey, + encodeKeyset, + type KeysetKey, + type KeysetPage, + keysetOrderBy, + keysetPage, + type ListSortOrder, + preciseTimestampKey, + preciseTimestampText, + resumeKeyset, + textKey, +} from '@/lib/api/list-query' + +/** A paused run of an active workflow, with the raw `pause_points` its detail is built from. */ +interface WorkspacePausedRunRow { + id: string + workflowId: string + workflowName: string + executionId: string + pausedAt: Date + pausedAtCursor: string + pausePoints: unknown +} + +const PAUSED_RUN_KEYS: readonly KeysetKey[] = [ + preciseTimestampKey(pausedExecutions.pausedAt, (row) => row.pausedAtCursor), + textKey(pausedExecutions.id, (row) => row.id), +] + +/** Whether `point.value` is a pause point still waiting on a human, with `mapPausePoints`' defaults. */ +const POINT_WAITS_ON_HUMAN = sql`coalesce(point.value ->> 'pauseKind', 'human') <> 'time' + and coalesce(point.value ->> 'resumeStatus', 'paused') = 'paused'` + +/** + * A paused run with a pause point still waiting on a human. `pause_points` is an object keyed + * by context id, or an array on legacy rows; both shapes are read, as `normalizePausePoints` + * reads them, and each set function only ever receives the shape it accepts. + */ +const WAITS_ON_HUMAN = sql`exists ( + select 1 from ( + select value from jsonb_each( + case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'object' + then ${pausedExecutions.pausePoints} else '{}'::jsonb end + ) + union all + select value from jsonb_array_elements( + case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'array' + then ${pausedExecutions.pausePoints} else '[]'::jsonb end + ) + ) as point(value) + where ${POINT_WAITS_ON_HUMAN} +)` + +/** The cursor that resumes the list after `row`. */ +export function pausedRunCursorKeys(row: WorkspacePausedRunRow): CursorKey[] { + return encodeKeyset(PAUSED_RUN_KEYS, row) +} + +/** + * One page of a workspace's paused runs that wait on at least one human pause + * point, ordered by when they paused. Runs of archived workflows are excluded. + * Authorization is the caller's. + */ +export async function listWorkspacePausedRuns(input: { + workspaceId: string + sortOrder: ListSortOrder + cursorKeys?: CursorKey[] + limit: number +}): Promise> { + const rows = await db + .select({ + id: pausedExecutions.id, + workflowId: pausedExecutions.workflowId, + workflowName: workflow.name, + executionId: pausedExecutions.executionId, + pausedAt: pausedExecutions.pausedAt, + pausedAtCursor: preciseTimestampText(pausedExecutions.pausedAt), + pausePoints: pausedExecutions.pausePoints, + }) + .from(pausedExecutions) + .innerJoin(workflow, eq(workflow.id, pausedExecutions.workflowId)) + .where( + and( + eq(workflow.workspaceId, input.workspaceId), + isNull(workflow.archivedAt), + inArray(pausedExecutions.status, ['paused', 'partially_resumed']), + WAITS_ON_HUMAN, + resumeKeyset(PAUSED_RUN_KEYS, input.cursorKeys, input.sortOrder) + ) + ) + .orderBy(...keysetOrderBy(PAUSED_RUN_KEYS, input.sortOrder)) + .limit(input.limit + 1) + return keysetPage(PAUSED_RUN_KEYS, rows, input.limit) +} diff --git a/apps/sim/lib/workflows/triggers/run-options.ts b/apps/sim/lib/workflows/triggers/run-options.ts index bb02b73f856..fcbd64d3996 100644 --- a/apps/sim/lib/workflows/triggers/run-options.ts +++ b/apps/sim/lib/workflows/triggers/run-options.ts @@ -307,6 +307,18 @@ export function resolveTriggerRunOptions( ) } +/** + * JSON Schema for the `input` an API run of these blocks accepts, or null when + * no block can start an API run. Describes the start block the executor itself + * picks for an API run, so the description cannot drift from what it runs. + */ +export function resolveApiRunInputSchema( + blocks: Record +): Record | null { + const [entry] = resolveStartCandidates(blocks, { execution: 'api' }) + return entry ? buildTriggerRunOption(entry, true).inputSchema : null +} + /** Strips internal fields so the option can be returned to the agent. */ export function toPublicRunOption(option: TriggerRunOption): { triggerBlockId: string diff --git a/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts b/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts index 9d00c1266f8..3c9b13720bb 100644 --- a/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts +++ b/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts @@ -13,6 +13,7 @@ import { type V2ExecuteWorkflowBody, type V2ResumeWorkflowBody, v2ExecuteWorkflowDataSchema, + v2ResumeWorkflowDataSchema, v2WorkflowRunStatusSchema, } from '@/lib/api/contracts/v2/workflows' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' @@ -482,7 +483,7 @@ try { assert.deepEqual(points[0].inputFields, [ { name: 'approved', label: 'approved', type: 'boolean', required: true }, ]) - const resumed = v2ExecuteWorkflowDataSchema.parse( + const resumed = v2ResumeWorkflowDataSchema.parse( ( await request( 'POST', diff --git a/apps/sim/scripts/test-workflow-stop-after-e2e.ts b/apps/sim/scripts/test-workflow-stop-after-e2e.ts index d15efcdeb8d..ef6c71f16f8 100644 --- a/apps/sim/scripts/test-workflow-stop-after-e2e.ts +++ b/apps/sim/scripts/test-workflow-stop-after-e2e.ts @@ -19,6 +19,7 @@ import { type V2ExecuteWorkflowData, type V2ResumeWorkflowBody, v2ExecuteWorkflowDataSchema, + v2ResumeWorkflowDataSchema, v2WorkflowRunStatusSchema, } from '@/lib/api/contracts/v2/workflows' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' @@ -407,7 +408,7 @@ async function resume( } throw new Error('Timed wait did not finish after automatic resume') } - const result = v2ExecuteWorkflowDataSchema.parse( + const result = v2ResumeWorkflowDataSchema.parse( record( await execute( fixture.workflowId, @@ -677,7 +678,7 @@ try { assert(!resumedScopes.has(index), 'Each iteration or branch resumes once') resumedScopes.add(index) } - const resumed = v2ExecuteWorkflowDataSchema.parse( + const resumed = v2ResumeWorkflowDataSchema.parse( record( await execute( fixture.workflowId, diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 0a98d5c268f..ff8ac81163c 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -2320,6 +2320,16 @@ export const CLI_CONTRACT: CliContract = { { header: 'run', path: 'runId' }, ], }, + listPausedRuns: { + command: 'workflows runs paused', + describe: 'List runs waiting on human input across the workspace', + columns: [ + { header: 'paused at', path: 'pausedAt', format: 'timestamp' }, + { header: 'workflow', path: 'workflowName' }, + { header: 'waiting points', path: 'points', format: 'count' }, + { header: 'run', path: 'runId' }, + ], + }, cancelWorkflowRun: { command: 'workflows runs cancel', pathFlags: WORKFLOW_RUN_SCOPE, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 755e908162e..5bbf5f1a20b 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -5636,11 +5636,13 @@ type ExecuteWorkflowResponseRef1 = { startedAt?: string endedAt?: string durationMs?: number + executionTimeoutSeconds: number | null } type ExecuteWorkflowResponseRef2 = { runId: string statusUrl: string + executionTimeoutSeconds: number | null } export type ExecuteWorkflowResponse = @@ -5852,6 +5854,11 @@ type GetBillingStatusResponseRef0 = { limitBytes: number percentUsed: number } | null + limits: { + maxSyncExecutionTimeoutSeconds: number | null + maxAsyncExecutionTimeoutSeconds: number | null + maxConcurrentExecutions: number | null + } } export type GetBillingStatusResponse = { @@ -7752,6 +7759,12 @@ type GetWorkflowResponseRef0 = { } type GetWorkflowResponseRef1 = { + type: string + properties?: Record + required?: Array +} + +type GetWorkflowResponseRef2 = { id: string webUrl: string name: string @@ -7766,10 +7779,11 @@ type GetWorkflowResponseRef1 = { updatedAt: string variables: Record inputs: Array + inputSchema: GetWorkflowResponseRef1 | null } export type GetWorkflowResponse = { - data: GetWorkflowResponseRef1 + data: GetWorkflowResponseRef2 } /** `GET /api/v2/workflows/[workflowId]/deployments/chat` */ @@ -10026,6 +10040,61 @@ export type ListOrganizationWorkspacesResponse = { nextCursor: string | null } +/** `GET /api/v2/workflows/runs/paused` */ +export type ListPausedRunsQuery = { + workspaceId: string + includeOutput?: boolean + limit?: number + cursor?: string + sortBy?: 'pausedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListPausedRunsResponseRef0 = { + runId: string + workflowId: string + workflowName: string + pausedAt: string + points: Array +} + +type ListPausedRunsResponseRef1 = { + contextId: string + blockId: string + blockName: string | null + kind: 'human' | 'time' + status: 'paused' | 'queued' | 'resuming' | 'resumed' | 'failed' + registeredAt: string | null + resumeAt: string | null + parallel: { + parallelId: string + branchIndex: number + branchTotal: number | null + } | null + loop: { + loopId: string + iteration: number + } | null + inputFields: Array | null + display: unknown | null + queuePosition: number | null +} + +type ListPausedRunsResponseRef2 = { + name: string + label: string + type: string + description?: string + placeholder?: string + required: boolean + options?: Array +} + +export type ListPausedRunsResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members` */ export type ListPermissionGroupMembersParams = { organizationId: string @@ -16879,10 +16948,16 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Compare workflow versions', query: { - base: { kind: 'integer', required: true, describe: 'Deployment version to compare from.' }, + base: { + kind: 'integer', + required: true, + maximum: 2147483647, + describe: 'Deployment version to compare from.', + }, target: { kind: 'integer', required: true, + maximum: 2147483647, describe: 'Deployment version to compare to, in the same workflow.', }, }, @@ -17148,7 +17223,12 @@ export const V2_OPERATIONS = { }, name: { kind: 'string', required: true, describe: 'File name, including its extension.' }, contentType: { kind: 'string', required: true, describe: 'MIME type of the uploaded file.' }, - size: { kind: 'integer', required: true, describe: 'Exact file size in bytes.' }, + size: { + kind: 'integer', + required: true, + maximum: 5368709120, + describe: 'Exact file size in bytes.', + }, folderPath: { kind: 'string', describe: 'Canonical destination folder path. Omit for the workspace root.', @@ -17271,6 +17351,7 @@ export const V2_OPERATIONS = { syncIntervalMinutes: { kind: 'integer', default: 1440, + maximum: 525600, describe: 'Scheduled synchronization interval in minutes; zero disables scheduling.', }, }, @@ -17298,7 +17379,12 @@ export const V2_OPERATIONS = { required: true, describe: 'Supported MIME type for the document.', }, - size: { kind: 'integer', required: true, describe: 'Exact file size in bytes.' }, + size: { + kind: 'integer', + required: true, + maximum: 104857600, + describe: 'Exact file size in bytes.', + }, tag1: { kind: 'string', describe: 'Value for tag slot 1.' }, tag2: { kind: 'string', describe: 'Value for tag slot 2.' }, tag3: { kind: 'string', describe: 'Value for tag slot 3.' }, @@ -17496,11 +17582,13 @@ export const V2_OPERATIONS = { timeout: { kind: 'integer', default: 30000, + maximum: 300000, describe: 'Per-request timeout in milliseconds. Defaults to 30000 on creation.', }, retries: { kind: 'integer', default: 3, + maximum: 10, describe: 'Number of retries per request. Defaults to 3 on creation.', }, enabled: { @@ -17652,7 +17740,12 @@ export const V2_OPERATIONS = { body: { name: { kind: 'string', required: true, describe: 'File name, including its extension.' }, contentType: { kind: 'string', required: true, describe: 'MIME type of the uploaded file.' }, - size: { kind: 'integer', required: true, describe: 'Exact file size in bytes.' }, + size: { + kind: 'integer', + required: true, + maximum: 5368709120, + describe: 'Exact file size in bytes.', + }, folderPath: { kind: 'string', describe: @@ -18461,7 +18554,7 @@ export const V2_OPERATIONS = { describe: 'One condition or a recursive `all`/`any` group, normalized to a grouped predicate. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', }, - limit: { kind: 'integer', describe: 'Maximum matching rows to delete.' }, + limit: { kind: 'integer', maximum: 1000, describe: 'Maximum matching rows to delete.' }, rowIds: { kind: 'array', describe: 'Explicit row identifiers to delete.' }, }, }, @@ -18640,6 +18733,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -18699,6 +18793,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -18837,6 +18932,7 @@ export const V2_OPERATIONS = { }, timeoutSeconds: { kind: 'integer', + maximum: 300, describe: 'Maximum wait time in seconds; defaults to 120 and cannot exceed 300. Timeout does not guarantee that a third-party side effect was cancelled.', }, @@ -18867,11 +18963,13 @@ export const V2_OPERATIONS = { }, executionTimeoutSeconds: { kind: 'integer', + maximum: 604800, describe: "Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true` or `waitTimeoutSeconds`; otherwise returns `400`.", }, waitTimeoutSeconds: { kind: 'integer', + maximum: 604800, describe: 'Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires a full-access API key or an OAuth access token with `api:write`. Cannot be combined with `async` or `stream`.', }, @@ -18904,6 +19002,7 @@ export const V2_OPERATIONS = { }, base64MaxBytes: { kind: 'integer', + maximum: 16777216, describe: 'Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true.', }, @@ -19294,6 +19393,7 @@ export const V2_OPERATIONS = { segmentCount: { kind: 'integer', default: 72, + maximum: 500, describe: 'Number of equal time buckets to divide the window into, from 1 to 500. It is the ceiling on how many buckets a series carries: with `includeEmpty=true` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.', }, @@ -19449,6 +19549,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20002,6 +20103,7 @@ export const V2_OPERATIONS = { }, base64MaxBytes: { kind: 'integer', + maximum: 16777216, describe: 'Per-file inline ceiling, lowering but never raising the server limit of 16 MiB.', }, }, @@ -20075,6 +20177,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20219,6 +20322,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20283,6 +20387,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum usage events per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20347,6 +20452,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum blocks to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20386,6 +20492,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum chat deployments to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20423,6 +20530,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 25, + maximum: 100, describe: 'Maximum connector types to return per page. Must be a whole number from 1 to 100. Defaults to 25.', }, @@ -20450,6 +20558,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum credential members to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20532,6 +20641,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum credentials to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20573,6 +20683,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum custom tools to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20644,6 +20755,7 @@ export const V2_OPERATIONS = { }, depth: { kind: 'integer', + maximum: 64, describe: 'Deepest level below parentPath to include when recursive is true.', }, }, @@ -20745,6 +20857,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20799,6 +20912,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum knowledge bases to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20851,6 +20965,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum chunks to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20885,6 +21000,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum connector documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20924,6 +21040,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum connectors to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -20950,6 +21067,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21102,21 +21220,25 @@ export const V2_OPERATIONS = { }, minDurationMs: { kind: 'integer', + maximum: 2147483647, describe: 'Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.', }, maxDurationMs: { kind: 'integer', + maximum: 2147483647, describe: 'Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.', }, minCost: { kind: 'number', + maximum: 1000000, describe: 'Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run.', }, maxCost: { kind: 'number', + maximum: 1000000, describe: 'Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run.', }, @@ -21222,6 +21344,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21282,6 +21405,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21321,6 +21445,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21360,6 +21485,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21390,6 +21516,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum domain claims to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21445,6 +21572,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum invitations to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21487,6 +21615,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21526,6 +21655,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum members to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21564,6 +21694,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum organizations to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21622,6 +21753,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum usage events per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21673,6 +21805,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21683,6 +21816,48 @@ export const V2_OPERATIONS = { }, }, }, + listPausedRuns: { + method: 'GET', + path: '/api/v2/workflows/runs/paused', + pathParams: [] as const, + responseMode: 'json', + summary: 'List paused workflow runs', + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace whose paused runs should be listed.', + }, + includeOutput: { + kind: 'boolean', + describe: "Include each pause point's reviewer `display` content when true.", + }, + limit: { + kind: 'integer', + default: 10, + maximum: 25, + describe: + 'Maximum runs to return per page. A page also ends early once it holds 100 pause points, so it can return fewer runs while `nextCursor` is set. Must be a whole number from 1 to 25. Defaults to 10.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + sortBy: { + kind: 'enum', + values: ['pausedAt'] as const, + default: 'pausedAt', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'desc', + describe: 'Sort direction.', + }, + }, + }, listPermissionGroupMembers: { method: 'GET', path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', @@ -21710,6 +21885,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum group members to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21749,6 +21925,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum permission groups to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21836,6 +22013,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 100, + maximum: 1000, describe: 'Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.', }, cursor: { @@ -21872,6 +22050,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21910,6 +22089,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum sandboxes to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -21958,6 +22138,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22128,6 +22309,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum options per page, from 1 to 100. Defaults to 50.', }, }, @@ -22160,6 +22342,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum skill editors to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22198,6 +22381,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum skills to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22223,6 +22407,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum identity providers to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22304,6 +22489,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 100, + maximum: 1000, describe: 'Maximum rows to return per page. Must be a whole number from 1 to 1000. Defaults to 100.', }, @@ -22426,6 +22612,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum tools to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22512,6 +22699,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum workflow-MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22558,6 +22746,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum workflow runs to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22606,6 +22795,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum workflows to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22652,6 +22842,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum deployment versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22674,6 +22865,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22708,6 +22900,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22755,6 +22948,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum members to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22776,6 +22970,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -22814,6 +23009,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 25, + maximum: 100, describe: 'Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 25.', }, @@ -22839,6 +23035,7 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', default: 50, + maximum: 100, describe: 'Maximum schedules to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, @@ -23224,6 +23421,7 @@ export const V2_OPERATIONS = { sort: { kind: 'array', describe: 'Ordered table-row sort specification.' }, limit: { kind: 'integer', + maximum: 1000, describe: 'Maximum rows per page. `0` requests all matches, subject to a 5 MiB row-data ceiling; larger results return `400` with `TABLE_QUERY_RESULT_TOO_LARGE`. Use paged queries or an export for larger results.', }, @@ -23266,16 +23464,19 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the file.' }, maxBytes: { kind: 'integer', + maximum: 26214400, describe: 'Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.', }, offset: { kind: 'integer', + maximum: 9007199254740991, describe: 'First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.', }, limit: { kind: 'integer', + maximum: 9007199254740991, describe: 'How many lines to return from `offset`. Absent reads to the end.', }, }, @@ -23291,16 +23492,19 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the file.' }, maxBytes: { kind: 'integer', + maximum: 26214400, describe: 'Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.', }, offset: { kind: 'integer', + maximum: 9007199254740991, describe: 'First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.', }, limit: { kind: 'integer', + maximum: 9007199254740991, describe: 'How many lines to return from `offset`. Absent reads to the end.', }, }, @@ -23512,6 +23716,7 @@ export const V2_OPERATIONS = { expectedRevision: { kind: 'integer', required: true, + maximum: 9007199254740991, describe: 'Permission revision read before exporting the grants.', }, permissions: { @@ -23648,6 +23853,7 @@ export const V2_OPERATIONS = { }, newLimitCredits: { kind: 'integer', + maximum: 9007199254740991, describe: 'Required only for a usage-limit request: a whole-number credit cap greater than the current cap. Omit for permission requests. Available when action is apply.', }, @@ -23674,6 +23880,7 @@ export const V2_OPERATIONS = { }, newLimitCredits: { kind: 'integer', + maximum: 9007199254740991, describe: 'Required only for a usage-limit request: a whole-number credit cap greater than the current cap. Omit for permission requests.', }, @@ -23837,6 +24044,7 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the file.' }, expectedCurrentVersion: { kind: 'integer', + maximum: 2147483647, describe: 'Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.', }, @@ -23862,6 +24070,7 @@ export const V2_OPERATIONS = { body: { expectedCurrentVersion: { kind: 'integer', + maximum: 2147483647, describe: 'Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.', }, @@ -23927,6 +24136,7 @@ export const V2_OPERATIONS = { body: { version: { kind: 'integer', + maximum: 2147483647, describe: 'Deployment version to reactivate. Omit to select the previous active version.', }, }, @@ -24260,7 +24470,12 @@ export const V2_OPERATIONS = { default: 'regex', describe: 'How `query` is read.', }, - maxResults: { kind: 'integer', default: 50, describe: 'Maximum matching lines to return.' }, + maxResults: { + kind: 'integer', + default: 50, + maximum: 200, + describe: 'Maximum matching lines to return.', + }, folderPaths: { kind: 'string', describe: @@ -24313,6 +24528,7 @@ export const V2_OPERATIONS = { topK: { kind: 'number', default: 10, + maximum: 100, describe: 'Maximum number of search results to return. Must be a whole number between 1 and 100.', }, @@ -24340,6 +24556,7 @@ export const V2_OPERATIONS = { }, rerankerInputCount: { kind: 'integer', + maximum: 100, describe: 'How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from.', }, @@ -24365,7 +24582,12 @@ export const V2_OPERATIONS = { default: 'regex', describe: 'How `query` is read.', }, - maxResults: { kind: 'integer', default: 50, describe: 'Maximum matching lines to return.' }, + maxResults: { + kind: 'integer', + default: 50, + maximum: 200, + describe: 'Maximum matching lines to return.', + }, folderPaths: { kind: 'string', describe: @@ -24741,6 +24963,7 @@ export const V2_OPERATIONS = { }, syncIntervalMinutes: { kind: 'integer', + maximum: 525600, describe: 'New scheduled synchronization interval in minutes.', }, status: { @@ -24891,11 +25114,13 @@ export const V2_OPERATIONS = { timeout: { kind: 'integer', default: 30000, + maximum: 300000, describe: 'Per-request timeout in milliseconds. Defaults to 30000 on creation.', }, retries: { kind: 'integer', default: 3, + maximum: 10, describe: 'Number of retries per request. Defaults to 3 on creation.', }, enabled: { @@ -25058,7 +25283,11 @@ export const V2_OPERATIONS = { describe: 'New parent folder identifier; null moves to the root, omission leaves the parent unchanged.', }, - sortOrder: { kind: 'integer', describe: 'New manual position; omit to leave unchanged.' }, + sortOrder: { + kind: 'integer', + maximum: 9007199254740991, + describe: 'New manual position; omit to leave unchanged.', + }, }, }, updateProjectFileShare: { @@ -25117,7 +25346,7 @@ export const V2_OPERATIONS = { required: true, describe: 'Row-data patch applied to every matching row.', }, - limit: { kind: 'integer', describe: 'Maximum matching rows to update.' }, + limit: { kind: 'integer', maximum: 1000, describe: 'Maximum matching rows to update.' }, }, }, updateSandbox: { diff --git a/packages/sim-cli/src/runtime/execute.test.ts b/packages/sim-cli/src/runtime/execute.test.ts index 109042264f8..f033408c1eb 100644 --- a/packages/sim-cli/src/runtime/execute.test.ts +++ b/packages/sim-cli/src/runtime/execute.test.ts @@ -273,6 +273,28 @@ describe('selector pagination metadata', () => { }) }) +describe('a list whose contract caps its page below the CLI default', () => { + it('never asks for more per page than the contract accepts, and keeps paging to the total', async () => { + const maximum = V2_OPERATIONS.listPausedRuns.query?.limit?.maximum + expect(maximum).toBe(25) + request + .mockResolvedValueOnce({ data: [{ runId: 'run_1' }], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ data: [{ runId: 'run_2' }], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + const commandSpec = CLI_CONTRACT.listPausedRuns + if (!commandSpec) throw new Error('listPausedRuns has no CLI command') + await executeOperation('listPausedRuns', commandSpec, V2_OPERATIONS.listPausedRuns, [ + { limit: '30' }, + leaf('listPausedRuns'), + ]) + + expect(request).toHaveBeenCalledTimes(2) + expect(request.mock.calls.map(([, options]) => options.query.limit)).toEqual([25, 25]) + }) +}) + describe('an in-band run failure', () => { it('fails the process when a synchronous run reports status failed', async () => { request.mockResolvedValue({ diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 47efd80c360..d6eba513290 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -505,6 +505,11 @@ export async function executeOperation( throw new SimApiError('--cursor must be a non-empty string', 0) } const limit = pagedLimit === 0 ? Number.POSITIVE_INFINITY : pagedLimit + /** An operation whose contract caps its page below the CLI's default page asks for less. */ + const maxPageSize = Math.min( + DEFAULT_PAGE_SIZE, + operationSpec[paging]?.limit?.maximum ?? Number.POSITIVE_INFINITY + ) const rows: unknown[] = [] const seenCursors = new Set(initialCursor ? [initialCursor] : []) const progress = pageProgress() @@ -516,7 +521,7 @@ export async function executeOperation( // would otherwise leave the progress text on the line the error prints onto. try { do { - const pageSize = Math.min(DEFAULT_PAGE_SIZE, limit - rows.length) + const pageSize = Math.min(maxPageSize, limit - rows.length) const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} const page: V2Page = await client.request(operation, { params: request.params, diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index a234cb8bc01..33cdb05d06a 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -51,6 +51,8 @@ export interface FieldSpec { nullable?: true values?: readonly string[] default?: unknown + /** The largest value the API accepts for a numeric field, when the contract bounds it. */ + maximum?: number /** The field's `.describe()` from the route contract, used as `--help` text. */ describe?: string } diff --git a/packages/testing/src/mocks/billing-attribution.mock.ts b/packages/testing/src/mocks/billing-attribution.mock.ts index f3b2605d86e..7bfcde12b00 100644 --- a/packages/testing/src/mocks/billing-attribution.mock.ts +++ b/packages/testing/src/mocks/billing-attribution.mock.ts @@ -164,6 +164,11 @@ export const billingAttributionMockFns = { mockCheckAttributedBillingBlocks: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), mockRefreshAttributionPeriod: vi.fn(), + /** No Enterprise overrides unless a test sets them, matching a non-Enterprise payer. */ + mockReadEnterpriseLimitOverrides: vi.fn(() => ({ + enterpriseConcurrencyLimit: null, + enterpriseWorkflowExecutionTimeoutSeconds: null, + })), } /** @@ -216,4 +221,5 @@ export const billingAttributionMock = { checkAttributedBillingBlocks: billingAttributionMockFns.mockCheckAttributedBillingBlocks, checkAttributedUsageLimits: billingAttributionMockFns.mockCheckAttributedUsageLimits, refreshAttributionPeriod: billingAttributionMockFns.mockRefreshAttributionPeriod, + readEnterpriseLimitOverrides: billingAttributionMockFns.mockReadEnterpriseLimitOverrides, } diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index db156236746..8df2610df87 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -412,7 +412,15 @@ const GROUP_SECTIONS: Record = { }, { title: 'Run and inspect', - prefixes: ['run', 'runs list', 'runs get', 'runs wait', 'runs resume', 'runs cancel'], + prefixes: [ + 'run', + 'runs list', + 'runs paused', + 'runs get', + 'runs wait', + 'runs resume', + 'runs cancel', + ], }, { title: 'Chat deployments', prefixes: ['chat status', 'chat publish', 'chat unpublish'] }, { title: 'Export and import', prefixes: ['export', 'import-preview', 'import'] }, diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 4b9af6376a0..c8c6d7f8654 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -616,6 +616,8 @@ export function renderSlotMap( ) } if (property.default !== undefined) parts.push(`default: ${JSON.stringify(property.default)}`) + if ((kind === 'number' || kind === 'integer') && typeof property.maximum === 'number') + parts.push(`maximum: ${property.maximum}`) // The contract's own `.describe()` is the field's documentation, and it is // already what the OpenAPI specs publish. Carrying it here is what lets // `--help` say what a flag means instead of restating its name back at the