diff --git a/apps/docs/content/docs/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/api-reference/(generated)/workflows/meta.json index 4c6c32150a3..f4b795acb97 100644 --- a/apps/docs/content/docs/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/api-reference/(generated)/workflows/meta.json @@ -9,6 +9,7 @@ "duplicateWorkflow", "moveWorkflows", "getWorkflowState", + "getWorkflowOutline", "replaceWorkflowState", "applyWorkflowOperations", "applyWorkflowVariables", @@ -24,6 +25,7 @@ "updateWorkflowPublicApi", "listWorkflowTriggers", "updateWorkflowTrigger", + "listWorkspaceSchedules", "deployWorkflow", "undeployWorkflow", "rollbackWorkflow", diff --git a/apps/docs/content/docs/api-reference/authentication.mdx b/apps/docs/content/docs/api-reference/authentication.mdx index adf02a95ad8..7a178c8e462 100644 --- a/apps/docs/content/docs/api-reference/authentication.mdx +++ b/apps/docs/content/docs/api-reference/authentication.mdx @@ -42,7 +42,7 @@ Delete a key from the same settings page to revoke it. Update applications that A read-only key cannot change workspace resources or run, resume, or cancel workflows. Reads can still cost credits: searches use embedding credits, and search chat uses model credits and keeps a private conversation history. Selector lookups call third-party services with your connected accounts. -A refused request fails with `403` and the message `This API key is read-only and cannot perform this operation`. Where the surface carries a code, it is `READ_ONLY_API_KEY`: in `error.details.code` on the v2 API and the Sim MCP server, in `details.code` on the v1 API and workflow execution, and in `error.data.code` on a workflow MCP server. +A refused request fails with `403` and the message `This API key is read-only and cannot perform this operation. Use a full-access API key.` Where the surface carries a code, it is `READ_ONLY_API_KEY`: in `error.details.code` on the v2 API and the Sim MCP server, in `details.code` on the v1 API and workflow execution, and in `error.data.code` on a workflow MCP server. Access narrows a key; it never grants more than the key's identity already has. Access is fixed at creation, so create a new key to change it. Keys created before access levels existed have full access. Keys created from the deploy modal or for a workflow MCP server always have full access, because they exist to run workflows. diff --git a/apps/docs/content/docs/api-reference/getting-started.mdx b/apps/docs/content/docs/api-reference/getting-started.mdx index 9d0b352682e..5b78d3c56aa 100644 --- a/apps/docs/content/docs/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/api-reference/getting-started.mdx @@ -174,11 +174,14 @@ Rejected v2 requests use HTTP error status codes and a stable code/message envel { "error": { "code": "NOT_FOUND", - "message": "Workflow not found" + "message": "Workflow not found", + "traceId": "3f9a1c7e" } } ``` +Every response carries an `x-request-id` header, and an error repeats it as `error.traceId`. Include it when you contact support about a failed request. + | Status | Meaning | Action | | --- | --- | --- | | `400` | Invalid request | Check the published schema and `error.details`, when present | @@ -195,13 +198,13 @@ Other statuses and endpoint-specific recovery are documented in each reference. V2 validates path parameters, query strings, and bodies against the published schema. Unknown fields return `400`; for example, `?limt=20` is an error. The rule also applies to endpoints with no query parameters: omit tracking tags, cache busters, and other undeclared fields. -A validation error can look like: +The message names the first rejected field, prefixed with where it was sent (`path`, `query`, `header`, or `body`); `error.details` lists every issue. A validation error can look like: ```json { "error": { "code": "BAD_REQUEST", - "message": "Invalid request", + "message": "query: Unrecognized key: \"limt\"", "details": [ { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } ] diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index e998b13dc7b..9badb69c50f 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -58,6 +58,7 @@ See [output formats](/cli/output) for the JSON default and display formats. | [`sim organizations`](/cli/organizations) | Manage organization membership, invitations, usage, access requests, and SSO. | | [`sim permission-groups`](/cli/permission-groups) | Manage organization permission groups, their configuration, and member assignments. | | [`sim sandboxes`](/cli/sandboxes) | Manage reusable dependency sets for Function blocks. | +| [`sim schedules`](/cli/schedules) | List the schedules deployed workflows run across a workspace. | | [`sim secrets`](/cli/secrets) | Set and manage workspace or personal secrets. | | [`sim selectors`](/cli/selectors) | Discover destination options for registered fields, such as calendars, channels, and tables. | | [`sim skills`](/cli/skills) | Create reusable agent instructions and manage their editors. | diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index 14ef23a60dd..71c788d6f2d 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -31,6 +31,7 @@ "organizations", "permission-groups", "sandboxes", + "schedules", "secrets", "selectors", "skills", diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 981fdcef182..95034c65489 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -5101,6 +5101,37 @@ sim sandboxes update [options] API request and response schemas: [updateSandbox](/api-reference/sandboxes/updateSandbox). +## sim schedules + +List the schedules deployed workflows run across a workspace. + +A schedule’s timing comes from its schedule block. Edit the block with `sim workflows operations apply`, then deploy; pause or resume one with `sim workflows triggers update`. + +Also spelled `sim schedule`. + +### sim schedules list + +Show the timing and health of every schedule the workspace’s deployments run + +```bash +sim schedules list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--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 "createdAt". Accepted values: `createdAt`. | +| `--sort-order ` | No | Sort direction. Defaults to "desc". Accepted values: `asc`, `desc`. | + + + +API request and response schemas: [listWorkspaceSchedules](/api-reference/workflows/listWorkspaceSchedules). + ## sim secrets Set and manage workspace or personal secrets. @@ -8166,6 +8197,26 @@ sim workflows deployment update [options] API request and response schemas: [updateWorkflowPublicApi](/api-reference/workflows/updateWorkflowPublicApi). +### sim workflows outline + +Show a workflow’s draft as a compact read-only outline + +```bash +sim workflows outline +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +API request and response schemas: [getWorkflowOutline](/api-reference/workflows/getWorkflowOutline). + ### sim workflows state get Get workflow state diff --git a/apps/docs/content/docs/cli/schedules.mdx b/apps/docs/content/docs/cli/schedules.mdx new file mode 100644 index 00000000000..eb395409db7 --- /dev/null +++ b/apps/docs/content/docs/cli/schedules.mdx @@ -0,0 +1,35 @@ +--- +title: Schedules +description: List the schedules deployed workflows run across a workspace. +--- + +import { CommandTable } from '@/components/ui/command-table' + +List the schedules deployed workflows run across a workspace. + +A schedule’s timing comes from its schedule block. Edit the block with `sim workflows operations apply`, then deploy; pause or resume one with `sim workflows triggers update`. + +`sim schedules` is also spelled `sim schedule`. + +These commands accept the [global options](/cli/commands#global-options). + +## Show the timing and health of every schedule the workspace’s deployments run + +```bash +sim schedules list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--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 "createdAt". Accepted values: `createdAt`. | +| `--sort-order ` | No | Sort direction. Defaults to "desc". Accepted values: `asc`, `desc`. | + + + +API request and response schemas: [listWorkspaceSchedules](/api-reference/workflows/listWorkspaceSchedules). diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index d0d274cc03c..83355b32bfa 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -280,6 +280,24 @@ sim workflows state replace [options] API request and response schemas: [replaceWorkflowState](/api-reference/workflows/replaceWorkflowState). +### Show a workflow’s draft as a compact read-only outline + +```bash +sim workflows outline +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +API request and response schemas: [getWorkflowOutline](/api-reference/workflows/getWorkflowOutline). + ### Edit a workflow’s blocks, connections and settings with a batch of operations OAuth login or personal API key required. diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index fabcf04f983..4466244cfba 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -596,6 +596,10 @@ "description": "Other structured context defined by the specific error." } ] + }, + "traceId": { + "description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.", + "type": "string" } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index d28f2df00a7..2bee19d5e39 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -4743,6 +4743,10 @@ "description": "Other structured context defined by the specific error." } ] + }, + "traceId": { + "description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.", + "type": "string" } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 75f473cb683..5da4ad42a65 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -6048,6 +6048,10 @@ "description": "Other structured context defined by the specific error." } ] + }, + "traceId": { + "description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.", + "type": "string" } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 58c1c1e947f..ff54cb5dfaf 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -1095,6 +1095,10 @@ "description": "Other structured context defined by the specific error." } ] + }, + "traceId": { + "description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.", + "type": "string" } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index e5a56c40eb0..60802925003 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -14957,6 +14957,10 @@ "description": "Other structured context defined by the specific error." } ] + }, + "traceId": { + "description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.", + "type": "string" } }, "required": ["code", "message"], @@ -20031,7 +20035,12 @@ }, "type": { "type": "string", - "description": "Editor control the field renders as, e.g. `short-input`." + "description": "Editor control the field renders as, e.g. `short-input`. Not a value type; see `valueType`." + }, + "valueType": { + "description": "Type of value the field holds, from the block’s declared input for the field (the same type `inputDefinitions` and operation `inputs` report). Absent when the block declares no input for the field.", + "type": "string", + "enum": ["string", "number", "boolean", "json", "array", "file"] }, "title": { "description": "Human-readable label.", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 509b1ef430d..1ab952f8223 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -6007,6 +6007,10 @@ "description": "Other structured context defined by the specific error." } ] + }, + "traceId": { + "description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.", + "type": "string" } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index d18c6fee64d..be15da73e55 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -595,6 +595,109 @@ } } }, + "/api/v2/workflows/{workflowId}/outline": { + "get": { + "operationId": "getWorkflowOutline", + "summary": "Get workflow outline", + "description": "Read the saved draft as a compact outline: connections embedded in their source blocks, loop and parallel children nested under their container, and empty values and editor-only fields omitted. Requires workspace read access, and reveals nothing Get workflow state does not: its derived fields, such as `triggerWebhookUrl`, are computed from values that state already returns. The outline is read-only: it adds those derived fields and omits positions and edge handles, so never send it to Replace workflow state; edit with Apply workflow operations or a full state from Get workflow state. This read records no audit event; `HEAD` mirrors `GET`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.read", + "x-oauth-scope": "api:read", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } + } + ], + "responses": { + "200": { + "description": "The workflow draft outline.", + "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/WorkflowOutlineResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "blocks": { + "start-1": { + "type": "starter", + "name": "Start", + "enabled": true, + "connections": { + "source": "agent-1" + } + }, + "agent-1": { + "type": "agent", + "name": "Agent 1", + "enabled": true, + "inputs": { + "systemPrompt": "Route the ticket to the right team." + } + } + } + } + } + } + } + } + } + }, + "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}/operations": { "post": { "operationId": "applyWorkflowOperations", @@ -2669,7 +2772,7 @@ "patch": { "operationId": "updateWorkflowTrigger", "summary": "Update workflow trigger", - "description": "Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets `failedCount`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns `400`. A permission group that blocks webhook triggers returns `403` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive `404`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nOAuth scope: `api:write`.", + "description": "Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets `failedCount`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns `400`. A permission group that blocks webhook triggers returns `403` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive `404`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nA schedule's cron expression and timezone cannot be changed here: deploy computes them from the schedule block, and a deployed version is never edited. Change the block's schedule fields in the draft with Apply workflow operations, then deploy.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.triggers.set_enabled", "x-oauth-scope": "api:write", "tags": ["Workflows"], @@ -2709,7 +2812,7 @@ ], "requestBody": { "required": true, - "description": "Enable or disable one trigger of the live deployment.", + "description": "Enable or disable one trigger of the live deployment. A schedule's timing is not set here: it comes from the deployed schedule block, so edit that block in the draft and deploy.", "content": { "application/json": { "schema": { @@ -2805,6 +2908,153 @@ } } }, + "/api/v2/schedules": { + "get": { + "operationId": "listWorkspaceSchedules", + "summary": "List workspace schedules", + "description": "List the schedules every deployed workflow in a workspace runs, including disabled ones, with their timing and failure counts. Requires workspace read access. These are the same schedules List workflow triggers reports for each workflow; a workflow that is not deployed has none. To change a schedule's timing, edit its schedule block in the draft and deploy; to pause or resume one, use Update workflow trigger.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.triggers.read", + "x-oauth-scope": "api:read", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose schedules should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose schedules should be listed." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum schedules to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum schedules to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "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": "createdAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["createdAt"] + } + }, + { + "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 schedules.", + "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/WorkspaceScheduleListResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": [ + { + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowName": "Customer support triage", + "blockId": "blk_01J8ZK3QW4M6X2R9T7B5C0V4", + "enabled": true, + "status": "active", + "failedCount": 0, + "lastFailedAt": null, + "nextRunAt": "2026-08-10T09:00:00.000Z", + "lastRanAt": "2026-08-10T08:00:00.000Z", + "cronExpression": "0 * * * *", + "timezone": "UTC" + } + ], + "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}/deploy": { "post": { "operationId": "deployWorkflow", @@ -7815,6 +8065,10 @@ "description": "Other structured context defined by the specific error." } ] + }, + "traceId": { + "description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.", + "type": "string" } }, "required": ["code", "message"], @@ -8684,6 +8938,156 @@ } ] }, + "WorkflowOutlineBlock": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Block type, e.g. `agent` or `slack`." + }, + "name": { + "type": "string", + "description": "Display name; references to this block's outputs use it." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "inputs": { + "description": "Configured values keyed by field id, with empty values omitted and editor-only metadata removed. Loop and parallel containers report their iteration settings here. A webhook trigger also carries a derived `triggerWebhookUrl`, and a credential-routed trigger a derived `triggerRouting`; both are read-only.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Configured value; shape depends on the field." + } + }, + "connections": { + "description": "Outgoing connections keyed by source handle: `source`, `error`, `if` / `else-if-0` / `else` for a Condition, or `route-0`, `route-1`, ... for a Router.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Target block id, or ids when the handle fans out." + } + }, + "nestedNodes": { + "description": "Blocks inside this loop or parallel container, keyed by block id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Child block in this same outline shape." + } + }, + "advancedMode": { + "description": "Whether the block shows its advanced fields.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block has an error output path.", + "type": "boolean" + }, + "retry": { + "description": "Per-block retry configuration.", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "number", + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "number", + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "additionalProperties": false + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + } + }, + "required": ["type", "name", "enabled"], + "additionalProperties": false, + "title": "Workflow outline block", + "description": "One block of a workflow outline, with its connections embedded." + }, + "WorkflowOutline": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowOutlineBlock" + }, + "description": "Top-level blocks keyed by block id. Blocks inside a loop or parallel appear under their container's `nestedNodes`." + } + }, + "required": ["blocks"], + "additionalProperties": false, + "title": "Workflow outline", + "description": "Compact read-only view of a workflow draft. Not a valid Replace workflow state body." + }, + "WorkflowOutlineResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowOutline" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow outline response", + "description": "The compact read-only outline of a workflow draft.", + "examples": [ + { + "data": { + "blocks": { + "start-1": { + "type": "starter", + "name": "Start", + "enabled": true, + "connections": { + "source": "agent-1" + } + }, + "agent-1": { + "type": "agent", + "name": "Agent 1", + "enabled": true, + "inputs": { + "systemPrompt": "Route the ticket to the right team." + } + } + } + } + } + ] + }, "WorkflowLintReport": { "type": "object", "properties": { @@ -12906,7 +13310,7 @@ "type": "null" } ], - "description": "Cron expression of a schedule; null for non-schedules." + "description": "Cron expression the deployed schedule runs on; null for non-schedules. Read-only: it is computed at deploy from the schedule block, so change it by editing the block in the draft and deploying." }, "timezone": { "anyOf": [ @@ -12917,7 +13321,7 @@ "type": "null" } ], - "description": "IANA timezone the cron expression runs in; null for non-schedules." + "description": "IANA timezone the cron expression runs in; null for non-schedules. Read-only, like `cronExpression`." }, "url": { "anyOf": [ @@ -13040,13 +13444,159 @@ "required": ["enabled"], "additionalProperties": false, "title": "Update workflow trigger request", - "description": "Enable or disable one trigger of the live deployment.", + "description": "Enable or disable one trigger of the live deployment. A schedule's timing is not set here: it comes from the deployed schedule block, so edit that block in the draft and deploy.", "examples": [ { "enabled": true } ] }, + "WorkspaceSchedule": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Trigger block in the deployed workflow." + }, + "enabled": { + "type": "boolean", + "description": "Whether the trigger currently fires." + }, + "status": { + "type": "string", + "enum": ["active", "disabled", "completed"], + "description": "`disabled` after a manual disable or an automatic one following repeated failures. `completed` applies only to schedules that have no runs left." + }, + "failedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Consecutive failures since the last success; the trigger disables itself at 100. Always 0 for `webhook` and `event` triggers." + }, + "lastFailedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the most recent counted failure, or null.", + "format": "date-time" + }, + "nextRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the next scheduled run; null when disabled and for non-schedules.", + "format": "date-time" + }, + "lastRanAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the last scheduled run; null for non-schedules.", + "format": "date-time" + }, + "cronExpression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Cron expression the deployed schedule runs on; null for non-schedules. Read-only: it is computed at deploy from the schedule block, so change it by editing the block in the draft and deploying." + }, + "workflowId": { + "type": "string", + "description": "Workflow whose live deployment runs the schedule." + }, + "workflowName": { + "type": "string", + "description": "Name of that workflow." + }, + "timezone": { + "type": "string", + "description": "IANA timezone the cron expression runs in. Read-only, like `cronExpression`." + } + }, + "required": [ + "blockId", + "enabled", + "status", + "failedCount", + "lastFailedAt", + "nextRunAt", + "lastRanAt", + "cronExpression", + "workflowId", + "workflowName", + "timezone" + ], + "additionalProperties": false, + "title": "Workspace schedule", + "description": "Health and timing of one schedule a deployed workflow in the workspace runs." + }, + "WorkspaceScheduleListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceSchedule" + }, + "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": "Workspace schedule list response", + "description": "A cursor-paginated page of the schedules deployed workflows run.", + "examples": [ + { + "data": [ + { + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowName": "Customer support triage", + "blockId": "blk_01J8ZK3QW4M6X2R9T7B5C0V4", + "enabled": true, + "status": "active", + "failedCount": 0, + "lastFailedAt": null, + "nextRunAt": "2026-08-10T09:00:00.000Z", + "lastRanAt": "2026-08-10T08:00:00.000Z", + "cronExpression": "0 * * * *", + "timezone": "UTC" + } + ], + "nextCursor": null + } + ] + }, "DeployResult": { "type": "object", "properties": { diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json index 6694ecde8c8..132bbd3395d 100644 --- a/apps/docs/scripts/fixtures/content-baseline.json +++ b/apps/docs/scripts/fixtures/content-baseline.json @@ -879,16 +879,16 @@ { "lang": "json", "meta": null, - "sha256": "573578c2c3add71d0cbed2cca9574f0a44f6761971a6feeb8dd19757480b2272", - "bytes": 81, - "line": 169 + "sha256": "770907f8d75e5512815cdbcbfc73b1595d82596ce05ba3b5aa0005ff15498522", + "bytes": 108, + "line": 173 }, { "lang": "json", "meta": null, - "sha256": "6340f4239f2a4c453c52b0ee9bdf52cbb24fe1208e4cbe23a7081d7fd8e6bc20", - "bytes": 213, - "line": 196 + "sha256": "b631aa240e7bf162bc7ee08642c5c32eb2e7d8ddcd0f8b07233726510add28ad", + "bytes": 231, + "line": 203 }, { "lang": "bash", @@ -12258,6 +12258,13 @@ "bytes": 42, "line": 5074 }, + { + "lang": "bash", + "meta": null, + "sha256": "d0f3737b421a98903f809a0b9e6dea5cd3bd96377b7dcc30fc5239e3aecf311d", + "bytes": 28, + "line": 5116 + }, { "lang": "bash", "meta": null, @@ -13000,6 +13007,13 @@ "bytes": 54, "line": 8143 }, + { + "lang": "bash", + "meta": null, + "sha256": "185227ff19342ab461c3e652c4158d8ece85c079b2542b9243b7c7cf0b2dac2c", + "bytes": 34, + "line": 8204 + }, { "lang": "bash", "meta": null, @@ -17194,6 +17208,13 @@ "bytes": 50, "line": 246 }, + { + "lang": "bash", + "meta": null, + "sha256": "185227ff19342ab461c3e652c4158d8ece85c079b2542b9243b7c7cf0b2dac2c", + "bytes": 34, + "line": 285 + }, { "lang": "bash", "meta": null, diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 6b0db3d312e..807c54b3edb 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,3 +1,4 @@ +import { getRequestContext } from '@sim/logger' import { NextResponse } from 'next/server' import type { ZodError } from 'zod' import { @@ -7,7 +8,7 @@ import { } from '@/lib/api/contracts/v2/error-codes' import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' -import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import { getValidationErrorMessage, type RequestSlice, serializeZodIssues } from '@/lib/api/server' import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' import { ContentDeliveryUnobservedError, @@ -192,14 +193,25 @@ interface V2ErrorOptions { omitRetryAfter?: boolean } -/** `{ error: { code, message, details? } }`. */ +/** + * `{ error: { code, message, details?, traceId? } }`. + * + * `traceId` is the id the route handler stamps on the `x-request-id` response + * header, repeated in the body so a caller that only logs the body can still + * quote it to support. Absent outside a route handler's request context. + */ export function v2Error( code: V2ErrorCode, message: string, options: V2ErrorOptions = {} ): NextResponse { - const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } + const error: { code: V2ErrorCode; message: string; details?: unknown; traceId?: string } = { + code, + message, + } if (options.details !== undefined) error.details = options.details + const traceId = getRequestContext()?.requestId + if (traceId) error.traceId = traceId const status = options.status ?? V2_ERROR_STATUS_BY_CODE[code] const retryAfterSeconds = options.omitRetryAfter ? undefined @@ -237,9 +249,18 @@ export function v2UploadDataPlaneError(): NextResponse { return v2Error('INTERNAL_ERROR', 'Internal server error') } -/** Render a contract `ZodError` as the v2 error envelope. */ -export function v2ValidationError(error: ZodError): NextResponse { - return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { +/** + * Render a contract `ZodError` as the v2 error envelope. The message is the + * first issue's, prefixed with where it failed (`body.limit: ...`) so a caller + * reading only the message knows which field to fix; `details` keeps every + * issue with its slice-relative path. + */ +export function v2ValidationError(error: ZodError, slice?: RequestSlice): NextResponse { + const message = getValidationErrorMessage(error, 'Invalid request') + const location = [slice, ...(error.issues[0]?.path ?? []).map(String)] + .filter((part) => part !== undefined && part !== '') + .join('.') + return v2Error('BAD_REQUEST', location ? `${location}: ${message}` : message, { details: serializeZodIssues(error), }) } diff --git a/apps/sim/app/api/v2/schedules/route.ts b/apps/sim/app/api/v2/schedules/route.ts new file mode 100644 index 00000000000..8839814a610 --- /dev/null +++ b/apps/sim/app/api/v2/schedules/route.ts @@ -0,0 +1,56 @@ +import { v2ListWorkspaceSchedulesContract } 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 { presentV2WorkspaceSchedule } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { listWorkspaceSchedules } from '@/lib/workflows/application/triggers' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Every param that changes which schedules this list returns. */ +function scheduleCursorFilters(query: { workspaceId: string }) { + return cursorScopeKey(cursorRoute(v2ListWorkspaceSchedulesContract), { + workspaceId: query.workspaceId, + }) +} + +/** + * GET /api/v2/schedules — health and timing of every schedule the workspace's + * deployed workflows run, disabled ones included. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListWorkspaceSchedulesContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.readTriggers, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + scheduleCursorFilters(query) + ), + }), + useCase: listWorkspaceSchedules, + present: ({ schedules, nextCursorKeys }, { query }) => ({ + data: schedules.map(presentV2WorkspaceSchedule), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + scheduleCursorFilters(query) + ), + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts index e5ce031b2bf..eb006d0d878 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts @@ -307,7 +307,7 @@ describe('/api/v2/workflows/[workflowId]/deployments/chat', () => { expect(response.status).toBe(400) expect((await response.json()).error.message).toBe( - 'password is required when authType is "password"' + 'body.password: password is required when authType is "password"' ) expect(mockPerformChatDeploy).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/outline/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/outline/route.ts new file mode 100644 index 00000000000..0a73d8bd9f4 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/outline/route.ts @@ -0,0 +1,24 @@ +import { v2GetWorkflowOutlineContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflowOutline } from '@/lib/workflows/application/read-workflow-graph' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[workflowId]/outline — the draft as a compact, + * read-only outline. Head-safe and unaudited for the same reasons as + * `GET /api/v2/workflows/{workflowId}/state`, which it projects. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowOutlineContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.workflowId }), + useCase: readWorkflowOutline, + present: ({ blocks }) => ({ data: { blocks } }), +}) diff --git a/apps/sim/lib/api-key/policy-messages.ts b/apps/sim/lib/api-key/policy-messages.ts index e7eb3837a52..4c0d2bd7275 100644 --- a/apps/sim/lib/api-key/policy-messages.ts +++ b/apps/sim/lib/api-key/policy-messages.ts @@ -12,4 +12,4 @@ export const PERSONAL_KEY_DENIED = 'Personal API keys are not allowed for this w export const API_KEY_EXPIRED = 'API key has expired' export const READ_ONLY_API_KEY_DENIED = - 'This API key is read-only and cannot perform this operation' + 'This API key is read-only and cannot perform this operation. Use a full-access API key.' diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts index dd905cd2008..59167a71c39 100644 --- a/apps/sim/lib/api/contracts/v2/catalog.ts +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -115,7 +115,17 @@ const catalogCredentialConditionSchema = z.object({ export const v2BlockFieldSchema = z .object({ id: z.string().describe('Field identifier, and the key its value is stored under.'), - type: z.string().describe('Editor control the field renders as, e.g. `short-input`.'), + type: z + .string() + .describe( + 'Editor control the field renders as, e.g. `short-input`. Not a value type; see `valueType`.' + ), + valueType: z + .enum(['string', 'number', 'boolean', 'json', 'array', 'file']) + .optional() + .describe( + 'Type of value the field holds, from the block’s declared input for the field (the same type `inputDefinitions` and operation `inputs` report). Absent when the block declares no input for the field.' + ), title: z.string().optional().describe('Human-readable label.'), required: z .boolean() 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 eec048b12f4..1ef82d8425e 100644 --- a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts @@ -75,6 +75,7 @@ const PAGED_LISTS = [ 'GET /api/v2/logs', 'GET /api/v2/mcp-servers', 'GET /api/v2/sandboxes', + 'GET /api/v2/schedules', 'GET /api/v2/secrets', 'GET /api/v2/skills', 'GET /api/v2/skills/[skillId]/editors', @@ -315,6 +316,7 @@ const CURSOR_BINDINGS: Record = { ], 'GET /api/v2/mcp-servers': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/sandboxes': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/schedules': ['workspaceId', 'sortBy', 'sortOrder'], 'GET /api/v2/secrets': ['workspaceId', 'scope', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/skills': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/tables': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index e077ab772b4..62292ad24d3 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -47,6 +47,7 @@ import { v2ExportWorkflowContract, v2GetWorkflowContract, v2GetWorkflowDeploymentContract, + v2GetWorkflowOutlineContract, v2GetWorkflowRunContract, v2GetWorkflowStateContract, v2GetWorkflowVersionContract, @@ -56,6 +57,7 @@ import { v2ListWorkflowsContract, v2ListWorkflowTriggersContract, v2ListWorkflowVersionsContract, + v2ListWorkspaceSchedulesContract, v2MoveWorkflowsContract, v2RelocateWorkflowFolderContract, v2ReplaceWorkflowStateContract, @@ -348,6 +350,48 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2GetWorkflowOutlineContract, + workflowOperation({ + applicationOperation: workflowOperations.read, + operationId: 'getWorkflowOutline', + summary: 'Get workflow outline', + description: + 'Read the saved draft as a compact outline: connections embedded in their source blocks, loop and parallel children nested under their container, and empty values and editor-only fields omitted. Requires workspace read access, and reveals nothing Get workflow state does not: its derived fields, such as `triggerWebhookUrl`, are computed from values that state already returns. The outline is read-only: it adds those derived fields and omits positions and edge handles, so never send it to Replace workflow state; edit with Apply workflow operations or a full state from Get workflow state. This read records no audit event; `HEAD` mirrors `GET`.', + errors: RESOURCE_ERRORS, + success: jsonSuccess('The workflow draft outline.'), + }), + { + params: v2GetWorkflowOutlineContract.params, + query: v2GetWorkflowOutlineContract.query, + response: documentedSchema( + v2GetWorkflowOutlineContract.response.schema, + 'WorkflowOutlineResponse', + 'Workflow outline response', + 'The compact read-only outline of a workflow draft.', + [ + { + data: { + blocks: { + 'start-1': { + type: 'starter', + name: 'Start', + enabled: true, + connections: { source: 'agent-1' }, + }, + 'agent-1': { + type: 'agent', + name: 'Agent 1', + enabled: true, + inputs: { systemPrompt: 'Route the ticket to the right team.' }, + }, + }, + }, + }, + ] + ), + } + ), defineOpenApiRoute( v2ReplaceWorkflowStateContract, workflowOperation({ @@ -949,7 +993,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.setTriggerEnabled, operationId: 'updateWorkflowTrigger', summary: 'Update workflow trigger', - description: `Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets \`failedCount\`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns \`400\`. A permission group that blocks webhook triggers returns \`403\` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive \`404\`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.`, + description: `Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets \`failedCount\`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns \`400\`. A permission group that blocks webhook triggers returns \`403\` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive \`404\`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nA schedule's cron expression and timezone cannot be changed here: deploy computes them from the schedule block, and a deployed version is never edited. Change the block's schedule fields in the draft with Apply workflow operations, then deploy.`, errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The trigger after the update.'), }), @@ -976,6 +1020,47 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListWorkspaceSchedulesContract, + workflowOperation({ + applicationOperation: workflowOperations.readTriggers, + operationId: 'listWorkspaceSchedules', + summary: 'List workspace schedules', + description: + "List the schedules every deployed workflow in a workspace runs, including disabled ones, with their timing and failure counts. Requires workspace read access. These are the same schedules List workflow triggers reports for each workflow; a workflow that is not deployed has none. To change a schedule's timing, edit its schedule block in the draft and deploy; to pause or resume one, use Update workflow trigger.", + errors: [...WORKSPACE_ERRORS, 'NotFound'], + success: jsonSuccess('A page of schedules.'), + }), + { + query: v2ListWorkspaceSchedulesContract.query, + response: documentedSchema( + v2ListWorkspaceSchedulesContract.response.schema, + 'WorkspaceScheduleListResponse', + 'Workspace schedule list response', + 'A cursor-paginated page of the schedules deployed workflows run.', + [ + { + data: [ + { + workflowId: WORKFLOW_ID, + workflowName: WORKFLOW_EXAMPLE.name, + blockId: WORKFLOW_TRIGGER_EXAMPLE.blockId, + enabled: true, + status: 'active', + failedCount: 0, + lastFailedAt: null, + nextRunAt: '2026-08-10T09:00:00.000Z', + lastRanAt: '2026-08-10T08:00:00.000Z', + cronExpression: '0 * * * *', + timezone: 'UTC', + }, + ], + nextCursor: null, + }, + ] + ), + } + ), defineOpenApiRoute( v2DeployWorkflowContract, workflowOperation({ diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index d73c885d5fd..a9c5608fb90 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -213,6 +213,12 @@ export const v2ErrorResponseSchema = z.object({ .describe( 'Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.' ), + traceId: z + .string() + .optional() + .describe( + 'Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.' + ), }) .describe('Canonical error details.'), }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 127aae80a39..efcdbec6e3f 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1344,11 +1344,15 @@ const v2WorkflowTriggerSchema = z cronExpression: z .string() .nullable() - .describe('Cron expression of a schedule; null for non-schedules.'), + .describe( + 'Cron expression the deployed schedule runs on; null for non-schedules. Read-only: it is computed at deploy from the schedule block, so change it by editing the block in the draft and deploying.' + ), timezone: z .string() .nullable() - .describe('IANA timezone the cron expression runs in; null for non-schedules.'), + .describe( + 'IANA timezone the cron expression runs in; null for non-schedules. Read-only, like `cronExpression`.' + ), url: z .string() .url() @@ -1403,7 +1407,8 @@ const v2UpdateWorkflowTriggerBodySchema = z .meta({ id: 'UpdateWorkflowTriggerRequest', title: 'Update workflow trigger request', - description: 'Enable or disable one trigger of the live deployment.', + description: + "Enable or disable one trigger of the live deployment. A schedule's timing is not set here: it comes from the deployed schedule block, so edit that block in the draft and deploy.", examples: [{ enabled: true }], }) @@ -1419,6 +1424,58 @@ export const v2UpdateWorkflowTriggerContract = defineRouteContract({ }, }) +const v2WorkspaceScheduleSortFields = ['createdAt'] as const + +/** One schedule the live deployments of a workspace run. */ +const v2WorkspaceScheduleSchema = v2WorkflowTriggerSchema + .pick({ + blockId: true, + enabled: true, + status: true, + failedCount: true, + lastFailedAt: true, + nextRunAt: true, + lastRanAt: true, + cronExpression: true, + }) + .extend({ + workflowId: z.string().describe('Workflow whose live deployment runs the schedule.'), + workflowName: z.string().describe('Name of that workflow.'), + timezone: z + .string() + .describe('IANA timezone the cron expression runs in. Read-only, like `cronExpression`.'), + }) + .strict() + .meta({ + id: 'WorkspaceSchedule', + title: 'Workspace schedule', + description: 'Health and timing of one schedule a deployed workflow in the workspace runs.', + }) +export type V2WorkspaceSchedule = z.output + +const v2ListWorkspaceSchedulesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace whose schedules should be listed.'), + ...v2PaginationFields({ description: 'Maximum schedules to return per page.' }), + ...v2SortFields(v2WorkspaceScheduleSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), + }) + .strict() + .meta({ + id: 'ListWorkspaceSchedulesQuery', + title: 'List workspace schedules query', + description: 'Workspace, page size, cursor, and ordering for listing schedules.', + }) + +export const v2ListWorkspaceSchedulesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/schedules', + query: v2ListWorkspaceSchedulesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkspaceScheduleSchema), + }, +}) + /** * Structured execution error — mirrors `WorkflowExecutionErrorCode` in * `@/executor/utils/errors` (duplicated literally: contracts are @@ -3277,6 +3334,81 @@ export const v2GetWorkflowStateContract = defineRouteContract({ }, }) +/** + * One block of the outline. Nested blocks are published open, as the catalog + * does for nested tool outputs: a self-referential schema publishes as an + * anonymous recursive `$ref` no generated client can name. + */ +const v2WorkflowOutlineBlockSchema = z + .object({ + type: z.string().describe('Block type, e.g. `agent` or `slack`.'), + name: z.string().describe("Display name; references to this block's outputs use it."), + enabled: z.boolean().describe('Whether the block runs.'), + inputs: z + .record(z.string(), z.unknown().describe('Configured value; shape depends on the field.')) + .optional() + .describe( + 'Configured values keyed by field id, with empty values omitted and editor-only metadata removed. Loop and parallel containers report their iteration settings here. A webhook trigger also carries a derived `triggerWebhookUrl`, and a credential-routed trigger a derived `triggerRouting`; both are read-only.' + ), + connections: z + .record( + z.string(), + z + .union([z.string(), z.array(z.string())]) + .describe('Target block id, or ids when the handle fans out.') + ) + .optional() + .describe( + 'Outgoing connections keyed by source handle: `source`, `error`, `if` / `else-if-0` / `else` for a Condition, or `route-0`, `route-1`, ... for a Router.' + ), + nestedNodes: z + .record(z.string(), z.unknown().describe('Child block in this same outline shape.')) + .optional() + .describe('Blocks inside this loop or parallel container, keyed by block id.'), + advancedMode: z.boolean().optional().describe('Whether the block shows its advanced fields.'), + errorEnabled: z.boolean().optional().describe('Whether the block has an error output path.'), + retry: z + .object({ + enabled: z.boolean().describe('Whether the block retries on failure.'), + maxTries: z.number().describe('Total attempts, including the first.'), + waitBetweenTriesMs: z.number().describe('Delay between attempts, in milliseconds.'), + }) + .optional() + .describe('Per-block retry configuration.'), + triggerMode: z.boolean().optional().describe('Whether the block acts as the workflow trigger.'), + }) + .meta({ + id: 'WorkflowOutlineBlock', + title: 'Workflow outline block', + description: 'One block of a workflow outline, with its connections embedded.', + }) + +const v2WorkflowOutlineSchema = z + .object({ + blocks: z + .record(z.string(), v2WorkflowOutlineBlockSchema) + .describe( + "Top-level blocks keyed by block id. Blocks inside a loop or parallel appear under their container's `nestedNodes`." + ), + }) + .meta({ + id: 'WorkflowOutline', + title: 'Workflow outline', + description: + 'Compact read-only view of a workflow draft. Not a valid Replace workflow state body.', + }) + +export const v2GetWorkflowOutlineContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[workflowId]/outline', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowOutlineSchema), + }, +}) + export const v2ReplaceWorkflowStateContract = defineRouteContract({ method: 'PUT', path: '/api/v2/workflows/[workflowId]/state', diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 7984fbe8faa..410f4fe5f1e 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -310,6 +310,7 @@ import { v2ExportWorkflowContract, v2GetWorkflowContract, v2GetWorkflowDeploymentContract, + v2GetWorkflowOutlineContract, v2GetWorkflowRunContract, v2GetWorkflowStateContract, v2GetWorkflowVersionContract, @@ -319,6 +320,7 @@ import { v2ListWorkflowsContract, v2ListWorkflowTriggersContract, v2ListWorkflowVersionsContract, + v2ListWorkspaceSchedulesContract, v2MoveWorkflowsContract, v2PreviewWorkflowImportContract, v2RelocateWorkflowFolderContract, @@ -1661,6 +1663,14 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/workflow-mcp-servers/[serverId]/route').then((route) => route.GET), }, + getWorkflowOutline: { + contract: v2GetWorkflowOutlineContract, + summary: 'Get workflow outline', + description: + 'Read the saved draft as a compact outline: connections embedded in their source blocks, loop and parallel children nested under their container, and empty values and editor-only fields omitted. Requires workspace read access, and reveals nothing Get workflow state does not: its derived fields, such as `triggerWebhookUrl`, are computed from values that state already returns. The outline is read-only: it adds those derived fields and omits positions and edge handles, so never send it to Replace workflow state; edit with Apply workflow operations or a full state from Get workflow state. This read records no audit event; `HEAD` mirrors `GET`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/outline/route').then((route) => route.GET), + }, getWorkflowRun: { contract: v2GetWorkflowRunContract, summary: 'Get workflow run', @@ -2273,6 +2283,13 @@ export const V2_MCP_OPERATIONS = { 'List active workspaces available to the calling credential. Personal API keys and OAuth tokens see accessible workspaces that permit user-held API credentials; a workspace API key sees only its bound workspace. Results use cursor pagination.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/workspaces/route').then((route) => route.GET), }, + listWorkspaceSchedules: { + contract: v2ListWorkspaceSchedulesContract, + summary: 'List workspace schedules', + description: + "List the schedules every deployed workflow in a workspace runs, including disabled ones, with their timing and failure counts. Requires workspace read access. These are the same schedules List workflow triggers reports for each workflow; a workflow that is not deployed has none. To change a schedule's timing, edit its schedule block in the draft and deploy; to pause or resume one, use Update workflow trigger.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/schedules/route').then((route) => route.GET), + }, moveFileItems: { contract: v2MoveFileItemsContract, summary: 'Move files', @@ -3015,7 +3032,7 @@ export const V2_MCP_OPERATIONS = { contract: v2UpdateWorkflowTriggerContract, summary: 'Update workflow trigger', description: - 'Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets `failedCount`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns `400`. A permission group that blocks webhook triggers returns `403` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive `404`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nOAuth scope: `api:write`.', + "Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets `failedCount`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns `400`. A permission group that blocks webhook triggers returns `403` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive `404`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nA schedule's cron expression and timezone cannot be changed here: deploy computes them from the schedule block, and a deployed version is never edited. Change the block's schedule fields in the draft with Apply workflow operations, then deploy.\n\nOAuth scope: `api:write`.", handler: () => import('@/app/api/v2/workflows/[workflowId]/triggers/[blockId]/route').then( (route) => route.PATCH diff --git a/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.ts b/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.ts index b9d189d63fc..b0cea6d7dcf 100644 --- a/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.ts +++ b/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.ts @@ -70,7 +70,10 @@ interface V2BodyLifecycleRouteOptions< auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy - parseOptions?: Omit + parseOptions?: Omit< + ParseRequestOptions, + 'validationErrorResponse' | 'validationErrorResponseBySlice' + > admission: V2BodyLifecycleAdmission readBody(context: V2BodyLifecycleContext): Promise mapInput(context: V2BodyLifecycleInputContext): I @@ -136,7 +139,7 @@ export function defineV2BodyLifecycleRoute< const parsed = await parseRequest(options.contract, request, context ?? {}, { ...V2_PARSE_DEFAULTS, ...options.parseOptions, - validationErrorResponse: v2ValidationError, + validationErrorResponseBySlice: v2ValidationError, }) if (!parsed.success) return parsed.response diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index be2251f91d2..8e1a7a642fb 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -232,7 +232,7 @@ export function v2InvalidBodyResponse(request: Request): NextResponse { export const V2_PARSE_DEFAULTS = { payloadTooLargeResponse: v2PayloadTooLargeResponse, invalidJsonResponse: v2InvalidJsonResponse, - validationErrorResponse: v2ValidationError, + validationErrorResponseBySlice: v2ValidationError, /** See {@link blankQueryValueValidationError}. */ rejectBlankQueryValues: true, /** See {@link duplicateQueryValueValidationError}. */ @@ -482,7 +482,10 @@ interface V2JsonRouteOptions + parseOptions?: Omit< + ParseRequestOptions, + 'validationErrorResponse' | 'validationErrorResponseBySlice' + > beforeParse?(args: { request: NextRequest principal: Principal @@ -557,7 +560,7 @@ export function defineV2JsonRoute< ...V2_PARSE_DEFAULTS, invalidJsonResponse: () => v2InvalidBodyResponse(request), ...options.parseOptions, - validationErrorResponse: v2ValidationError, + validationErrorResponseBySlice: v2ValidationError, }) if (!parsed.success) return parsed.response 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 87fba6041b9..cbcddae01a5 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 @@ -595,6 +595,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/sandboxes/{sandboxId}', load: () => import('@/app/api/v2/sandboxes/[sandboxId]/route'), }, + { + pattern: '/api/v2/schedules', + load: () => import('@/app/api/v2/schedules/route'), + }, { pattern: '/api/v2/secrets', load: () => import('@/app/api/v2/secrets/route'), @@ -811,6 +815,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/workflows/{workflowId}/operations', load: () => import('@/app/api/v2/workflows/[workflowId]/operations/route'), }, + { + pattern: '/api/v2/workflows/{workflowId}/outline', + load: () => import('@/app/api/v2/workflows/[workflowId]/outline/route'), + }, { pattern: '/api/v2/workflows/{workflowId}/restore', load: () => import('@/app/api/v2/workflows/[workflowId]/restore/route'), diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index d5b7a2eb401..0e0f8ea7cb5 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -91,8 +91,16 @@ interface RouteContextWithParams { | Record } +/** The request part a validation failure came from, named as OpenAPI's parameter `in`. */ +export type RequestSlice = 'path' | 'query' | 'header' | 'body' + export interface ParseRequestOptions { validationErrorResponse?: (error: z.ZodError) => NextResponse + /** + * Like `validationErrorResponse`, but also told which request part failed, so + * the renderer can name it. Takes precedence when both are set. + */ + validationErrorResponseBySlice?: (error: z.ZodError, slice: RequestSlice) => NextResponse invalidJsonResponse?: () => NextResponse payloadTooLargeResponse?: () => NextResponse invalidJson?: 'response' | 'throw' @@ -347,39 +355,44 @@ export async function parseRequest( if (options?.rejectDuplicateQueryValues) { const duplicated = duplicateQueryValueValidationError(rawQuery) if (duplicated) { - return { success: false, response: projectValidationError(duplicated, options) } + return { + success: false, + response: projectValidationError(duplicated, 'query', options), + } } } if (options?.rejectBlankQueryValues) { const blank = blankQueryValueValidationError(rawQuery) if (blank) { - return { success: false, response: projectValidationError(blank, options) } + return { success: false, response: projectValidationError(blank, 'query', options) } } } const params = contract.params - ? validateRequestSchema(contract.params, rawParams, options) + ? validateRequestSchema(contract.params, rawParams, 'path', options) : undefined if (params && !params.success) return params const query = contract.query - ? validateRequestSchema(contract.query, rawQuery, options) + ? validateRequestSchema(contract.query, rawQuery, 'query', options) : undefined if (query && !query.success) return query const headers = contract.headers - ? validateRequestSchema(contract.headers, rawHeaders, options) + ? validateRequestSchema(contract.headers, rawHeaders, 'header', options) : undefined if (headers && !headers.success) return headers - const parsedBody = contract.body ? validateRequestSchema(contract.body, body, options) : undefined + const parsedBody = contract.body + ? validateRequestSchema(contract.body, body, 'body', options) + : undefined if (parsedBody && !parsedBody.success) return parsedBody const nulBytes = - rejectNulBytes(params?.data, options) ?? - rejectNulBytes(query?.data, options) ?? - rejectNulBytes(parsedBody?.data, options) + rejectNulBytes(params?.data, 'path', options) ?? + rejectNulBytes(query?.data, 'query', options) ?? + rejectNulBytes(parsedBody?.data, 'body', options) if (nulBytes) return nulBytes return { @@ -400,18 +413,23 @@ export async function parseRequest( */ function rejectNulBytes( data: unknown, + slice: RequestSlice, options?: ParseRequestOptions ): { success: false; response: NextResponse } | null { const error = nulByteValidationError(data) if (!error) return null - return { success: false, response: projectValidationError(error, options) } + return { success: false, response: projectValidationError(error, slice, options) } } /** Renders a validation failure through the caller's envelope when it supplies one. */ function projectValidationError( error: z.ZodError, + slice: RequestSlice, options?: ParseRequestOptions ): NextResponse { + if (options?.validationErrorResponseBySlice) { + return options.validationErrorResponseBySlice(error, slice) + } return options?.validationErrorResponse ? options.validationErrorResponse(error) : validationErrorResponse(error) @@ -420,13 +438,14 @@ function projectValidationError( function validateRequestSchema( schema: S, data: unknown, + slice: RequestSlice, options?: ParseRequestOptions ): ValidationResult { const result = schema.safeParse(data) if (!result.success) { return { success: false, - response: projectValidationError(result.error, options), + response: projectValidationError(result.error, slice, options), error: result.error, } } diff --git a/apps/sim/lib/catalog/projection/block-detail.ts b/apps/sim/lib/catalog/projection/block-detail.ts index 7c6da2bb8d9..f6e0705ee04 100644 --- a/apps/sim/lib/catalog/projection/block-detail.ts +++ b/apps/sim/lib/catalog/projection/block-detail.ts @@ -161,6 +161,36 @@ function authorableSubBlocks(block: BlockConfig): SubBlockConfig[] { return fields.filter((subBlock) => !subBlock.hideFromCopilot) } +/** + * Projects a field and attaches its value type from the block's declared input + * for it, through the same {@link declaredInputFor} lookup + * {@link computeOperationLevelInputs} uses. A field the block + * declares no input for gets no `valueType`: its editor `type` alone does not + * say what value it holds. + */ +function projectField( + subBlock: SubBlockConfig, + declaredInputs?: BlockConfig['inputs'] +): CatalogSubBlock { + const projected = projectSubBlock(subBlock) + const declared = declaredInputFor(subBlock, declaredInputs) + if (declared) projected.valueType = declared.type + return projected +} + +/** The block input a field writes: declared under its id, else under its `canonicalParamId`. */ +function declaredInputFor( + subBlock: SubBlockConfig, + declaredInputs: BlockConfig['inputs'] = {} +): BlockConfig['inputs'][string] | undefined { + return ( + declaredInputs[subBlock.id] ?? + (subBlock.canonicalParamId !== undefined + ? declaredInputs[subBlock.canonicalParamId] + : undefined) + ) +} + /** Whether a condition gates its field on a specific operation being selected. */ function operationGate(subBlock: SubBlockConfig): { values: string[] } | undefined { const condition = normalizeCondition(subBlock.condition) @@ -181,7 +211,8 @@ function operationGate(subBlock: SubBlockConfig): { values: string[] } | undefin */ export function splitFieldsByOperation( subBlocks: SubBlockConfig[], - inputDefinitions: Record = {} + inputDefinitions: Record = {}, + declaredInputs: BlockConfig['inputs'] = {} ): { commonFields: CatalogSubBlock[] operationFields: Record @@ -190,7 +221,7 @@ export function splitFieldsByOperation( const operationFields: Record = {} for (const subBlock of subBlocks) { - const projected = projectSubBlock(subBlock) + const projected = projectField(subBlock, declaredInputs) const gate = operationGate(subBlock) if (gate) { @@ -257,9 +288,7 @@ export function computeOperationLevelInputs( for (const subBlock of authorableSubBlocks(block)) { const gate = operationGate(subBlock) if (!gate) continue - const definition = - inputs[subBlock.id] ?? - (subBlock.canonicalParamId !== undefined ? inputs[subBlock.canonicalParamId] : undefined) + const definition = declaredInputFor(subBlock, inputs) if (!definition) continue for (const operationId of gate.values) { operationInputs[operationId] ??= {} @@ -417,7 +446,7 @@ function projectCustomBlockDetail(block: BlockConfig): CatalogBlockDetail { ) return { ...summaryWithSunsetFirst(block), - inputSchema: visibleFields.map(projectSubBlock), + inputSchema: visibleFields.map((subBlock) => projectField(subBlock, block.inputs)), operationInputSchema: {}, inputDefinitions: {}, operations: {}, @@ -440,7 +469,8 @@ export function projectBlockDetail( const inputDefinitions = computeBlockLevelInputs(block, hidden) const { commonFields, operationFields } = splitFieldsByOperation( authorableSubBlocks(block), - inputDefinitions + inputDefinitions, + block.inputs ) const tools: CatalogToolDetail[] = [] diff --git a/apps/sim/lib/catalog/projection/invariants.test.ts b/apps/sim/lib/catalog/projection/invariants.test.ts index 280970e023b..858cf330325 100644 --- a/apps/sim/lib/catalog/projection/invariants.test.ts +++ b/apps/sim/lib/catalog/projection/invariants.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest' */ vi.mock('@/tools/tool-ids', () => ({ resolveToolId: (toolId: string) => toolId })) +import { projectBlockDetail } from '@/lib/catalog/projection/block-detail' import { projectBlockSummary } from '@/lib/catalog/projection/block-summary' import { AsyncOptionsFunctionError, @@ -130,3 +131,31 @@ describe('options functions must be synchronous', () => { expect(() => resolveSubBlockOptions(subBlock)).toThrow(AsyncOptionsFunctionError) }) }) + +describe('a field publishes the value type its block declares, never a guessed one', () => { + const config = block({ + type: 'sheets', + subBlocks: [ + { id: 'operation', type: 'dropdown', options: [{ id: 'read', label: 'Read' }] }, + { id: 'spreadsheetSelector', type: 'file-selector', canonicalParamId: 'spreadsheetId' }, + { + id: 'rowLimit', + type: 'short-input', + condition: { field: 'operation', value: 'read' }, + }, + ] as SubBlockConfig[], + inputs: { + operation: { type: 'string' }, + spreadsheetId: { type: 'string' }, + rowLimit: { type: 'number' }, + }, + }) + const detail = projectBlockDetail(config, { deployment: { hostedKeys: false } }) + const field = (fields: typeof detail.inputSchema, id: string) => + fields.find((candidate) => candidate.id === id) + + it('joins a picker through its canonical param and an operation field through its id', () => { + expect(field(detail.inputSchema, 'spreadsheetSelector')?.valueType).toBe('string') + expect(field(detail.operationInputSchema.read ?? [], 'rowLimit')?.valueType).toBe('number') + }) +}) diff --git a/apps/sim/lib/catalog/projection/subblock.ts b/apps/sim/lib/catalog/projection/subblock.ts index d8fb6b698e5..0a0d7eb8e30 100644 --- a/apps/sim/lib/catalog/projection/subblock.ts +++ b/apps/sim/lib/catalog/projection/subblock.ts @@ -1,4 +1,4 @@ -import type { SubBlockConfig } from '@/blocks/types' +import type { ParamType, SubBlockConfig } from '@/blocks/types' import { DYNAMIC_MODEL_PROVIDERS, getHostedModels, PROVIDER_DEFINITIONS } from '@/providers/models' import { useProvidersStore } from '@/stores/providers' @@ -61,7 +61,13 @@ export type CatalogDependsOn = string[] | { all?: string[]; any?: string[] } /** A block configuration field, projected to serializable data. */ export interface CatalogSubBlock { id: string + /** The editor control the field renders as, e.g. `short-input`. Not a value type. */ type: string + /** + * The type of value the field holds, from the block's declared input for the + * field. Absent when the block declares none. + */ + valueType?: ParamType title?: string /** * Whether the field must always be supplied. A field whose requirement diff --git a/apps/sim/lib/mothership/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts index 4c5d21144ed..d193d1fab82 100644 --- a/apps/sim/lib/mothership/generated/docs-manifest.ts +++ b/apps/sim/lib/mothership/generated/docs-manifest.ts @@ -46,6 +46,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/profiles.mdx', 'cli/reference.mdx', 'cli/sandboxes.mdx', + 'cli/schedules.mdx', 'cli/scripting.mdx', 'cli/secrets.mdx', 'cli/selectors.mdx', diff --git a/apps/sim/lib/workflows/api/index.ts b/apps/sim/lib/workflows/api/index.ts index 7d40f29c6f4..15fb042b55a 100644 --- a/apps/sim/lib/workflows/api/index.ts +++ b/apps/sim/lib/workflows/api/index.ts @@ -7,4 +7,7 @@ export { v2WorkflowErrorPolicies, WORKFLOW_NOT_FOUND_MESSAGE, } from '@/lib/workflows/api/route-policies' -export { presentV2WorkflowTrigger } from '@/lib/workflows/api/trigger-presenter' +export { + presentV2WorkflowTrigger, + presentV2WorkspaceSchedule, +} from '@/lib/workflows/api/trigger-presenter' diff --git a/apps/sim/lib/workflows/api/trigger-presenter.ts b/apps/sim/lib/workflows/api/trigger-presenter.ts index f376817c59e..451d6c9eda5 100644 --- a/apps/sim/lib/workflows/api/trigger-presenter.ts +++ b/apps/sim/lib/workflows/api/trigger-presenter.ts @@ -1,5 +1,8 @@ -import type { V2WorkflowTrigger } from '@/lib/api/contracts/v2/workflows' -import type { WorkflowTriggerHealth } from '@/lib/workflows/triggers/health' +import type { V2WorkflowTrigger, V2WorkspaceSchedule } from '@/lib/api/contracts/v2/workflows' +import type { + WorkflowTriggerHealth, + WorkspaceScheduleRecord, +} from '@/lib/workflows/triggers/health' /** The v2 wire shape of one live trigger, shared by the list and the update. */ export function presentV2WorkflowTrigger(trigger: WorkflowTriggerHealth): V2WorkflowTrigger { @@ -18,3 +21,13 @@ export function presentV2WorkflowTrigger(trigger: WorkflowTriggerHealth): V2Work url: trigger.url, } } + +/** The v2 wire shape of one schedule in the workspace-wide list. */ +export function presentV2WorkspaceSchedule({ + workflowId, + workflowName, + health, +}: WorkspaceScheduleRecord): V2WorkspaceSchedule { + const { kind, provider, url, timezone, ...schedule } = presentV2WorkflowTrigger(health) + return { workflowId, workflowName, ...schedule, timezone: timezone ?? 'UTC' } +} diff --git a/apps/sim/lib/workflows/application/read-workflow-graph.ts b/apps/sim/lib/workflows/application/read-workflow-graph.ts index 205af682e75..562b9225d6e 100644 --- a/apps/sim/lib/workflows/application/read-workflow-graph.ts +++ b/apps/sim/lib/workflows/application/read-workflow-graph.ts @@ -4,6 +4,10 @@ import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/aut import { workflowOperations } from '@/lib/workflows/application/operations' import { resolvePrincipalWorkflowContext } from '@/lib/workflows/application/principal-scope' import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' +import { + type CopilotWorkflowState, + sanitizeForCopilot, +} from '@/lib/workflows/sanitization/json-sanitizer' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' export interface ReadWorkflowGraphInput { @@ -67,3 +71,17 @@ export async function loadWorkflowGraph(context: { variables: variables ?? {}, } } + +/** + * Reads a workflow's draft as a compact read-only outline, under the same + * authorization and loader as {@link readWorkflowGraph}. It carries derived + * fields graph writes reject, so it is never a write body. + */ +export const readWorkflowOutline = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: resolvePrincipalWorkflowContext, + async execute({ context }): Promise { + const { blocks, edges, loops, parallels } = await loadWorkflowGraph(context) + return sanitizeForCopilot({ blocks, edges, loops, parallels }) + }, +}) diff --git a/apps/sim/lib/workflows/application/triggers.ts b/apps/sim/lib/workflows/application/triggers.ts index 1478e43e929..29e662062ad 100644 --- a/apps/sim/lib/workflows/application/triggers.ts +++ b/apps/sim/lib/workflows/application/triggers.ts @@ -21,12 +21,14 @@ import { isInRequestedState, type LiveWorkflowTriggerRecord, listLiveWorkflowTriggers, + listWorkspaceLiveSchedulesPage, setLiveTriggersEnabled, type TriggerLiveness, toWorkflowTriggerHealth, type WorkflowTriggerHealth, type WorkflowTriggerStatus, } from '@/lib/workflows/triggers/health' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' const TRIGGER_NOT_FOUND = 'Trigger not found' @@ -45,6 +47,27 @@ export const listWorkflowTriggers = defineAuthorizedWorkflowUseCase({ }, }) +type ListWorkspaceSchedulesInput = Parameters[0] + +/** + * Every schedule the workspace's deployed workflows run, disabled ones + * included, one keyset page at a time. The same rows and the same read + * authorization as {@link listWorkflowTriggers}, across the workspace instead + * of one workflow. + */ +export const listWorkspaceSchedules = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readTriggers, + resolveContext: ({ input }: { input: ListWorkspaceSchedulesInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ input, context }) { + const page = await listWorkspaceLiveSchedulesPage({ + ...input, + workspaceId: context.workspaceId, + }) + return { schedules: page.data, nextCursorKeys: page.nextCursorKeys } + }, +}) + /** * Which trigger to switch. Public callers name a block of the live deployment; * the legacy editor routes still name a row id, which is resolved to its block diff --git a/apps/sim/lib/workflows/triggers/health.ts b/apps/sim/lib/workflows/triggers/health.ts index 219485c820d..d7a0db7b1ce 100644 --- a/apps/sim/lib/workflows/triggers/health.ts +++ b/apps/sim/lib/workflows/triggers/health.ts @@ -17,6 +17,17 @@ import { sql, } from 'drizzle-orm' import type { AnyPgColumn } from 'drizzle-orm/pg-core' +import { + type CursorKey, + type KeysetKey, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { resolveDeployedWebhookUrl } from '@/lib/webhooks/deployed-urls' import { POLL_BACKOFF_UNTIL_KEY, POLL_SOURCE_FAILURES_KEY } from '@/lib/webhooks/polling/utils' import { @@ -109,6 +120,15 @@ function servedByDeployment( return predicate } +/** An unarchived workflow (not job) schedule row that names its block. */ +function workflowScheduleRowPredicate(): SQL | undefined { + return and( + isNull(workflowSchedule.archivedAt), + or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)), + isNotNull(workflowSchedule.blockId) + ) +} + /** * The schedule rows the deployment serves. Job schedules share the table but * belong to no workflow deployment, so they are excluded by source. @@ -117,9 +137,7 @@ function liveSchedulePredicate(workflowId: string, liveness: TriggerLiveness): S const predicate = and( eq(workflowSchedule.workflowId, workflowId), servedByDeployment(workflowSchedule.deploymentVersionId, workflowId, liveness), - isNull(workflowSchedule.archivedAt), - or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)), - isNotNull(workflowSchedule.blockId) + workflowScheduleRowPredicate() ) if (!predicate) throw new Error('Live schedule predicate is empty') return predicate @@ -379,3 +397,71 @@ export async function setLiveTriggersEnabled(input: { throw error } } + +/** A live schedule of some workflow in a workspace, with that workflow's name. */ +export interface WorkspaceScheduleRecord { + workflowId: string + workflowName: string + health: WorkflowTriggerHealth +} + +interface WorkspaceScheduleRow { + schedule: ScheduleRow + workflowId: string + workflowName: string +} + +const WORKSPACE_SCHEDULE_SORTS = { + createdAt: [ + timestampKey(workflowSchedule.createdAt, (row) => row.schedule.createdAt), + textKey(workflowSchedule.id, (row) => row.schedule.id), + ], +} satisfies Record<'createdAt', readonly KeysetKey[]> + +/** + * One keyset page of the schedules every deployed workflow in a workspace runs: + * the same rows {@link listLiveWorkflowTriggers} reports as `running` for each + * workflow, joined across the workspace in one ordered read. + */ +export async function listWorkspaceLiveSchedulesPage(params: { + workspaceId: string + sortBy: keyof typeof WORKSPACE_SCHEDULE_SORTS + sortOrder: ListSortOrder + limit: number + cursorKeys?: CursorKey[] +}): Promise<{ data: WorkspaceScheduleRecord[]; nextCursorKeys: CursorKey[] | null }> { + const keys = WORKSPACE_SCHEDULE_SORTS[params.sortBy] + const rows = await db + .select({ schedule: workflowSchedule, workflowId: workflow.id, workflowName: workflow.name }) + .from(workflowSchedule) + .innerJoin(workflow, eq(workflow.id, workflowSchedule.workflowId)) + .innerJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.id, workflowSchedule.deploymentVersionId), + eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(workflow.workspaceId, params.workspaceId), + eq(workflow.isDeployed, true), + isNull(workflow.archivedAt), + workflowScheduleRowPredicate(), + resumeKeyset(keys, params.cursorKeys, params.sortOrder) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder)) + .limit(params.limit + 1) + + const page = keysetPage(keys, rows, params.limit) + return { + data: page.data.map(({ schedule, workflowId, workflowName }) => ({ + workflowId, + workflowName, + health: toWorkflowTriggerHealth({ table: 'schedule', row: withBlockId(schedule) }), + })), + nextCursorKeys: page.nextCursorKeys, + } +} diff --git a/apps/sim/lib/workspaces/__integration__/members.integration.ts b/apps/sim/lib/workspaces/__integration__/members.integration.ts index 749e28e625f..f0a174b6cfb 100644 --- a/apps/sim/lib/workspaces/__integration__/members.integration.ts +++ b/apps/sim/lib/workspaces/__integration__/members.integration.ts @@ -101,11 +101,14 @@ async function call( const response = method === 'PATCH' ? await updateMember(request, context) : await removeMember(request, context) expect(response.headers.get('cache-control')).toMatch(/no-store/) - return { - status: response.status, - retryAfter: response.headers.get('retry-after'), - body: await response.json(), + const body = await response.json() + // traceId is per-request: check it against its header, then drop it from the compared body. + if (body?.error) { + const { traceId, ...error } = body.error + expect(traceId).toBe(response.headers.get('x-request-id')) + body.error = error } + return { status: response.status, retryAfter: response.headers.get('retry-after'), body } } async function roleOf(target: string, scope = f.workspaceId) { diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index a76ee9d6a01..802c79002a3 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -371,6 +371,18 @@ export const CLI_CONTRACT: CliContract = { updateWorkflowTrigger: { describe: 'Enable or disable one trigger of the live deployment', }, + listWorkspaceSchedules: { + describe: 'Show the timing and health of every schedule the workspace’s deployments run', + columns: [ + { header: 'workflow', path: 'workflowName' }, + { header: 'block', path: 'blockId' }, + { header: 'cron', path: 'cronExpression' }, + { header: 'timezone' }, + { header: 'status' }, + { header: 'failures', path: 'failedCount' }, + { header: 'next run', path: 'nextRunAt', format: 'timestamp' }, + ], + }, setSecret: { hidden: true }, // Soft deletes, all three: `tables restore`, `knowledge restore` and @@ -613,6 +625,10 @@ export const CLI_CONTRACT: CliContract = { * Each name is the operation's own verb instead. */ getWorkflowState: { command: 'workflows state get' }, + getWorkflowOutline: { + command: 'workflows outline', + describe: 'Show a workflow’s draft as a compact read-only outline', + }, replaceWorkflowState: { command: 'workflows state replace', confirm: 'This replaces the entire draft graph and cannot be undone.', diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 052210e9390..03292b739d6 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -5869,6 +5869,7 @@ export type GetBlockQuery = { type GetBlockResponseRef0 = { id: string type: string + valueType?: 'string' | 'number' | 'boolean' | 'json' | 'array' | 'file' title?: string required?: boolean requiredWhen?: GetBlockResponseRef1 @@ -7886,6 +7887,38 @@ export type GetWorkflowMcpServerResponse = { data: GetWorkflowMcpServerResponseRef0 } +/** `GET /api/v2/workflows/[workflowId]/outline` */ +export type GetWorkflowOutlineParams = { + workflowId: string +} + +export type GetWorkflowOutlineQuery = Record + +type GetWorkflowOutlineResponseRef0 = { + type: string + name: string + enabled: boolean + inputs?: Record + connections?: Record> + nestedNodes?: Record + advancedMode?: boolean + errorEnabled?: boolean + retry?: { + enabled: boolean + maxTries: number + waitBetweenTriesMs: number + } + triggerMode?: boolean +} + +type GetWorkflowOutlineResponseRef1 = { + blocks: Record +} + +export type GetWorkflowOutlineResponse = { + data: GetWorkflowOutlineResponseRef1 +} + /** `GET /api/v2/workflows/[workflowId]/runs/[runId]` */ export type GetWorkflowRunParams = { workflowId: string @@ -11143,6 +11176,34 @@ export type ListWorkspacesResponse = { nextCursor: string | null } +/** `GET /api/v2/schedules` */ +export type ListWorkspaceSchedulesQuery = { + workspaceId: string + limit?: number + cursor?: string + sortBy?: 'createdAt' + sortOrder?: 'asc' | 'desc' +} + +type ListWorkspaceSchedulesResponseRef0 = { + blockId: string + enabled: boolean + status: 'active' | 'disabled' | 'completed' + failedCount: number + lastFailedAt: string | null + nextRunAt: string | null + lastRanAt: string | null + cronExpression: string | null + workflowId: string + workflowName: string + timezone: string +} + +export type ListWorkspaceSchedulesResponse = { + data: Array + nextCursor: string | null +} + /** `POST /api/v2/files/move` */ export type MoveFileItemsQuery = Record @@ -19870,6 +19931,14 @@ export const V2_OPERATIONS = { summary: 'Get workflow MCP server', workspaceKeyUnsupported: true, }, + getWorkflowOutline: { + method: 'GET', + path: '/api/v2/workflows/[workflowId]/outline', + pathParams: ['workflowId'] as const, + pathParamDocs: { workflowId: 'Unique workflow identifier.' }, + responseMode: 'json', + summary: 'Get workflow outline', + }, getWorkflowRun: { method: 'GET', path: '/api/v2/workflows/[workflowId]/runs/[runId]', @@ -22720,6 +22789,43 @@ export const V2_OPERATIONS = { }, }, }, + listWorkspaceSchedules: { + method: 'GET', + path: '/api/v2/schedules', + pathParams: [] as const, + responseMode: 'json', + summary: 'List workspace schedules', + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace whose schedules should be listed.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum schedules to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + 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: ['createdAt'] as const, + default: 'createdAt', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'desc', + describe: 'Sort direction.', + }, + }, + }, moveFileItems: { method: 'POST', path: '/api/v2/files/move', diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index d918b5d976b..46ecc97a16e 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -28,6 +28,7 @@ const GROUP_ALIASES: Readonly> = { logs: 'log', 'mcp-servers': 'mcp-server', sandboxes: 'sandbox', + schedules: 'schedule', secrets: 'secret', skills: 'skill', tables: 'table', diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 6b5320089ef..62b6d54fe4c 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -183,6 +183,12 @@ const GROUP_DOCUMENTATION: Record = { description: 'Manage reusable dependency sets for Function blocks.', related: [['Function sandboxes', '/workflows/blocks/function#sandboxes']], }, + schedules: { + description: 'List the schedules deployed workflows run across a workspace.', + notes: [ + 'A schedule’s timing comes from its schedule block. Edit the block with `sim workflows operations apply`, then deploy; pause or resume one with `sim workflows triggers update`.', + ], + }, secrets: { description: 'Set and manage workspace or personal secrets.', notes: [ @@ -389,7 +395,7 @@ const GROUP_SECTIONS: Record = { title: 'Workflows', prefixes: ['list', 'get', 'create', 'update', 'duplicate', 'move', 'mv', 'delete', 'restore'], }, - { title: 'Draft state and edits', prefixes: ['state', 'operations', 'variables'] }, + { title: 'Draft state and edits', prefixes: ['state', 'outline', 'operations', 'variables'] }, { title: 'Deployments and versions', prefixes: [