diff --git a/.agents/skills/v2-api-conventions/SKILL.md b/.agents/skills/v2-api-conventions/SKILL.md index 55da39cd239..15f0caed19e 100644 --- a/.agents/skills/v2-api-conventions/SKILL.md +++ b/.agents/skills/v2-api-conventions/SKILL.md @@ -113,7 +113,9 @@ Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-p Return `nextCursor: null` on the last page and only then. Never construct a cursor client-side. -**Ordering is `sortBy` + `sortOrder`, except where there is nothing to sort by.** Fourteen lists take the pair. Two — `GET /logs` and `GET /workflows/{id}/runs` — have exactly one sortable column (start time), so there is no `sortBy` to pair with and the direction rides on a single `order` param; `sortBy`/`sortOrder` are not accepted there. That split is documented in both contracts and is the *only* sanctioned deviation. A new list picks the pair. Do not "fix" the two by accepting `sortOrder` as an alias: an alias is a second spelling of one thing with undefined precedence when both arrive, which is its own inconsistency, and renaming `order` would break every shipped caller. +**Ordering is `sortBy` + `sortOrder`, except where there is nothing to sort by.** Fifteen lists take the pair. Exactly one — `GET /workflows/{workflowId}/runs` — has a single sortable column (start time), so there is no `sortBy` to pair with and the direction rides on a single `order` param; `sortBy`/`sortOrder` are not accepted there. That is the *only* sanctioned deviation, and it is documented in its contract. A new list picks the pair. Do not "fix" it by accepting `sortOrder` as an alias: an alias is a second spelling of one thing with undefined precedence when both arrive, which is its own inconsistency. + +`GET /logs` was the second exception until it absorbed `POST /logs/query`. That fold is the cautionary tale for this rule: the justification for the `order` spelling was "logs have exactly one sortable column", and a second endpoint sorting the same rows four ways had already disproved it. When a rule's premise is contradicted by another endpoint on the same collection, fix the premise rather than documenting the exception. **A boolean query param is a real boolean**, declared with `booleanQueryFlagSchema` from `contracts/primitives.ts`. It coerces `'true'`/`'1'` and `'false'`/`'0'`/`''`, so it is a strict widening of a `z.enum(['true','false'])` — which is what two v2 params used to be, purely by inheritance from the internal shapes they reused. Reusing an internal `.shape.x` inherits the internal spelling; re-declare instead when the internal one is not the v2 convention. diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index e8a71db70ad..c4c431ae0e5 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -22,6 +22,8 @@ "(generated)/credentials", "(generated)/secrets", "(generated)/billing", + "(generated)/catalog", + "(generated)/meta", "(generated)/audit-logs" ] } diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflow-runs/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflow-runs/meta.json index c3c55e8f895..66d2d156e60 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflow-runs/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflow-runs/meta.json @@ -1,3 +1,9 @@ { - "pages": ["listWorkflowRunsV2", "getWorkflowRunV2", "resumeWorkflowRunV2", "cancelRunV2"] + "pages": [ + "listWorkflowRunsV2", + "getWorkflowRunV2", + "downloadWorkflowRunFileV2", + "resumeWorkflowRunV2", + "cancelRunV2" + ] } diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index 1017e55e280..28453d1b10f 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -5,14 +5,29 @@ "getWorkflow", "updateWorkflowV2", "deleteWorkflowV2", + "restoreWorkflow", + "duplicateWorkflow", + "moveWorkflows", + "getWorkflowState", + "replaceWorkflowState", + "applyWorkflowOperations", + "applyWorkflowVariables", "listWorkflowVersionsV2", "getWorkflowVersionV2", + "updateWorkflowVersionV2", + "activateWorkflowVersion", + "revertWorkflowVersion", "exportWorkflow", "importWorkflow", "getWorkflowDeployment", + "updateWorkflowPublicApi", "deployWorkflow", "undeployWorkflow", "rollbackWorkflow", + "listChatDeployments", + "getWorkflowChatDeployment", + "replaceWorkflowChatDeployment", + "deleteWorkflowChatDeployment", "executeWorkflowV2", "listWorkflowsFolders", "createWorkflowsFolder", diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index e8a71db70ad..c4c431ae0e5 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -22,6 +22,8 @@ "(generated)/credentials", "(generated)/secrets", "(generated)/billing", + "(generated)/catalog", + "(generated)/meta", "(generated)/audit-logs" ] } diff --git a/apps/docs/content/docs/en/cli/audit-logs.mdx b/apps/docs/content/docs/en/cli/audit-logs.mdx index 8428737e164..6acd66321be 100644 --- a/apps/docs/content/docs/en/cli/audit-logs.mdx +++ b/apps/docs/content/docs/en/cli/audit-logs.mdx @@ -12,7 +12,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio ## Get audit log ```bash -sim audit-logs get [options] +sim audit-logs get [options] ``` **Arguments** @@ -21,7 +21,7 @@ sim audit-logs get [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Audit-log entry identifier. | +| `auditLogId` | Yes | Audit-log entry identifier. | diff --git a/apps/docs/content/docs/en/cli/blocks.mdx b/apps/docs/content/docs/en/cli/blocks.mdx new file mode 100644 index 00000000000..9d95f9fc6d5 --- /dev/null +++ b/apps/docs/content/docs/en/cli/blocks.mdx @@ -0,0 +1,46 @@ +--- +title: Blocks +description: Manage blocks — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Get block + +```bash +sim blocks get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `blockId` | Yes | Block type identifier. An unversioned base type resolves to the newest version, and the response echoes the resolved id. | + + + +## List blocks + +```bash +sim blocks list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the block id, name, and description. | +| `--category ` | No | Restrict to one toolbar category. Accepted values: `blocks`, `tools`, `triggers`. | +| `--capability ` | No | Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields. Accepted values: `trigger`. | +| `--source ` | No | Restrict to shipped blocks or to this workspace’s deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/content/docs/en/cli/chat-deployments.mdx b/apps/docs/content/docs/en/cli/chat-deployments.mdx new file mode 100644 index 00000000000..840e10a0a6c --- /dev/null +++ b/apps/docs/content/docs/en/cli/chat-deployments.mdx @@ -0,0 +1,29 @@ +--- +title: Chat Deployments +description: Manage chat deployments — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## List chat deployments + +```bash +sim chat-deployments list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow-id ` | No | Restrict to deployments of one workflow. | +| `--is-active` | No | Restrict to active or inactive deployments. | +| `--no-is-active` | No | Send --is-active as false. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `identifier`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/content/docs/en/cli/commands.mdx b/apps/docs/content/docs/en/cli/commands.mdx index 8a1e209c3a1..9c12ac1f34c 100644 --- a/apps/docs/content/docs/en/cli/commands.mdx +++ b/apps/docs/content/docs/en/cli/commands.mdx @@ -33,15 +33,21 @@ These apply to every command, and may be written before or after it. | [`sim profiles`](/cli/profiles) | List profiles or add a workspace profile that shares a stored login | | [`sim audit-logs`](/cli/audit-logs) | Manage audit logs | | [`sim billing`](/cli/billing) | Manage billing | +| [`sim blocks`](/cli/blocks) | Manage blocks | +| [`sim chat-deployments`](/cli/chat-deployments) | Manage chat deployments | +| [`sim connector-types`](/cli/connector-types) | Manage connector types | | [`sim credentials`](/cli/credentials) | Manage credentials | | [`sim custom-tools`](/cli/custom-tools) | Manage custom tools | | [`sim files`](/cli/files) | Manage files | | [`sim knowledge`](/cli/knowledge) | Manage knowledge | | [`sim logs`](/cli/logs) | Manage logs | | [`sim mcp-servers`](/cli/mcp-servers) | Manage mcp servers | +| [`sim meta`](/cli/meta) | Manage meta | | [`sim secrets`](/cli/secrets) | Manage secrets | | [`sim skills`](/cli/skills) | Manage skills | | [`sim tables`](/cli/tables) | Manage tables | +| [`sim tools`](/cli/tools) | Manage tools | +| [`sim workflow-mcp-servers`](/cli/workflow-mcp-servers) | Manage workflow mcp servers | | [`sim workflows`](/cli/workflows) | Manage workflows | | [`sim workspaces`](/cli/workspaces) | Manage workspaces | diff --git a/apps/docs/content/docs/en/cli/connector-types.mdx b/apps/docs/content/docs/en/cli/connector-types.mdx new file mode 100644 index 00000000000..2823ed60e0c --- /dev/null +++ b/apps/docs/content/docs/en/cli/connector-types.mdx @@ -0,0 +1,24 @@ +--- +title: Connector Types +description: Manage connector types — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## List connector types + +```bash +sim connector-types list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the connector name. | + + diff --git a/apps/docs/content/docs/en/cli/credentials.mdx b/apps/docs/content/docs/en/cli/credentials.mdx index ff0e850e2c3..53965c66277 100644 --- a/apps/docs/content/docs/en/cli/credentials.mdx +++ b/apps/docs/content/docs/en/cli/credentials.mdx @@ -21,7 +21,7 @@ sim credentials delete [options] | Argument | Required | Description | | --- | --- | --- | -| `credentialId` | Yes | Credential to disconnect. | +| `credentialId` | Yes | Credential to update or disconnect. | @@ -72,6 +72,46 @@ sim credentials list [options] +## Update credential + +```bash +sim credentials update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `credentialId` | Yes | Credential to update or disconnect. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--display-name ` | No | New name shown for the credential in Sim. | +| `--description ` | No | New credential description. Send null to clear the stored one. | +| `--service-account-json ` | No | Write-only Google service-account JSON key. | +| `--api-token ` | No | Write-only provider API token. | +| `--domain ` | No | Provider account domain. | +| `--signing-secret ` | No | Write-only webhook signing secret. | +| `--bot-token ` | No | Write-only bot token. | +| `--client-id ` | No | OAuth client identifier. | +| `--client-secret ` | No | Write-only OAuth client secret. | +| `--certificate-id ` | No | Provider certificate mapping identifier. | +| `--org-id ` | No | Provider organization ID. | +| `--data-center ` | No | Provider data center. | +| `--auth-method ` | No | Provider authentication method. | +| `--private-key ` | No | Write-only PEM private key. | +| `--username ` | No | Provider run-as username. | + + + ## Create a service-account credential using its discovered provider schema ```bash diff --git a/apps/docs/content/docs/en/cli/custom-tools.mdx b/apps/docs/content/docs/en/cli/custom-tools.mdx index 97e61c50269..3f5af1e743f 100644 --- a/apps/docs/content/docs/en/cli/custom-tools.mdx +++ b/apps/docs/content/docs/en/cli/custom-tools.mdx @@ -30,7 +30,7 @@ sim custom-tools create [options] ## Delete custom tool ```bash -sim custom-tools delete [options] +sim custom-tools delete [options] ``` **Arguments** @@ -39,7 +39,7 @@ sim custom-tools delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique custom tool identifier. | +| `customToolId` | Yes | Unique custom tool identifier. | @@ -56,7 +56,7 @@ sim custom-tools delete [options] ## Get custom tool ```bash -sim custom-tools get +sim custom-tools get ``` **Arguments** @@ -65,7 +65,7 @@ sim custom-tools get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique custom tool identifier. | +| `customToolId` | Yes | Unique custom tool identifier. | @@ -91,7 +91,7 @@ sim custom-tools list [options] ## Update custom tool ```bash -sim custom-tools update [options] +sim custom-tools update [options] ``` **Arguments** @@ -100,7 +100,7 @@ sim custom-tools update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique custom tool identifier. | +| `customToolId` | Yes | Unique custom tool identifier. | diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index 65ee0f24985..56cf92d9857 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -107,6 +107,7 @@ Also available as `sim files folders ls`. | `--search ` | No | Case-insensitive substring match against the folder name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -129,6 +130,22 @@ Also available as `sim files folders mv`. +## Restore an archived file folder + +```bash +sim files folders restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | + + + ## Delete file ```bash @@ -226,6 +243,22 @@ sim files share set [options] +## Get file upload + +```bash +sim files uploads get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `uploadId` | Yes | Upload session identifier. | + + + ## List files ```bash @@ -267,6 +300,32 @@ Also available as `sim files mv`. +## Read a file’s text content + +```bash +sim files read [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | + + + ## Rename a file ```bash @@ -309,6 +368,32 @@ sim files restore +## Unzip an archive into a new folder beside it + +```bash +sim files unzip [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ## Replace a file’s contents ```bash diff --git a/apps/docs/content/docs/en/cli/index.mdx b/apps/docs/content/docs/en/cli/index.mdx index 5451ee09201..ffe240c7b16 100644 --- a/apps/docs/content/docs/en/cli/index.mdx +++ b/apps/docs/content/docs/en/cli/index.mdx @@ -139,6 +139,12 @@ sim tables rows query --help | [`billing`](/cli/billing) | Check plan status and credit usage | | [`audit-logs`](/cli/audit-logs) | Read organization audit logs | | [`workspaces`](/cli/workspaces) | Inspect the active workspace and its members | +| [`blocks`](/cli/blocks) | Browse the block catalog and read one block's configuration fields | +| [`tools`](/cli/tools) | Browse the tool catalog | +| [`connector-types`](/cli/connector-types) | Browse knowledge-base connector types and their config fields | +| [`chat-deployments`](/cli/chat-deployments) | List the hosted chats a workspace serves | +| [`workflow-mcp-servers`](/cli/workflow-mcp-servers) | Publish workflows as MCP tools for outside agents | +| [`meta`](/cli/meta) | Check what this API supports and which limits apply | The [command reference](/cli/commands) documents every subcommand, argument, and flag, and is generated from the CLI itself. diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index a2fb532b6d8..130b55b2da9 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -9,6 +9,392 @@ import { CommandTable } from '@/components/ui/command-table' Every command below also accepts the [global options](/cli/commands#global-options). +## Index files the workspace already stores + +```bash +sim knowledge from-workspace-files create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | + + + +## Declare the tag definitions a knowledge base needs + +```bash +sim knowledge tags save [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--definitions ` | Yes | Tag definitions: [{"tagSlot":"tag1","displayName":"category","fieldType":"text"}] (JSON, or @path / @- to read a file or stdin). | + + + +## Create tag + +```bash +sim knowledge tags create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--display-name ` | Yes | Name tag filters and document reads use for this tag. | +| `--field-type ` | No | Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3. Accepted values: `text`, `number`, `date`, `boolean`. | +| `--tag-slot ` | No | Slot to store the tag in. Omit to take the next free slot for the field type; a slot that does not belong to the field type, or one already in use, is rejected. Accepted values: `tag1`, `tag2`, `tag3`, `tag4`, `tag5`, `tag6`, `tag7`, `number1`, `number2`, `number3`, `number4`, `number5`, `date1`, `date2`, `boolean1`, `boolean2`, `boolean3`. | + + + +## Delete tag + +```bash +sim knowledge tags delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `tagId` | Yes | Unique tag definition identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Remove tag definitions no document still uses + +```bash +sim knowledge tags cleanup [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. | +| `--no-unused` | No | Send --unused as false. | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Show which tag slot a create would take for a field type + +```bash +sim knowledge tags next-slot [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--field-type ` | Yes | Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3. Accepted values: `text`, `number`, `date`, `boolean`. | + + + +## List tags + +```bash +sim knowledge tags list +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +## Show how many documents and chunks carry each tag + +```bash +sim knowledge tags usage +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +## Update tag + +```bash +sim knowledge tags update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `tagId` | Yes | Unique tag definition identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--display-name ` | No | New tag display name. | +| `--field-type ` | No | New value type for the tag. Accepted values: `text`, `number`, `date`, `boolean`. | + + + +## Enable, disable, or delete many chunks at once + +```bash +sim knowledge chunks batch-update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | +| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Create chunk + +```bash +sim knowledge chunks create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | Yes | Text to embed. It is embedded on write, so the chunk is searchable immediately. | +| `--enabled` | No | Whether the new chunk participates in search. | +| `--no-enabled` | No | Send --enabled as false. | + + + +## Delete chunk + +```bash +sim knowledge chunks delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | +| `chunkId` | Yes | Unique chunk identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Get chunk + +```bash +sim knowledge chunks get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | +| `chunkId` | Yes | Unique chunk identifier. | + + + +## List chunks + +```bash +sim knowledge chunks list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against chunk content. | +| `--enabled ` | No | Restrict to enabled or disabled chunks. `all` returns both. Accepted values: `true`, `false`, `all`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `chunkIndex`, `tokenCount`, `enabled`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## Update chunk + +```bash +sim knowledge chunks update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | +| `chunkId` | Yes | Unique chunk identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | No | Replacement text. Changing it re-embeds the chunk and re-derives its token and character counts. | +| `--enabled` | No | Whether the chunk participates in search. Disabling keeps it indexed. | +| `--no-enabled` | No | Send --enabled as false. | + + + ## Enable or disable every matching document ```bash @@ -216,7 +602,7 @@ sim knowledge create [options] ## Create knowledge connector ```bash -sim knowledge connectors create [options] +sim knowledge connectors create [options] ``` **Arguments** @@ -225,7 +611,7 @@ sim knowledge connectors create [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -246,7 +632,7 @@ sim knowledge connectors create [options] ## Delete knowledge connector ```bash -sim knowledge connectors delete [options] +sim knowledge connectors delete [options] ``` **Arguments** @@ -255,7 +641,7 @@ sim knowledge connectors delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -275,7 +661,7 @@ sim knowledge connectors delete [options] ## Get knowledge connector ```bash -sim knowledge connectors get +sim knowledge connectors get ``` **Arguments** @@ -284,7 +670,7 @@ sim knowledge connectors get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -292,7 +678,7 @@ sim knowledge connectors get ## List knowledge connector documents ```bash -sim knowledge connectors documents list [options] +sim knowledge connectors documents list [options] ``` **Arguments** @@ -301,7 +687,7 @@ sim knowledge connectors documents list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -321,7 +707,7 @@ sim knowledge connectors documents list [options] ## List knowledge connectors ```bash -sim knowledge connectors list [options] +sim knowledge connectors list [options] ``` **Arguments** @@ -330,7 +716,7 @@ sim knowledge connectors list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -349,7 +735,7 @@ sim knowledge connectors list [options] ## Update knowledge connector ```bash -sim knowledge connectors update [options] +sim knowledge connectors update [options] ``` **Arguments** @@ -358,7 +744,7 @@ sim knowledge connectors update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -378,7 +764,7 @@ sim knowledge connectors update [options] ## Update knowledge connector documents ```bash -sim knowledge connectors documents update [options] +sim knowledge connectors documents update [options] ``` **Arguments** @@ -387,7 +773,7 @@ sim knowledge connectors documents update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -489,7 +875,7 @@ Also available as `sim knowledge folders mv`. ## Delete knowledge base ```bash -sim knowledge delete [options] +sim knowledge delete [options] ``` **Arguments** @@ -498,7 +884,7 @@ sim knowledge delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -515,7 +901,7 @@ sim knowledge delete [options] ## Get knowledge base ```bash -sim knowledge get +sim knowledge get ``` **Arguments** @@ -524,7 +910,7 @@ sim knowledge get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -540,6 +926,7 @@ sim knowledge list [options] | Option | Required | Description | | --- | --- | --- | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | @@ -548,10 +935,10 @@ sim knowledge list [options] -## List tags +## Restore an archived knowledge base ```bash -sim knowledge tags list +sim knowledge restore ``` **Arguments** @@ -591,7 +978,7 @@ sim knowledge search [options] ## Sync knowledge connector ```bash -sim knowledge sync create [options] +sim knowledge sync create [options] ``` **Arguments** @@ -600,7 +987,7 @@ sim knowledge sync create [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -619,7 +1006,7 @@ sim knowledge sync create [options] ## Update knowledge base ```bash -sim knowledge update [options] +sim knowledge update [options] ``` **Arguments** @@ -628,7 +1015,7 @@ sim knowledge update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -648,7 +1035,7 @@ sim knowledge update [options] ## Move a knowledge base to a folder ```bash -sim knowledge mv +sim knowledge mv ``` **Arguments** @@ -657,7 +1044,7 @@ sim knowledge mv | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | | `folder` | Yes | Folder path as shown in the app; the leading / is optional | diff --git a/apps/docs/content/docs/en/cli/logs.mdx b/apps/docs/content/docs/en/cli/logs.mdx index d6334d9db42..920792e8bdf 100644 --- a/apps/docs/content/docs/en/cli/logs.mdx +++ b/apps/docs/content/docs/en/cli/logs.mdx @@ -35,6 +35,28 @@ sim logs get [options] +## Summarize run counts, failures, and cost over a window + +```bash +sim logs stats [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | +| `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | +| `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | +| `--segment-count ` | No | Number of equal time buckets to divide the window into, from 1 to 500. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty. | + + + ## List logs ```bash @@ -47,8 +69,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -61,8 +83,13 @@ sim logs list [options] | `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--order ` | No | Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects. Accepted values: `asc`, `desc`. | +| `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | +| `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | +| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | +| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | diff --git a/apps/docs/content/docs/en/cli/mcp-servers.mdx b/apps/docs/content/docs/en/cli/mcp-servers.mdx index 46afdcad279..9f618b0100d 100644 --- a/apps/docs/content/docs/en/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/mcp-servers.mdx @@ -39,7 +39,7 @@ sim mcp-servers create [options] ## Delete MCP server ```bash -sim mcp-servers delete [options] +sim mcp-servers delete [options] ``` **Arguments** @@ -48,7 +48,7 @@ sim mcp-servers delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique MCP server identifier. | +| `mcpServerId` | Yes | Unique MCP server identifier. | @@ -65,7 +65,7 @@ sim mcp-servers delete [options] ## Get MCP server ```bash -sim mcp-servers get +sim mcp-servers get ``` **Arguments** @@ -74,7 +74,7 @@ sim mcp-servers get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique MCP server identifier. | +| `mcpServerId` | Yes | Unique MCP server identifier. | @@ -100,7 +100,7 @@ sim mcp-servers list [options] ## List MCP server tools ```bash -sim mcp-servers tools list [options] +sim mcp-servers tools list [options] ``` **Arguments** @@ -109,7 +109,7 @@ sim mcp-servers tools list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique MCP server identifier. | +| `mcpServerId` | Yes | Unique MCP server identifier. | @@ -127,7 +127,7 @@ sim mcp-servers tools list [options] ## Update MCP server ```bash -sim mcp-servers update [options] +sim mcp-servers update [options] ``` **Arguments** @@ -136,7 +136,7 @@ sim mcp-servers update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique MCP server identifier. | +| `mcpServerId` | Yes | Unique MCP server identifier. | diff --git a/apps/docs/content/docs/en/cli/meta.json b/apps/docs/content/docs/en/cli/meta.json index 5e3ceb688a5..d90b96413aa 100644 --- a/apps/docs/content/docs/en/cli/meta.json +++ b/apps/docs/content/docs/en/cli/meta.json @@ -14,15 +14,21 @@ "profiles", "audit-logs", "billing", + "blocks", + "chat-deployments", + "connector-types", "credentials", "custom-tools", "files", "knowledge", "logs", "mcp-servers", + "meta", "secrets", "skills", "tables", + "tools", + "workflow-mcp-servers", "workflows", "workspaces", "reference" diff --git a/apps/docs/content/docs/en/cli/meta.mdx b/apps/docs/content/docs/en/cli/meta.mdx new file mode 100644 index 00000000000..66f7a488b23 --- /dev/null +++ b/apps/docs/content/docs/en/cli/meta.mdx @@ -0,0 +1,14 @@ +--- +title: Meta +description: Manage meta — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Show what this API supports and which limits apply + +```bash +sim meta status +``` diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index 4447a60c831..f78d567aa6d 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -178,7 +178,7 @@ Also spelled `sim audit-log`. Get Audit Log ```bash -sim audit-logs get [options] +sim audit-logs get [options] ``` **Arguments** @@ -187,7 +187,7 @@ sim audit-logs get [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Audit-log entry identifier. | +| `auditLogId` | Yes | Audit-log entry identifier. | @@ -272,6 +272,95 @@ sim billing logs [options] +## sim blocks + +### sim blocks get + +Get Block + +```bash +sim blocks get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `blockId` | Yes | Block type identifier. An unversioned base type resolves to the newest version, and the response echoes the resolved id. | + + + +### sim blocks list + +List Blocks + +```bash +sim blocks list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the block id, name, and description. | +| `--category ` | No | Restrict to one toolbar category. Accepted values: `blocks`, `tools`, `triggers`. | +| `--capability ` | No | Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields. Accepted values: `trigger`. | +| `--source ` | No | Restrict to shipped blocks or to this workspace’s deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## sim chat-deployments + +### sim chat-deployments list + +List Chat Deployments + +```bash +sim chat-deployments list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow-id ` | No | Restrict to deployments of one workflow. | +| `--is-active` | No | Restrict to active or inactive deployments. | +| `--no-is-active` | No | Send --is-active as false. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `identifier`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## sim connector-types + +### sim connector-types list + +List Connector Types + +```bash +sim connector-types list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the connector name. | + + + ## sim credentials Also spelled `sim credential`. @@ -290,7 +379,7 @@ sim credentials delete [options] | Argument | Required | Description | | --- | --- | --- | -| `credentialId` | Yes | Credential to disconnect. | +| `credentialId` | Yes | Credential to update or disconnect. | @@ -345,6 +434,48 @@ sim credentials list [options] +### sim credentials update + +Update Credential + +```bash +sim credentials update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `credentialId` | Yes | Credential to update or disconnect. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--display-name ` | No | New name shown for the credential in Sim. | +| `--description ` | No | New credential description. Send null to clear the stored one. | +| `--service-account-json ` | No | Write-only Google service-account JSON key. | +| `--api-token ` | No | Write-only provider API token. | +| `--domain ` | No | Provider account domain. | +| `--signing-secret ` | No | Write-only webhook signing secret. | +| `--bot-token ` | No | Write-only bot token. | +| `--client-id ` | No | OAuth client identifier. | +| `--client-secret ` | No | Write-only OAuth client secret. | +| `--certificate-id ` | No | Provider certificate mapping identifier. | +| `--org-id ` | No | Provider organization ID. | +| `--data-center ` | No | Provider data center. | +| `--auth-method ` | No | Provider authentication method. | +| `--private-key ` | No | Write-only PEM private key. | +| `--username ` | No | Provider run-as username. | + + + ### sim credentials create Create a service-account credential using its discovered provider schema @@ -451,7 +582,7 @@ sim custom-tools create [options] Delete Custom Tool ```bash -sim custom-tools delete [options] +sim custom-tools delete [options] ``` **Arguments** @@ -460,7 +591,7 @@ sim custom-tools delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique custom tool identifier. | +| `customToolId` | Yes | Unique custom tool identifier. | @@ -479,7 +610,7 @@ sim custom-tools delete [options] Get Custom Tool ```bash -sim custom-tools get +sim custom-tools get ``` **Arguments** @@ -488,7 +619,7 @@ sim custom-tools get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique custom tool identifier. | +| `customToolId` | Yes | Unique custom tool identifier. | @@ -518,7 +649,7 @@ sim custom-tools list [options] Update Custom Tool ```bash -sim custom-tools update [options] +sim custom-tools update [options] ``` **Arguments** @@ -527,7 +658,7 @@ sim custom-tools update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique custom tool identifier. | +| `customToolId` | Yes | Unique custom tool identifier. | @@ -655,6 +786,7 @@ Also available as `sim files folders ls`. | `--search ` | No | Case-insensitive substring match against the folder name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -679,6 +811,24 @@ Also available as `sim files folders mv`. +### sim files folders restore + +Restore an archived file folder + +```bash +sim files folders restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | + + + ### sim files delete Delete File @@ -784,6 +934,24 @@ sim files share set [options] +### sim files uploads get + +Get File Upload + +```bash +sim files uploads get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `uploadId` | Yes | Upload session identifier. | + + + ### sim files list List Files @@ -829,6 +997,34 @@ Also available as `sim files mv`. +### sim files read + +Read a file’s text content + +```bash +sim files read [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | + + + ### sim files rename Rename a file @@ -875,6 +1071,34 @@ sim files restore +### sim files unzip + +Unzip an archive into a new folder beside it + +```bash +sim files unzip [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ### sim files set-content Replace a file’s contents @@ -1013,12 +1237,12 @@ sim files mkdir Also spelled `sim kb`. -### sim knowledge documents batch-update +### sim knowledge from-workspace-files create -Enable or disable every matching document +Index files the workspace already stores ```bash -sim knowledge documents batch-update [options] +sim knowledge from-workspace-files create [options] ``` **Arguments** @@ -1037,19 +1261,16 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | -| `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | -| `--select-all` | No | Apply to every document in the knowledge base. | -| `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | -### sim knowledge documents delete +### sim knowledge tags save -Delete Document +Declare the tag definitions a knowledge base needs ```bash -sim knowledge documents delete [options] +sim knowledge tags save [options] ``` **Arguments** @@ -1059,7 +1280,6 @@ sim knowledge documents delete [options] | Argument | Required | Description | | --- | --- | --- | | `knowledgeBaseId` | Yes | Unique knowledge base identifier. | -| `documentId` | Yes | Unique knowledge document identifier. | @@ -1069,16 +1289,16 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--definitions ` | Yes | Tag definitions: [{"tagSlot":"tag1","displayName":"category","fieldType":"text"}] (JSON, or @path / @- to read a file or stdin). | -### sim knowledge documents get +### sim knowledge tags create -Get Document +Create Tag ```bash -sim knowledge documents get +sim knowledge tags create [options] ``` **Arguments** @@ -1088,16 +1308,27 @@ sim knowledge documents get | Argument | Required | Description | | --- | --- | --- | | `knowledgeBaseId` | Yes | Unique knowledge base identifier. | -| `documentId` | Yes | Unique knowledge document identifier. | -### sim knowledge documents list +**Options** -List Documents + + +| Option | Required | Description | +| --- | --- | --- | +| `--display-name ` | Yes | Name tag filters and document reads use for this tag. | +| `--field-type ` | No | Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3. Accepted values: `text`, `number`, `date`, `boolean`. | +| `--tag-slot ` | No | Slot to store the tag in. Omit to take the next free slot for the field type; a slot that does not belong to the field type, or one already in use, is rejected. Accepted values: `tag1`, `tag2`, `tag3`, `tag4`, `tag5`, `tag6`, `tag7`, `number1`, `number2`, `number3`, `number4`, `number5`, `date1`, `date2`, `boolean1`, `boolean2`, `boolean3`. | + + + +### sim knowledge tags delete + +Delete Tag ```bash -sim knowledge documents list [options] +sim knowledge tags delete [options] ``` **Arguments** @@ -1107,6 +1338,7 @@ sim knowledge documents list [options] | Argument | Required | Description | | --- | --- | --- | | `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `tagId` | Yes | Unique tag definition identifier. | @@ -1116,21 +1348,16 @@ sim knowledge documents list [options] | Option | Required | Description | | --- | --- | --- | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--search ` | No | Case-insensitive substring match against the document filename. | -| `--enabled-filter ` | No | Filter by whether documents are enabled for search. Accepted values: `all`, `enabled`, `disabled`. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `filename`, `fileSize`, `tokenCount`, `chunkCount`, `uploadedAt`, `processingStatus`, `enabled`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--tag-filters ` | No | A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{"tagName":"category","operator":"eq","value":"billing"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim knowledge documents update +### sim knowledge tags cleanup -Update Document +Remove tag definitions no document still uses ```bash -sim knowledge documents update [options] +sim knowledge tags cleanup [options] ``` **Arguments** @@ -1140,7 +1367,6 @@ sim knowledge documents update [options] | Argument | Required | Description | | --- | --- | --- | | `knowledgeBaseId` | Yes | Unique knowledge base identifier. | -| `documentId` | Yes | Unique knowledge document identifier. | @@ -1150,40 +1376,18 @@ sim knowledge documents update [options] | Option | Required | Description | | --- | --- | --- | -| `--filename ` | No | New filename for the document. | -| `--enabled` | No | Whether the document participates in search. Disabling keeps it indexed. | -| `--no-enabled` | No | Send --enabled as false. | -| `--tag1 ` | No | New value for tag slot 1. | -| `--tag2 ` | No | New value for tag slot 2. | -| `--tag3 ` | No | New value for tag slot 3. | -| `--tag4 ` | No | New value for tag slot 4. | -| `--tag5 ` | No | New value for tag slot 5. | -| `--tag6 ` | No | New value for tag slot 6. | -| `--tag7 ` | No | New value for tag slot 7. | -| `--number1 ` | No | New value for number tag slot 1. | -| `--number2 ` | No | New value for number tag slot 2. | -| `--number3 ` | No | New value for number tag slot 3. | -| `--number4 ` | No | New value for number tag slot 4. | -| `--number5 ` | No | New value for number tag slot 5. | -| `--date1 ` | No | New value for date tag slot 1, formatted YYYY-MM-DD. | -| `--date2 ` | No | New value for date tag slot 2, formatted YYYY-MM-DD. | -| `--boolean1` | No | New value for boolean tag slot 1. | -| `--no-boolean1` | No | Send --boolean1 as false. | -| `--boolean2` | No | New value for boolean tag slot 2. | -| `--no-boolean2` | No | Send --boolean2 as false. | -| `--boolean3` | No | New value for boolean tag slot 3. | -| `--no-boolean3` | No | Send --boolean3 as false. | -| `--retry-processing` | No | Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document. | -| `--no-retry-processing` | No | Send --retry-processing as false. | +| `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. | +| `--no-unused` | No | Send --unused as false. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim knowledge documents upload +### sim knowledge tags next-slot -Upload a document to a knowledge base +Show which tag slot a create would take for a field type ```bash -sim knowledge documents upload [options] +sim knowledge tags next-slot [options] ``` **Arguments** @@ -1192,8 +1396,7 @@ sim knowledge documents upload [options] | Argument | Required | Description | | --- | --- | --- | -| `knowledgeBaseId` | Yes | Knowledge base to upload into | -| `path` | Yes | Local file to upload | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -1203,40 +1406,34 @@ sim knowledge documents upload [options] | Option | Required | Description | | --- | --- | --- | -| `--name ` | No | Store it under a different name. | -| `--tag ` | No | Document tags, in tag1 through tag7 order. | -| `--recipe ` | No | Document processing recipe. | -| `--lang ` | No | Document language code. | +| `--field-type ` | Yes | Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3. Accepted values: `text`, `number`, `date`, `boolean`. | -### sim knowledge create +### sim knowledge tags list -Create Knowledge Base +List Tags ```bash -sim knowledge create [options] +sim knowledge tags list ``` -**Options** +**Arguments** -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `--name ` | Yes | Human-readable knowledge base name. | -| `--description ` | No | Optional knowledge base description. | -| `--chunking-config ` | No | Chunking configuration; defaults are applied when omitted. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | -### sim knowledge connectors create +### sim knowledge tags usage -Create Knowledge Connector +Show how many documents and chunks carry each tag ```bash -sim knowledge connectors create [options] +sim knowledge tags usage ``` **Arguments** @@ -1245,30 +1442,16 @@ sim knowledge connectors create [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--connector-type ` | Yes | Registered connector type. | -| `--credential-id ` | No | OAuth credential identifier for connectors that require OAuth. | -| `--api-key ` | No | Write-only API key for connectors that use API-key authentication. | -| `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | -| `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | -### sim knowledge connectors delete +### sim knowledge tags update -Delete Knowledge Connector +Update Tag ```bash -sim knowledge connectors delete [options] +sim knowledge tags update [options] ``` **Arguments** @@ -1277,8 +1460,8 @@ sim knowledge connectors delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | -| `connectorId` | Yes | Connector selected for the operation. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `tagId` | Yes | Unique tag definition identifier. | @@ -1288,18 +1471,17 @@ sim knowledge connectors delete [options] | Option | Required | Description | | --- | --- | --- | -| `--delete-documents` | No | Also permanently delete documents produced by this connector. | -| `--no-delete-documents` | No | Send --delete-documents as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--display-name ` | No | New tag display name. | +| `--field-type ` | No | New value type for the tag. Accepted values: `text`, `number`, `date`, `boolean`. | -### sim knowledge connectors get +### sim knowledge chunks batch-update -Get Knowledge Connector +Enable, disable, or delete many chunks at once ```bash -sim knowledge connectors get +sim knowledge chunks batch-update [options] ``` **Arguments** @@ -1308,17 +1490,29 @@ sim knowledge connectors get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | -| `connectorId` | Yes | Connector selected for the operation. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | -### sim knowledge connectors documents list +**Options** -List Knowledge Connector Documents + + +| Option | Required | Description | +| --- | --- | --- | +| `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | +| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim knowledge chunks create + +Create Chunk ```bash -sim knowledge connectors documents list [options] +sim knowledge chunks create [options] ``` **Arguments** @@ -1327,8 +1521,8 @@ sim knowledge connectors documents list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | -| `connectorId` | Yes | Connector selected for the operation. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | @@ -1338,18 +1532,18 @@ sim knowledge connectors documents list [options] | Option | Required | Description | | --- | --- | --- | -| `--include-excluded` | No | Include documents explicitly excluded by a user. | -| `--no-include-excluded` | No | Send --include-excluded as false. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--content ` | Yes | Text to embed. It is embedded on write, so the chunk is searchable immediately. | +| `--enabled` | No | Whether the new chunk participates in search. | +| `--no-enabled` | No | Send --enabled as false. | -### sim knowledge connectors list +### sim knowledge chunks delete -List Knowledge Connectors +Delete Chunk ```bash -sim knowledge connectors list [options] +sim knowledge chunks delete [options] ``` **Arguments** @@ -1358,7 +1552,9 @@ sim knowledge connectors list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | +| `chunkId` | Yes | Unique chunk identifier. | @@ -1368,18 +1564,16 @@ sim knowledge connectors list [options] | Option | Required | Description | | --- | --- | --- | -| `--sort-by ` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim knowledge connectors update +### sim knowledge chunks get -Update Knowledge Connector +Get Chunk ```bash -sim knowledge connectors update [options] +sim knowledge chunks get ``` **Arguments** @@ -1388,29 +1582,18 @@ sim knowledge connectors update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | -| `connectorId` | Yes | Connector selected for the operation. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--source-config ` | No | Replacement source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | -| `--sync-interval-minutes ` | No | New scheduled synchronization interval in minutes. | -| `--status ` | No | New connector state. Accepted values: `active`, `paused`. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | +| `chunkId` | Yes | Unique chunk identifier. | -### sim knowledge connectors documents update +### sim knowledge chunks list -Update Knowledge Connector Documents +List Chunks ```bash -sim knowledge connectors documents update [options] +sim knowledge chunks list [options] ``` **Arguments** @@ -1419,8 +1602,8 @@ sim knowledge connectors documents update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | -| `connectorId` | Yes | Connector selected for the operation. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | @@ -1430,17 +1613,20 @@ sim knowledge connectors documents update [options] | Option | Required | Description | | --- | --- | --- | -| `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--search ` | No | Case-insensitive substring match against chunk content. | +| `--enabled ` | No | Restrict to enabled or disabled chunks. `all` returns both. Accepted values: `true`, `false`, `all`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `chunkIndex`, `tokenCount`, `enabled`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -### sim knowledge folders create +### sim knowledge chunks update -Create a knowledge folder at a path +Update Chunk ```bash -sim knowledge folders create +sim knowledge chunks update [options] ``` **Arguments** @@ -1449,16 +1635,30 @@ sim knowledge folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | +| `chunkId` | Yes | Unique chunk identifier. | -### sim knowledge folders delete +**Options** -Delete Folder + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | No | Replacement text. Changing it re-embeds the chunk and re-derives its token and character counts. | +| `--enabled` | No | Whether the chunk participates in search. Disabling keeps it indexed. | +| `--no-enabled` | No | Send --enabled as false. | + + + +### sim knowledge documents batch-update + +Enable or disable every matching document ```bash -sim knowledge folders delete [options] +sim knowledge documents batch-update [options] ``` **Arguments** @@ -1467,7 +1667,7 @@ sim knowledge folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -1477,20 +1677,31 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | -| `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--select-all` | No | Apply to every document in the knowledge base. | +| `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | -### sim knowledge folders list +### sim knowledge documents delete -List Folders +Delete Document ```bash -sim knowledge folders list [options] +sim knowledge documents delete [options] ``` -Also available as `sim knowledge folders ls`. +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | + + **Options** @@ -1498,40 +1709,35 @@ Also available as `sim knowledge folders ls`. | Option | Required | Description | | --- | --- | --- | -| `--parent ` | No | Direct parent folder path. | -| `--search ` | No | Case-insensitive substring match against the folder name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim knowledge folders move +### sim knowledge documents get -Rename or move a knowledge folder +Get Document ```bash -sim knowledge folders move +sim knowledge documents get ``` -Also available as `sim knowledge folders mv`. - **Arguments** | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path as shown in the app; the leading / is optional | -| `destination` | Yes | Folder path as shown in the app; the leading / is optional | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | -### sim knowledge delete +### sim knowledge documents list -Delete Knowledge Base +List Documents ```bash -sim knowledge delete [options] +sim knowledge documents list [options] ``` **Arguments** @@ -1540,7 +1746,7 @@ sim knowledge delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -1550,16 +1756,21 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--search ` | No | Case-insensitive substring match against the document filename. | +| `--enabled-filter ` | No | Filter by whether documents are enabled for search. Accepted values: `all`, `enabled`, `disabled`. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `filename`, `fileSize`, `tokenCount`, `chunkCount`, `uploadedAt`, `processingStatus`, `enabled`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--tag-filters ` | No | A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{"tagName":"category","operator":"eq","value":"billing"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored. | -### sim knowledge get +### sim knowledge documents update -Get Knowledge Base +Update Document ```bash -sim knowledge get +sim knowledge documents update [options] ``` **Arguments** @@ -1568,38 +1779,51 @@ sim knowledge get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `documentId` | Yes | Unique knowledge document identifier. | -### sim knowledge list - -List Knowledge Bases - -```bash -sim knowledge list [options] -``` - **Options** | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | -| `--search ` | No | Case-insensitive substring match against the resource name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--filename ` | No | New filename for the document. | +| `--enabled` | No | Whether the document participates in search. Disabling keeps it indexed. | +| `--no-enabled` | No | Send --enabled as false. | +| `--tag1 ` | No | New value for tag slot 1. | +| `--tag2 ` | No | New value for tag slot 2. | +| `--tag3 ` | No | New value for tag slot 3. | +| `--tag4 ` | No | New value for tag slot 4. | +| `--tag5 ` | No | New value for tag slot 5. | +| `--tag6 ` | No | New value for tag slot 6. | +| `--tag7 ` | No | New value for tag slot 7. | +| `--number1 ` | No | New value for number tag slot 1. | +| `--number2 ` | No | New value for number tag slot 2. | +| `--number3 ` | No | New value for number tag slot 3. | +| `--number4 ` | No | New value for number tag slot 4. | +| `--number5 ` | No | New value for number tag slot 5. | +| `--date1 ` | No | New value for date tag slot 1, formatted YYYY-MM-DD. | +| `--date2 ` | No | New value for date tag slot 2, formatted YYYY-MM-DD. | +| `--boolean1` | No | New value for boolean tag slot 1. | +| `--no-boolean1` | No | Send --boolean1 as false. | +| `--boolean2` | No | New value for boolean tag slot 2. | +| `--no-boolean2` | No | Send --boolean2 as false. | +| `--boolean3` | No | New value for boolean tag slot 3. | +| `--no-boolean3` | No | Send --boolean3 as false. | +| `--retry-processing` | No | Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document. | +| `--no-retry-processing` | No | Send --retry-processing as false. | -### sim knowledge tags list +### sim knowledge documents upload -List Tags +Upload a document to a knowledge base ```bash -sim knowledge tags list +sim knowledge documents upload [options] ``` **Arguments** @@ -1608,16 +1832,30 @@ sim knowledge tags list | Argument | Required | Description | | --- | --- | --- | -| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Knowledge base to upload into | +| `path` | Yes | Local file to upload | -### sim knowledge search +**Options** -Search Knowledge + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Store it under a different name. | +| `--tag ` | No | Document tags, in tag1 through tag7 order. | +| `--recipe ` | No | Document processing recipe. | +| `--lang ` | No | Document language code. | + + + +### sim knowledge create + +Create Knowledge Base ```bash -sim knowledge search [options] +sim knowledge create [options] ``` **Options** @@ -1626,24 +1864,19 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | -| `--query ` | No | Text to search for. | -| `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | -| `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | -| `--search-mode ` | No | Search algorithm. Accepted values: `vector`, `hybrid`. | -| `--reranker-enabled` | No | Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response. | -| `--no-reranker-enabled` | No | Send --reranker-enabled as false. | -| `--reranker-model ` | No | Reranking model to use when `rerankerEnabled` is true. Defaults to `rerank-v4.0-fast`. Accepted values: `rerank-v4.0-pro`, `rerank-v4.0-fast`, `rerank-v3.5`. | -| `--reranker-input-count ` | No | How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from. | +| `--name ` | Yes | Human-readable knowledge base name. | +| `--description ` | No | Optional knowledge base description. | +| `--chunking-config ` | No | Chunking configuration; defaults are applied when omitted. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | -### sim knowledge sync create +### sim knowledge connectors create -Sync Knowledge Connector +Create Knowledge Connector ```bash -sim knowledge sync create [options] +sim knowledge connectors create [options] ``` **Arguments** @@ -1652,8 +1885,7 @@ sim knowledge sync create [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | -| `connectorId` | Yes | Connector selected for the operation. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -1663,17 +1895,675 @@ sim knowledge sync create [options] | Option | Required | Description | | --- | --- | --- | -| `--rehydrate` | No | Re-fetch and re-index every existing connector document. | -| `--no-rehydrate` | No | Send --rehydrate as false. | +| `--connector-type ` | Yes | Registered connector type. | +| `--credential-id ` | No | OAuth credential identifier for connectors that require OAuth. | +| `--api-key ` | No | Write-only API key for connectors that use API-key authentication. | +| `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | +| `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. | + + + +### sim knowledge connectors delete + +Delete Knowledge Connector + +```bash +sim knowledge connectors delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--delete-documents` | No | Also permanently delete documents produced by this connector. | +| `--no-delete-documents` | No | Send --delete-documents as false. | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim knowledge connectors get + +Get Knowledge Connector + +```bash +sim knowledge connectors get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +### sim knowledge connectors documents list + +List Knowledge Connector Documents + +```bash +sim knowledge connectors documents list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-excluded` | No | Include documents explicitly excluded by a user. | +| `--no-include-excluded` | No | Send --include-excluded as false. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim knowledge connectors list + +List Knowledge Connectors + +```bash +sim knowledge connectors list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim knowledge connectors update + +Update Knowledge Connector + +```bash +sim knowledge connectors update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--source-config ` | No | Replacement source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | +| `--sync-interval-minutes ` | No | New scheduled synchronization interval in minutes. | +| `--status ` | No | New connector state. Accepted values: `active`, `paused`. | + + + +### sim knowledge connectors documents update + +Update Knowledge Connector Documents + +```bash +sim knowledge connectors documents update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | + + + +### sim knowledge folders create + +Create a knowledge folder at a path + +```bash +sim knowledge folders create +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | + + + +### sim knowledge folders delete + +Delete Folder + +```bash +sim knowledge folders delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--recursive` | No | Delete the folder and its descendants. | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim knowledge folders list + +List Folders + +```bash +sim knowledge folders list [options] +``` + +Also available as `sim knowledge folders ls`. + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--parent ` | No | Direct parent folder path. | +| `--search ` | No | Case-insensitive substring match against the folder name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + +### sim knowledge folders move + +Rename or move a knowledge folder + +```bash +sim knowledge folders move +``` + +Also available as `sim knowledge folders mv`. + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | + + + +### sim knowledge delete + +Delete Knowledge Base + +```bash +sim knowledge delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim knowledge get + +Get Knowledge Base + +```bash +sim knowledge get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +### sim knowledge list + +List Knowledge Bases + +```bash +sim knowledge list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--search ` | No | Case-insensitive substring match against the resource name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim knowledge restore + +Restore an archived knowledge base + +```bash +sim knowledge restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +### sim knowledge search + +Search Knowledge + +```bash +sim knowledge search [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--query ` | No | Text to search for. | +| `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | +| `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | +| `--search-mode ` | No | Search algorithm. Accepted values: `vector`, `hybrid`. | +| `--reranker-enabled` | No | Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response. | +| `--no-reranker-enabled` | No | Send --reranker-enabled as false. | +| `--reranker-model ` | No | Reranking model to use when `rerankerEnabled` is true. Defaults to `rerank-v4.0-fast`. Accepted values: `rerank-v4.0-pro`, `rerank-v4.0-fast`, `rerank-v3.5`. | +| `--reranker-input-count ` | No | How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from. | + + + +### sim knowledge sync create + +Sync Knowledge Connector + +```bash +sim knowledge sync create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--rehydrate` | No | Re-fetch and re-index every existing connector document. | +| `--no-rehydrate` | No | Send --rehydrate as false. | ### sim knowledge update -Update Knowledge Base +Update Knowledge Base + +```bash +sim knowledge update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New knowledge base name. | +| `--description ` | No | New knowledge base description. | +| `--chunking-config ` | No | New document chunking configuration. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | + + + +### sim knowledge mv + +Move a knowledge base to a folder + +```bash +sim knowledge mv +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `folder` | Yes | Folder path as shown in the app; the leading / is optional | + + + +### sim knowledge ls + +List knowledge resources and child folders together + +```bash +sim knowledge ls [path] [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | No | Folder path to list; defaults to the root folder | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Filter folders and resources by name. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | + + + +### sim knowledge mkdir + +Create a knowledge directory at a path + +```bash +sim knowledge mkdir +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path to create; the leading / is optional | + + + +## sim logs + +Also spelled `sim log`. + +### sim logs get + +Show run diagnostics + +```bash +sim logs get [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `runId` | Yes | Unique workflow run identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. | + + + +### sim logs stats + +Summarize run counts, failures, and cost over a window + +```bash +sim logs stats [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | +| `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | +| `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | +| `--segment-count ` | No | Number of equal time buckets to divide the window into, from 1 to 500. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty. | + + + +### sim logs list + +List Logs + +```bash +sim logs list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | +| `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | +| `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | +| `--min-duration-ms ` | No | Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected. | +| `--max-duration-ms ` | No | Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected. | +| `--min-cost ` | No | Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run. | +| `--max-cost ` | No | Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run. | +| `--model ` | No | AI model used during execution. | +| `--details ` | No | Response detail level; full is requested by default to name each run’s workflow. Accepted values: `basic`, `full`. | +| `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | +| `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | +| `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | +| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--no-include-job-runs` | No | Send --include-job-runs as false. | +| `--run-id ` | No | Exact run identifier to match. | +| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | + + + +### sim logs follow + +Watch runs as they arrive, printing each new run once + +```bash +sim logs follow [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | +| `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | +| `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | +| `--interval ` | No | Seconds between polls. Defaults to `3`. | + + + +## sim mcp-servers + +Also spelled `sim mcp-server`. + +### sim mcp-servers create + +Create MCP Server + +```bash +sim mcp-servers create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Server display name. | +| `--description ` | No | Optional server description. | +| `--transport ` | No | Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create. Accepted values: `streamable-http`. | +| `--url ` | Yes | Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints. | +| `--auth-type ` | No | Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method. Accepted values: `none`, `headers`, `oauth`. | +| `--headers ` | No | Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat. (JSON, or @path / @- to read a file or stdin). | +| `--timeout ` | No | Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create. | +| `--retries ` | No | Number of retries per request. Applied server-side as 3 when omitted on create. | +| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. | +| `--no-enabled` | No | Send --enabled as false. | +| `--oauth-client-id ` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. | +| `--oauth-client-secret ` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. | + + + +### sim mcp-servers delete + +Delete MCP Server ```bash -sim knowledge update [options] +sim mcp-servers delete [options] ``` **Arguments** @@ -1682,7 +2572,7 @@ sim knowledge update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `mcpServerId` | Yes | Unique MCP server identifier. | @@ -1692,19 +2582,270 @@ sim knowledge update [options] | Option | Required | Description | | --- | --- | --- | -| `--name ` | No | New knowledge base name. | -| `--description ` | No | New knowledge base description. | -| `--chunking-config ` | No | New document chunking configuration. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim knowledge mv +### sim mcp-servers get -Move a knowledge base to a folder +Get MCP Server + +```bash +sim mcp-servers get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `mcpServerId` | Yes | Unique MCP server identifier. | + + + +### sim mcp-servers list + +List MCP Servers + +```bash +sim mcp-servers list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the server name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim mcp-servers tools list + +List MCP Server Tools + +```bash +sim mcp-servers tools list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `mcpServerId` | Yes | Unique MCP server identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--refresh` | No | Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip. | +| `--no-refresh` | No | Send --refresh as false. | + + + +### sim mcp-servers update + +Update MCP Server + +```bash +sim mcp-servers update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `mcpServerId` | Yes | Unique MCP server identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Server display name. | +| `--description ` | No | Optional server description. | +| `--transport ` | No | Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create. Accepted values: `streamable-http`. | +| `--url ` | No | Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints. | +| `--auth-type ` | No | Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method. Accepted values: `none`, `headers`, `oauth`. | +| `--headers ` | No | Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat. (JSON, or @path / @- to read a file or stdin). | +| `--timeout ` | No | Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create. | +| `--retries ` | No | Number of retries per request. Applied server-side as 3 when omitted on create. | +| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. | +| `--no-enabled` | No | Send --enabled as false. | +| `--oauth-client-id ` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. | +| `--oauth-client-secret ` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. | + + + +## sim meta + +### sim meta status + +Show what this API supports and which limits apply + +```bash +sim meta status +``` + +## sim secrets + +Also spelled `sim secret`. + +### sim secrets delete + +Delete Secret + +```bash +sim secrets delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `name` | Yes | Secret to create, replace, or delete. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim secrets list + +List Secrets + +```bash +sim secrets list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--scope ` | No | Restrict results to one ownership scope. Accepted values: `workspace`, `personal`. | +| `--search ` | No | Case-insensitive substring match against the secret name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim secrets set + +Create or replace a named secret + +```bash +sim secrets set [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `name` | Yes | Secret name, as referenced in workflows | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--scope ` | Yes | Secret ownership scope. Accepted values: `workspace`, `personal`. | +| `--value ` | No | Secret value; visible to shell history when supplied directly. | +| `--description ` | No | What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged. | + + + +## sim skills + +Also spelled `sim skill`. + +### sim skills create + +Create Skill + +```bash +sim skills create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Kebab-case name, unique within the workspace and not reserved by a built-in skill. | +| `--description ` | Yes | One-line summary of when the skill applies. | +| `--content ` | Yes | Skill body containing the instructions given to the agent. | + + + +### sim skills delete + +Delete Skill + +```bash +sim skills delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim skills get + +Get Skill ```bash -sim knowledge mv +sim skills get ``` **Arguments** @@ -1713,17 +2854,16 @@ sim knowledge mv | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | -| `folder` | Yes | Folder path as shown in the app; the leading / is optional | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | -### sim knowledge ls +### sim skills editors create -List knowledge resources and child folders together +Grant Skill Editor ```bash -sim knowledge ls [path] [options] +sim skills editors create [options] ``` **Arguments** @@ -1732,7 +2872,7 @@ sim knowledge ls [path] [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | No | Folder path to list; defaults to the root folder | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | @@ -1742,17 +2882,16 @@ sim knowledge ls [path] [options] | Option | Required | Description | | --- | --- | --- | -| `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--email ` | Yes | Email address of a current workspace member. | -### sim knowledge mkdir +### sim skills editors list -Create a knowledge directory at a path +List Skill Editors ```bash -sim knowledge mkdir +sim skills editors list [options] ``` **Arguments** @@ -1761,20 +2900,28 @@ sim knowledge mkdir | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path to create; the leading / is optional | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | -## sim logs +**Options** -Also spelled `sim log`. + -### sim logs get +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -Show run diagnostics + + +### sim skills editors delete + +Revoke Skill Editor ```bash -sim logs get [options] +sim skills editors delete [options] ``` **Arguments** @@ -1783,7 +2930,7 @@ sim logs get [options] | Argument | Required | Description | | --- | --- | --- | -| `runId` | Yes | Unique workflow run identifier. | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | @@ -1793,16 +2940,17 @@ sim logs get [options] | Option | Required | Description | | --- | --- | --- | -| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. | +| `--email ` | Yes | Email address of a current workspace member. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim logs list +### sim skills list -List Logs +List Skills ```bash -sim logs list [options] +sim skills list [options] ``` **Options** @@ -1811,89 +2959,53 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. (space-separated, or @path / @- with one value per line). | -| `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | -| `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | -| `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | -| `--min-duration-ms ` | No | Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected. | -| `--max-duration-ms ` | No | Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected. | -| `--min-cost ` | No | Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run. | -| `--max-cost ` | No | Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run. | -| `--model ` | No | AI model used during execution. | -| `--details ` | No | Response detail level; full is requested by default to name each run’s workflow. Accepted values: `basic`, `full`. | -| `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | -| `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | +| `--search ` | No | Case-insensitive substring match against the skill name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--order ` | No | Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects. Accepted values: `asc`, `desc`. | -| `--run-id ` | No | Exact run identifier to match. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -### sim logs follow +### sim skills update -Watch runs as they arrive, printing each new run once +Update Skill ```bash -sim logs follow [options] +sim skills update [options] ``` -**Options** +**Arguments** -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | -| `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | -| `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | -| `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | -| `--interval ` | No | Seconds between polls. Defaults to `3`. | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | -## sim mcp-servers - -Also spelled `sim mcp-server`. - -### sim mcp-servers create - -Create MCP Server - -```bash -sim mcp-servers create [options] -``` - **Options** | Option | Required | Description | | --- | --- | --- | -| `--name ` | Yes | Server display name. | -| `--description ` | No | Optional server description. | -| `--transport ` | No | Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create. Accepted values: `streamable-http`. | -| `--url ` | Yes | Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints. | -| `--auth-type ` | No | Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method. Accepted values: `none`, `headers`, `oauth`. | -| `--headers ` | No | Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat. (JSON, or @path / @- to read a file or stdin). | -| `--timeout ` | No | Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create. | -| `--retries ` | No | Number of retries per request. Applied server-side as 3 when omitted on create. | -| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. | -| `--no-enabled` | No | Send --enabled as false. | -| `--oauth-client-id ` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. | -| `--oauth-client-secret ` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. | +| `--name ` | No | New kebab-case skill name. | +| `--description ` | No | New one-line summary of when the skill applies. | +| `--content ` | No | Replacement skill body. | -### sim mcp-servers delete +## sim tables -Delete MCP Server +Also spelled `sim table`. + +### sim tables columns create + +Add Column ```bash -sim mcp-servers delete [options] +sim tables columns create [options] ``` **Arguments** @@ -1902,7 +3014,7 @@ sim mcp-servers delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique MCP server identifier. | +| `tableId` | Yes | Unique table identifier. | @@ -1912,16 +3024,16 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--column ` | Yes | Column definition to add. (JSON, or @path / @- to read a file or stdin). | -### sim mcp-servers get +### sim tables columns delete -Get MCP Server +Delete Column ```bash -sim mcp-servers get +sim tables columns delete [options] ``` **Arguments** @@ -1930,37 +3042,27 @@ sim mcp-servers get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique MCP server identifier. | +| `tableId` | Yes | Unique table identifier. | -### sim mcp-servers list - -List MCP Servers - -```bash -sim mcp-servers list [options] -``` - **Options** | Option | Required | Description | | --- | --- | --- | -| `--search ` | No | Case-insensitive substring match against the server name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--column-name ` | Yes | Name of the column to delete. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim mcp-servers tools list +### sim tables columns update -List MCP Server Tools +Update Column ```bash -sim mcp-servers tools list [options] +sim tables columns update [options] ``` **Arguments** @@ -1969,7 +3071,7 @@ sim mcp-servers tools list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique MCP server identifier. | +| `tableId` | Yes | Unique table identifier. | @@ -1979,17 +3081,17 @@ sim mcp-servers tools list [options] | Option | Required | Description | | --- | --- | --- | -| `--refresh` | No | Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip. | -| `--no-refresh` | No | Send --refresh as false. | +| `--column-name ` | Yes | Current name of the column to update. | +| `--updates ` | Yes | Mutable column fields. (JSON, or @path / @- to read a file or stdin). | -### sim mcp-servers update +### sim tables groups create -Update MCP Server +Add Workflow Group ```bash -sim mcp-servers update [options] +sim tables groups create [options] ``` **Arguments** @@ -1998,7 +3100,7 @@ sim mcp-servers update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique MCP server identifier. | +| `tableId` | Yes | Unique table identifier. | @@ -2008,31 +3110,19 @@ sim mcp-servers update [options] | Option | Required | Description | | --- | --- | --- | -| `--name ` | No | Server display name. | -| `--description ` | No | Optional server description. | -| `--transport ` | No | Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create. Accepted values: `streamable-http`. | -| `--url ` | No | Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints. | -| `--auth-type ` | No | Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method. Accepted values: `none`, `headers`, `oauth`. | -| `--headers ` | No | Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat. (JSON, or @path / @- to read a file or stdin). | -| `--timeout ` | No | Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create. | -| `--retries ` | No | Number of retries per request. Applied server-side as 3 when omitted on create. | -| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. | -| `--no-enabled` | No | Send --enabled as false. | -| `--oauth-client-id ` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. | -| `--oauth-client-secret ` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. | +| `--group ` | Yes | Workflow or enrichment producer definition. (JSON, or @path / @- to read a file or stdin). | +| `--output-columns ` | Yes | Columns created for producer outputs. (JSON, or @path / @- to read a file or stdin). | +| `--auto-run` | No | Whether to schedule existing rows after group creation. | +| `--no-auto-run` | No | Send --auto-run as false. | -## sim secrets - -Also spelled `sim secret`. - -### sim secrets delete +### sim tables groups delete -Delete Secret +Delete Workflow Group ```bash -sim secrets delete [options] +sim tables groups delete [options] ``` **Arguments** @@ -2041,7 +3131,7 @@ sim secrets delete [options] | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `tableId` | Yes | Unique table identifier. | @@ -2051,39 +3141,35 @@ sim secrets delete [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | +| `--group-id ` | Yes | Workflow group to delete. | | `-y, --yes` | Yes | Confirm this destructive operation. | -### sim secrets list +### sim tables groups list -List Secrets +List Workflow Groups ```bash -sim secrets list [options] +sim tables groups list ``` -**Options** +**Arguments** -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `--scope ` | No | Restrict results to one ownership scope. Accepted values: `workspace`, `personal`. | -| `--search ` | No | Case-insensitive substring match against the secret name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `tableId` | Yes | Unique table identifier. | -### sim secrets set +### sim tables groups update -Create or replace a named secret +Update Workflow Group ```bash -sim secrets set [options] +sim tables groups update [options] ``` **Arguments** @@ -2092,7 +3178,7 @@ sim secrets set [options] | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret name, as referenced in workflows | +| `tableId` | Yes | Unique table identifier. | @@ -2102,42 +3188,75 @@ sim secrets set [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | Yes | Secret ownership scope. Accepted values: `workspace`, `personal`. | -| `--value ` | No | Secret value; visible to shell history when supplied directly. | -| `--description ` | No | What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged. | +| `--group-id ` | Yes | Workflow group to update. | +| `--workflow-id ` | No | Replacement backing workflow identifier. | +| `--name ` | No | Replacement workflow-group display name. | +| `--dependencies ` | No | Replacement input dependencies. (JSON, or @path / @- to read a file or stdin). | +| `--outputs ` | No | Replacement producer outputs. (JSON, or @path / @- to read a file or stdin). | +| `--new-output-columns ` | No | Columns to add for new outputs. (JSON, or @path / @- to read a file or stdin). | +| `--mapping-updates ` | No | Existing output-column mapping changes. (JSON, or @path / @- to read a file or stdin). | +| `--input-mappings ` | No | Replacement workflow input mappings. (JSON, or @path / @- to read a file or stdin). | +| `--deployment-mode ` | No | Replacement workflow execution mode. Accepted values: `live`, `deployed`. | +| `--type ` | No | Workflow-group producer type. Must match the group's stored type — a group's producer cannot be changed after creation. Accepted values: `manual`, `enrichment`. | +| `--auto-run` | No | Replacement automatic-run setting. | +| `--no-auto-run` | No | Send --auto-run as false. | -## sim skills +### sim tables batch-delete -Also spelled `sim skill`. +Bulk Delete Tables and Folders -### sim skills create +```bash +sim tables batch-delete [options] +``` -Create Skill +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim tables rows update-each + +Apply a distinct patch to each listed row ```bash -sim skills create [options] +sim tables rows update-each [options] ``` +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | + + + **Options** | Option | Required | Description | | --- | --- | --- | -| `--name ` | Yes | Kebab-case name, unique within the workspace and not reserved by a built-in skill. | -| `--description ` | Yes | One-line summary of when the skill applies. | -| `--content ` | Yes | Skill body containing the instructions given to the agent. | +| `--updates ` | Yes | One merge patch per row. Each row identifier may appear at most once. (JSON, or @path / @- to read a file or stdin). | -### sim skills delete +### sim tables rows create -Delete Skill +Create Rows ```bash -sim skills delete [options] +sim tables rows create [options] ``` **Arguments** @@ -2146,7 +3265,7 @@ sim skills delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `tableId` | Yes | Unique table identifier. | @@ -2156,16 +3275,17 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--data ` | No | One row keyed by column name (JSON, or @path / @-; choose exactly one body flag). | +| `--rows ` | No | Several rows keyed by column name (JSON, or @path / @-; choose exactly one body flag). | -### sim skills get +### sim tables rows delete -Get Skill +Delete Row ```bash -sim skills get +sim tables rows delete [options] ``` **Arguments** @@ -2174,16 +3294,27 @@ sim skills get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `tableId` | Yes | Unique table identifier. | +| `rowId` | Yes | Unique table row identifier. | -### sim skills editors create +**Options** -Grant Skill Editor + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim tables rows batch-delete + +Delete rows matching a filter, or an explicit list of ids ```bash -sim skills editors create [options] +sim tables rows batch-delete [options] ``` **Arguments** @@ -2192,7 +3323,7 @@ sim skills editors create [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `tableId` | Yes | Unique table identifier. | @@ -2202,16 +3333,19 @@ sim skills editors create [options] | Option | Required | Description | | --- | --- | --- | -| `--email ` | Yes | Email address of a current workspace member. | +| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim skills editors list +### sim tables rows get -List Skill Editors +Get Row ```bash -sim skills editors list [options] +sim tables rows get [options] ``` **Arguments** @@ -2220,7 +3354,8 @@ sim skills editors list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `tableId` | Yes | Unique table identifier. | +| `rowId` | Yes | Unique table row identifier. | @@ -2230,18 +3365,17 @@ sim skills editors list [options] | Option | Required | Description | | --- | --- | --- | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--include-run-state` | No | Include per-workflow-group run state on the returned row. Off by default. | +| `--no-include-run-state` | No | Send --include-run-state as false. | -### sim skills editors delete +### sim tables rows list -Revoke Skill Editor +List Rows ```bash -sim skills editors delete [options] +sim tables rows list [options] ``` **Arguments** @@ -2250,7 +3384,7 @@ sim skills editors delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `tableId` | Yes | Unique table identifier. | @@ -2260,38 +3394,50 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | -| `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Caps `limit` at 200. | +| `--no-include-run-state` | No | Send --include-run-state as false. | -### sim skills list +### sim tables rows query -List Skills +Query Rows ```bash -sim skills list [options] +sim tables rows query [options] ``` +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | + + + **Options** | Option | Required | Description | | --- | --- | --- | -| `--search ` | No | Case-insensitive substring match against the skill name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Incompatible with `limit: 0`, and caps `limit` at 200. | +| `--no-include-run-state` | No | Send --include-run-state as false. | -### sim skills update +### sim tables rows count -Update Skill +Count rows matching a filter ```bash -sim skills update [options] +sim tables rows count [options] ``` **Arguments** @@ -2300,7 +3446,7 @@ sim skills update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `tableId` | Yes | Unique table identifier. | @@ -2310,22 +3456,16 @@ sim skills update [options] | Option | Required | Description | | --- | --- | --- | -| `--name ` | No | New kebab-case skill name. | -| `--description ` | No | New one-line summary of when the skill applies. | -| `--content ` | No | Replacement skill body. | +| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -## sim tables - -Also spelled `sim table`. - -### sim tables columns create +### sim tables rows enrich -Add Column +Run one row’s enrichment group ```bash -sim tables columns create [options] +sim tables rows enrich ``` **Arguments** @@ -2335,25 +3475,17 @@ sim tables columns create [options] | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | +| `rowId` | Yes | Unique table row identifier. | +| `groupId` | Yes | Workflow or enrichment group to run. | -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--column ` | Yes | Column definition to add. (JSON, or @path / @- to read a file or stdin). | - - - -### sim tables columns delete +### sim tables rows search -Delete Column +Search cells for a value and return their coordinates ```bash -sim tables columns delete [options] +sim tables rows search [options] ``` **Arguments** @@ -2372,17 +3504,18 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | -| `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--query ` | Yes | Value to search for. | +| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | -### sim tables columns run +### sim tables rows batch-update -Run a column’s workflow +Update every row matching a filter ```bash -sim tables columns run [options] +sim tables rows batch-update [options] ``` **Arguments** @@ -2401,21 +3534,19 @@ sim tables columns run [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | -| `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | -| `--limit ` | No | Optional cap on eligible rows to run. (JSON, or @path / @- to read a file or stdin). | +| `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim tables columns update +### sim tables rows update -Update Column +Update Row ```bash -sim tables columns update [options] +sim tables rows update [options] ``` **Arguments** @@ -2425,6 +3556,7 @@ sim tables columns update [options] | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | +| `rowId` | Yes | Unique table row identifier. | @@ -2434,17 +3566,16 @@ sim tables columns update [options] | Option | Required | Description | | --- | --- | --- | -| `--column-name ` | Yes | Current name of the column to update. | -| `--updates ` | Yes | Mutable column fields. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Partial row-data patch keyed by column name. (JSON, or @path / @- to read a file or stdin). | -### sim tables groups create +### sim tables dispatches cancel -Add Workflow Group +Cancel a running dispatch ```bash -sim tables groups create [options] +sim tables dispatches cancel [options] ``` **Arguments** @@ -2454,6 +3585,7 @@ sim tables groups create [options] | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | +| `dispatchId` | Yes | Unique table run-dispatch identifier. | @@ -2463,19 +3595,16 @@ sim tables groups create [options] | Option | Required | Description | | --- | --- | --- | -| `--group ` | Yes | Workflow or enrichment producer definition. (JSON, or @path / @- to read a file or stdin). | -| `--output-columns ` | Yes | Columns created for producer outputs. (JSON, or @path / @- to read a file or stdin). | -| `--auto-run` | No | Whether to schedule existing rows after group creation. | -| `--no-auto-run` | No | Send --auto-run as false. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim tables groups delete +### sim tables dispatches create -Delete Workflow Group +Start a column or enrichment run ```bash -sim tables groups delete [options] +sim tables dispatches create [options] ``` **Arguments** @@ -2494,17 +3623,21 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | -| `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--limit ` | No | Optional cap on eligible rows to run. (JSON, or @path / @- to read a file or stdin). | -### sim tables groups list +### sim tables dispatches get -List Workflow Groups +Get Run Dispatch ```bash -sim tables groups list +sim tables dispatches get ``` **Arguments** @@ -2514,15 +3647,16 @@ sim tables groups list | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | +| `dispatchId` | Yes | Unique table run-dispatch identifier. | -### sim tables groups update +### sim tables dispatches list -Update Workflow Group +List Active Run Dispatches ```bash -sim tables groups update [options] +sim tables dispatches list ``` **Arguments** @@ -2535,33 +3669,12 @@ sim tables groups update [options] -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--group-id ` | Yes | Workflow group to update. | -| `--workflow-id ` | No | Replacement backing workflow identifier. | -| `--name ` | No | Replacement workflow-group display name. | -| `--dependencies ` | No | Replacement input dependencies. (JSON, or @path / @- to read a file or stdin). | -| `--outputs ` | No | Replacement producer outputs. (JSON, or @path / @- to read a file or stdin). | -| `--new-output-columns ` | No | Columns to add for new outputs. (JSON, or @path / @- to read a file or stdin). | -| `--mapping-updates ` | No | Existing output-column mapping changes. (JSON, or @path / @- to read a file or stdin). | -| `--input-mappings ` | No | Replacement workflow input mappings. (JSON, or @path / @- to read a file or stdin). | -| `--deployment-mode ` | No | Replacement workflow execution mode. Accepted values: `live`, `deployed`. | -| `--type ` | No | Workflow-group producer type. Must match the group's stored type — a group's producer cannot be changed after creation. Accepted values: `manual`, `enrichment`. | -| `--auto-run` | No | Replacement automatic-run setting. | -| `--no-auto-run` | No | Send --auto-run as false. | - - - ### sim tables exports cancel Cancel Table Export ```bash -sim tables exports cancel +sim tables exports cancel ``` **Arguments** @@ -2570,6 +3683,7 @@ sim tables exports cancel | Argument | Required | Description | | --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | | `exportId` | Yes | Unique table-export identifier. | @@ -2607,7 +3721,7 @@ sim tables exports create [options] Get Table Export ```bash -sim tables exports get +sim tables exports get ``` **Arguments** @@ -2616,6 +3730,7 @@ sim tables exports get | Argument | Required | Description | | --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | | `exportId` | Yes | Unique table-export identifier. | @@ -2625,7 +3740,7 @@ sim tables exports get Get the download URL for a finished export ```bash -sim tables exports download +sim tables exports download ``` **Arguments** @@ -2634,6 +3749,7 @@ sim tables exports download | Argument | Required | Description | | --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | | `exportId` | Yes | Unique table-export identifier. | @@ -2817,12 +3933,30 @@ Also available as `sim tables folders mv`. -### sim tables rows create +### sim tables folders restore -Create Rows +Restore an archived table folder ```bash -sim tables rows create [options] +sim tables folders restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | + + + +### sim tables views create + +Create View + +```bash +sim tables views create [options] ``` **Arguments** @@ -2841,17 +3975,17 @@ sim tables rows create [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | No | One row keyed by column name (JSON, or @path / @-; choose exactly one body flag). | -| `--rows ` | No | Several rows keyed by column name (JSON, or @path / @-; choose exactly one body flag). | +| `--name ` | Yes | Saved-view display name. | +| `--config ` | Yes | Saved filter, sort, and column-layout configuration. (JSON, or @path / @- to read a file or stdin). | -### sim tables rows delete +### sim tables views delete -Delete Row +Delete View ```bash -sim tables rows delete [options] +sim tables views delete [options] ``` **Arguments** @@ -2861,7 +3995,7 @@ sim tables rows delete [options] | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | -| `rowId` | Yes | Unique table row identifier. | +| `viewId` | Yes | Unique saved-view identifier. | @@ -2875,12 +4009,49 @@ sim tables rows delete [options] -### sim tables rows batch-delete +### sim tables views get -Delete rows matching a filter, or an explicit list of ids +Get View + +```bash +sim tables views get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | +| `viewId` | Yes | Unique saved-view identifier. | + + + +### sim tables views list + +List Views + +```bash +sim tables views list +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | + + + +### sim tables views update + +Update View ```bash -sim tables rows batch-delete [options] +sim tables views update [options] ``` **Arguments** @@ -2890,6 +4061,7 @@ sim tables rows batch-delete [options] | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | +| `viewId` | Yes | Unique saved-view identifier. | @@ -2899,19 +4071,20 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--name ` | No | Replacement saved-view display name. | +| `--config ` | No | Complete replacement saved-view configuration. (JSON, or @path / @- to read a file or stdin). | +| `--config-patch ` | No | Saved-view configuration fields to shallow-merge. (JSON, or @path / @- to read a file or stdin). | +| `--is-default` | No | Whether to promote this view to the table default. | +| `--no-is-default` | No | Send --is-default as false. | -### sim tables rows find +### sim tables delete -Find rows matching a predicate +Delete Table ```bash -sim tables rows find [options] +sim tables delete [options] ``` **Arguments** @@ -2930,18 +4103,16 @@ sim tables rows find [options] | Option | Required | Description | | --- | --- | --- | -| `--query ` | Yes | Value to find. | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim tables rows get +### sim tables enrichment get -Get Row +Get Enrichment Run Detail ```bash -sim tables rows get +sim tables enrichment get ``` **Arguments** @@ -2952,15 +4123,16 @@ sim tables rows get | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | | `rowId` | Yes | Unique table row identifier. | +| `groupId` | Yes | Workflow or enrichment group to run. | -### sim tables rows list +### sim tables get -List Rows +Get Table ```bash -sim tables rows list [options] +sim tables get ``` **Arguments** @@ -2973,52 +4145,73 @@ sim tables rows list [options] +### sim tables list + +List Tables + +```bash +sim tables list [options] +``` + **Options** | Option | Required | Description | | --- | --- | --- | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--search ` | No | Case-insensitive substring match against the resource name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -### sim tables rows query +### sim tables move -Query Rows +Move Tables and Folders ```bash -sim tables rows query [options] +sim tables move [options] ``` -**Arguments** +**Options** -| Argument | Required | Description | +| Option | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | +| `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--to ` | No | Destination folder path; omit for root. | -**Options** +### sim tables restore + +Restore an archived table + +```bash +sim tables restore +``` + +**Arguments** -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `tableId` | Yes | Unique table identifier. | -### sim tables rows count +### sim tables update -Count rows matching a filter +Update Table ```bash -sim tables rows count [options] +sim tables update [options] ``` **Arguments** @@ -3037,16 +4230,18 @@ sim tables rows count [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--name ` | No | Identifier: letters, numbers, and underscores; cannot start with a number. | +| `--description ` | No | Replacement table description, or null to clear it. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | -### sim tables rows enrich +### sim tables mv -Run one row’s enrichment group +Move a table to a folder ```bash -sim tables rows enrich +sim tables mv ``` **Arguments** @@ -3056,17 +4251,16 @@ sim tables rows enrich | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | -| `rowId` | Yes | Unique table row identifier. | -| `groupId` | Yes | Workflow or enrichment group to run. | +| `folder` | Yes | Folder path as shown in the app; the leading / is optional | -### sim tables rows batch-update +### sim tables upsert -Update every row matching a filter +Insert a row, or update the one that conflicts on a unique column ```bash -sim tables rows batch-update [options] +sim tables upsert [options] ``` **Arguments** @@ -3085,19 +4279,17 @@ sim tables rows batch-update [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--on ` | No | Unique column to resolve the conflict against. | -### sim tables rows update +### sim tables import -Update Row +Import a CSV, into a new table by default ```bash -sim tables rows update [options] +sim tables import [path] [options] ``` **Arguments** @@ -3106,8 +4298,7 @@ sim tables rows update [options] | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | -| `rowId` | Yes | Unique table row identifier. | +| `path` | No | Local CSV file to import; omit when using --file-id | @@ -3117,16 +4308,24 @@ sim tables rows update [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Partial row-data patch keyed by column name. (JSON, or @path / @- to read a file or stdin). | +| `--name ` | No | Identifier for the new table: letters, numbers, and underscores; defaults to the sanitized file name. | +| `--table-id ` | No | Import into this existing table instead of creating one. | +| `--mode ` | No | How to write into --table-id (default: append). Accepted values: `append`, `replace`. | +| `--folder ` | No | Folder path for the new table, as shown in the app. | +| `--file-id ` | No | Import a file already in the workspace instead of a local path. | +| `--mapping ` | No | Column mapping (--table-id only). | +| `--create-columns ` | No | Columns to create (--table-id only). | +| `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `--no-wait` | No | Return once the import is queued instead of watching it. | -### sim tables views create +### sim tables ls -Create View +List table resources and child folders together ```bash -sim tables views create [options] +sim tables ls [path] [options] ``` **Arguments** @@ -3135,7 +4334,7 @@ sim tables views create [options] | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | +| `path` | No | Folder path to list; defaults to the root folder | @@ -3145,17 +4344,17 @@ sim tables views create [options] | Option | Required | Description | | --- | --- | --- | -| `--name ` | Yes | Saved-view display name. | -| `--config ` | Yes | Saved filter, sort, and column-layout configuration. (JSON, or @path / @- to read a file or stdin). | +| `--search ` | No | Filter folders and resources by name. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | -### sim tables views delete +### sim tables mkdir -Delete View +Create a table directory at a path ```bash -sim tables views delete [options] +sim tables mkdir ``` **Arguments** @@ -3164,64 +4363,83 @@ sim tables views delete [options] | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | -| `viewId` | Yes | Unique saved-view identifier. | +| `path` | Yes | Folder path to create; the leading / is optional | -**Options** +## sim tools + +### sim tools get + +Get Tool + +```bash +sim tools get +``` + +**Arguments** -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `toolId` | Yes | Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id. | -### sim tables views get +### sim tools list -Get View +List Tools ```bash -sim tables views get +sim tools list [options] ``` -**Arguments** +**Options** -| Argument | Required | Description | +| Option | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | -| `viewId` | Yes | Unique saved-view identifier. | +| `--search ` | No | Case-insensitive substring match against the tool id, name, and description. | +| `--hosted-api-key ` | No | Restrict to tools by how their API key is supplied. Accepted values: `always`, `conditional`, `none`. | +| `--oauth-provider ` | No | Restrict to tools that authenticate against this OAuth service. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -### sim tables views list +## sim workflow-mcp-servers -List Views +### sim workflow-mcp-servers create + +Create Workflow MCP Server ```bash -sim tables views list +sim workflow-mcp-servers create [options] ``` -**Arguments** +**Options** -| Argument | Required | Description | +| Option | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | +| `--name ` | Yes | Server display name, shown to connecting MCP clients. | +| `--description ` | No | Optional server description. | +| `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | +| `--no-is-public` | No | Send --is-public as false. | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | -### sim tables views update +### sim workflow-mcp-servers delete -Update View +Delete Workflow MCP Server ```bash -sim tables views update [options] +sim workflow-mcp-servers delete [options] ``` **Arguments** @@ -3230,8 +4448,7 @@ sim tables views update [options] | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | -| `viewId` | Yes | Unique saved-view identifier. | +| `serverId` | Yes | Unique workflow-MCP server identifier. | @@ -3241,20 +4458,16 @@ sim tables views update [options] | Option | Required | Description | | --- | --- | --- | -| `--name ` | No | Replacement saved-view display name. | -| `--config ` | No | Complete replacement saved-view configuration. (JSON, or @path / @- to read a file or stdin). | -| `--config-patch ` | No | Saved-view configuration fields to shallow-merge. (JSON, or @path / @- to read a file or stdin). | -| `--is-default` | No | Whether to promote this view to the table default. | -| `--no-is-default` | No | Send --is-default as false. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim tables delete +### sim workflow-mcp-servers tools create -Delete Table +Publish Workflow As MCP Tool ```bash -sim tables delete [options] +sim workflow-mcp-servers tools create [options] ``` **Arguments** @@ -3263,7 +4476,7 @@ sim tables delete [options] | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | +| `serverId` | Yes | Unique workflow-MCP server identifier. | @@ -3273,16 +4486,19 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--workflow-id ` | Yes | Deployed workflow to publish. The workflow must already be deployed. | +| `--tool-name ` | No | Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted. | +| `--tool-description ` | No | Description shown to MCP clients. Derived from the workflow name when omitted. | +| `--parameter-descriptions ` | No | Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored. (JSON, or @path / @- to read a file or stdin). | -### sim tables get +### sim workflow-mcp-servers tools list -Get Table +List Workflow MCP Tools ```bash -sim tables get +sim workflow-mcp-servers tools list ``` **Arguments** @@ -3291,38 +4507,45 @@ sim tables get | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | +| `serverId` | Yes | Unique workflow-MCP server identifier. | -### sim tables list +### sim workflow-mcp-servers tools delete -List Tables +Unpublish Workflow MCP Tool ```bash -sim tables list [options] +sim workflow-mcp-servers tools delete [options] ``` +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `serverId` | Yes | Unique workflow-MCP server identifier. | +| `workflowId` | Yes | Workflow published as a tool on this server. | + + + **Options** | Option | Required | Description | | --- | --- | --- | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | -| `--search ` | No | Case-insensitive substring match against the resource name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim tables update +### sim workflow-mcp-servers get -Update Table +Get Workflow MCP Server ```bash -sim tables update [options] +sim workflow-mcp-servers get ``` **Arguments** @@ -3331,47 +4554,36 @@ sim tables update [options] | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | No | Identifier: letters, numbers, and underscores; cannot start with a number. | -| `--description ` | No | Replacement table description, or null to clear it. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `serverId` | Yes | Unique workflow-MCP server identifier. | -### sim tables mv +### sim workflow-mcp-servers list -Move a table to a folder +List Workflow MCP Servers ```bash -sim tables mv +sim workflow-mcp-servers list [options] ``` -**Arguments** +**Options** -| Argument | Required | Description | +| Option | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | -| `folder` | Yes | Folder path as shown in the app; the leading / is optional | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -### sim tables upsert +### sim workflow-mcp-servers update -Insert a row, or update the one that conflicts on a unique column +Update Workflow MCP Server ```bash -sim tables upsert [options] +sim workflow-mcp-servers update [options] ``` **Arguments** @@ -3380,7 +4592,7 @@ sim tables upsert [options] | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | +| `serverId` | Yes | Unique workflow-MCP server identifier. | @@ -3390,53 +4602,42 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | -| `--on ` | No | Unique column to resolve the conflict against. | +| `--name ` | No | Server display name, shown to connecting MCP clients. | +| `--description ` | No | New server description, or null to clear it. | +| `--is-public` | No | Whether the server answers MCP clients without a Sim API key. | +| `--no-is-public` | No | Send --is-public as false. | -### sim tables import +## sim workflows -Import a CSV, into a new table by default +Also spelled `sim workflow`. + +### sim workflows activate create + +Activate Workflow Version ```bash -sim tables import [path] [options] +sim workflows activate create ``` **Arguments** -| Argument | Required | Description | -| --- | --- | --- | -| `path` | No | Local CSV file to import; omit when using --file-id | - - - -**Options** - - - -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `--name ` | No | Identifier for the new table: letters, numbers, and underscores; defaults to the sanitized file name. | -| `--table-id ` | No | Import into this existing table instead of creating one. | -| `--mode ` | No | How to write into --table-id (default: append). Accepted values: `append`, `replace`. | -| `--folder ` | No | Folder path for the new table, as shown in the app. | -| `--file-id ` | No | Import a file already in the workspace instead of a local path. | -| `--mapping ` | No | Column mapping (--table-id only). | -| `--create-columns ` | No | Columns to create (--table-id only). | -| `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | -| `--no-wait` | No | Return once the import is queued instead of watching it. | +| `workflowId` | Yes | Unique workflow identifier. | +| `version` | Yes | Numeric deployment version. | -### sim tables ls +### sim workflows operations apply -List table resources and child folders together +Apply Workflow Operations ```bash -sim tables ls [path] [options] +sim workflows operations apply [options] ``` **Arguments** @@ -3445,7 +4646,7 @@ sim tables ls [path] [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | No | Folder path to list; defaults to the root folder | +| `workflowId` | Yes | Unique workflow identifier. | @@ -3455,17 +4656,23 @@ sim tables ls [path] [options] | Option | Required | Description | | --- | --- | --- | -| `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | +| `--no-dry-run` | No | Send --dry-run as false. | +| `--operations ` | Yes | Edits to apply, in a single batch. (JSON, or @path / @- to read a file or stdin). | +| `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. | +| `--no-atomic` | No | Send --atomic as false. | +| `--layout ` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. | +| `--set-block-enabled ` | No | Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined. (JSON, or @path / @- to read a file or stdin). | +| `-y, --yes` | Yes | Confirm this destructive operation. | -### sim tables mkdir +### sim workflows variables update -Create a table directory at a path +Update Workflow Variables ```bash -sim tables mkdir +sim workflows variables update [options] ``` **Arguments** @@ -3474,13 +4681,20 @@ sim tables mkdir | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path to create; the leading / is optional | +| `workflowId` | Yes | Unique workflow identifier. | -## sim workflows +**Options** -Also spelled `sim workflow`. + + +| Option | Required | Description | +| --- | --- | --- | +| `--operations ` | Yes | Variable changes to apply, in order. (JSON, or @path / @- to read a file or stdin). | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + ### sim workflows runs cancel @@ -3537,6 +4751,9 @@ sim workflows runs get [options] | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | | `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | +| `--no-include-file-base64` | No | Send --include-file-base64 as false. | +| `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -3739,7 +4956,81 @@ Also available as `sim workflows folders mv`. Delete Workflow ```bash -sim workflows delete [options] +sim workflows delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim workflows chat unpublish + +Take a workflow’s chat deployment offline + +```bash +sim workflows chat unpublish [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +### sim workflows chat status + +Show a workflow’s chat deployment + +```bash +sim workflows chat status +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +### sim workflows chat publish + +Publish or replace a workflow’s chat deployment + +```bash +sim workflows chat publish [options] ``` **Arguments** @@ -3748,7 +5039,7 @@ sim workflows delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -3758,6 +5049,18 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | +| `--identifier ` | Yes | URL slug the deployed chat answers on. Must be free across live deployments. | +| `--title ` | Yes | Title shown to visitors. | +| `--description ` | No | Description shown to visitors. Omitted clears it. | +| `--customizations ` | No | Presentation overrides. Omitted fields take platform defaults. (JSON, or @path / @- to read a file or stdin). | +| `--auth-type ` | No | How visitors are gated. `public` leaves the chat open to anyone holding the URL. Accepted values: `public`, `password`, `email`, `sso`. | +| `--password ` | No | Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back. | +| `--allowed-emails ` | No | Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes. (JSON, or @path / @- to read a file or stdin). | +| `--output-configs ` | No | Block outputs to surface to visitors. Omitted surfaces none. (JSON, or @path / @- to read a file or stdin). | +| `--include-thinking` | No | Allow visitors to receive provider thinking events. | +| `--no-include-thinking` | No | Send --include-thinking as false. | +| `--include-tool-calls` | No | Allow visitors to receive tool lifecycle events. | +| `--no-include-tool-calls` | No | Send --include-tool-calls as false. | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -3767,7 +5070,7 @@ sim workflows delete [options] Deploy Workflow ```bash -sim workflows deploy [options] +sim workflows deploy [options] ``` **Arguments** @@ -3776,7 +5079,7 @@ sim workflows deploy [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -3791,12 +5094,41 @@ sim workflows deploy [options] +### sim workflows duplicate create + +Duplicate Workflow + +```bash +sim workflows duplicate create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Name for the copy. Defaults to the source name, deduplicated within the folder. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | + + + ### sim workflows run Run a deployed workflow ```bash -sim workflows run [options] +sim workflows run [options] ``` **Arguments** @@ -3805,7 +5137,7 @@ sim workflows run [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -3821,7 +5153,7 @@ sim workflows run [options] | `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | -| `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64. Rejected when `async` is true. | +| `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | | `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. | | `--include-thinking` | No | Show model reasoning while following (requires --follow). | | `--include-tool-calls` | No | Show tool calls while following (requires --follow). | @@ -3833,7 +5165,7 @@ sim workflows run [options] Print a workflow as a portable JSON document ```bash -sim workflows export +sim workflows export ``` **Arguments** @@ -3842,7 +5174,7 @@ sim workflows export | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -3851,7 +5183,7 @@ sim workflows export Get Workflow ```bash -sim workflows get +sim workflows get ``` **Arguments** @@ -3860,7 +5192,7 @@ sim workflows get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -3869,7 +5201,71 @@ sim workflows get Show a workflow’s current deployment ```bash -sim workflows deployment status +sim workflows deployment status +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +### sim workflows deployment update + +Update Workflow Public API Access + +```bash +sim workflows deployment update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--is-public-api ` | Yes | Whether the deployed workflow should accept unauthenticated public API execution. Accepted values: `true`, `false`. | + + + +### sim workflows state get + +Get Workflow State + +```bash +sim workflows state get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +### sim workflows state replace + +Replace Workflow State + +```bash +sim workflows state replace [options] ``` **Arguments** @@ -3878,7 +5274,24 @@ sim workflows deployment status | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | +| `--no-dry-run` | No | Send --dry-run as false. | +| `--blocks ` | Yes | Blocks keyed by block id. (JSON, or @path / @- to read a file or stdin). | +| `--edges ` | Yes | Directed connections between blocks. (JSON, or @path / @- to read a file or stdin). | +| `--loops ` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | +| `--parallels ` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | +| `--variables ` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -3887,7 +5300,7 @@ sim workflows deployment status Get Workflow Version ```bash -sim workflows versions get +sim workflows versions get ``` **Arguments** @@ -3896,7 +5309,7 @@ sim workflows versions get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | | `version` | Yes | Numeric deployment version. | @@ -3906,7 +5319,7 @@ sim workflows versions get List Workflow Versions ```bash -sim workflows versions list [options] +sim workflows versions list [options] ``` **Arguments** @@ -3915,7 +5328,7 @@ sim workflows versions list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -3929,6 +5342,36 @@ sim workflows versions list [options] +### sim workflows versions update + +Update Workflow Version + +```bash +sim workflows versions update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | +| `version` | Yes | Numeric deployment version. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New label for the deployment version. | +| `--description ` | No | New release note for the deployment version, or null to clear it. | + + + ### sim workflows import Import Workflow @@ -3964,6 +5407,7 @@ sim workflows list [options] | Option | Required | Description | | --- | --- | --- | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | @@ -3974,12 +5418,78 @@ sim workflows list [options] +### sim workflows move + +Move Workflows + +```bash +sim workflows move [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--folder ` | Yes | Folder path as shown in the app; the leading / is optional. | + + + +### sim workflows restore + +Restore an archived workflow + +```bash +sim workflows restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +### sim workflows revert create + +Revert Workflow To Version + +```bash +sim workflows revert create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | +| `version` | Yes | Numeric deployment version, or `active` for the currently live version. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ### sim workflows rollback Rollback Workflow ```bash -sim workflows rollback [options] +sim workflows rollback [options] ``` **Arguments** @@ -3988,7 +5498,7 @@ sim workflows rollback [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -4007,7 +5517,7 @@ sim workflows rollback [options] Take a workflow out of deployment ```bash -sim workflows undeploy +sim workflows undeploy ``` **Arguments** @@ -4016,7 +5526,7 @@ sim workflows undeploy | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -4025,7 +5535,7 @@ sim workflows undeploy Update Workflow ```bash -sim workflows update [options] +sim workflows update [options] ``` **Arguments** @@ -4034,7 +5544,7 @@ sim workflows update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -4055,7 +5565,7 @@ sim workflows update [options] Move a workflow to a folder ```bash -sim workflows mv +sim workflows mv ``` **Arguments** @@ -4064,7 +5574,7 @@ sim workflows mv | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | | `folder` | Yes | Folder path as shown in the app; the leading / is optional | diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index c261f689d5b..e6ffe5dc906 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -30,7 +30,7 @@ sim skills create [options] ## Delete skill ```bash -sim skills delete [options] +sim skills delete [options] ``` **Arguments** @@ -39,7 +39,7 @@ sim skills delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | @@ -56,7 +56,7 @@ sim skills delete [options] ## Get skill ```bash -sim skills get +sim skills get ``` **Arguments** @@ -65,14 +65,14 @@ sim skills get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | ## Grant skill editor ```bash -sim skills editors create [options] +sim skills editors create [options] ``` **Arguments** @@ -81,7 +81,7 @@ sim skills editors create [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | @@ -98,7 +98,7 @@ sim skills editors create [options] ## List skill editors ```bash -sim skills editors list [options] +sim skills editors list [options] ``` **Arguments** @@ -107,7 +107,7 @@ sim skills editors list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | @@ -126,7 +126,7 @@ sim skills editors list [options] ## Revoke skill editor ```bash -sim skills editors delete [options] +sim skills editors delete [options] ``` **Arguments** @@ -135,7 +135,7 @@ sim skills editors delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | @@ -172,7 +172,7 @@ sim skills list [options] ## Update skill ```bash -sim skills update [options] +sim skills update [options] ``` **Arguments** @@ -181,7 +181,7 @@ sim skills update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | +| `skillId` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index fed2c65ca92..1d67d3423ca 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -62,37 +62,6 @@ sim tables columns delete [options] -## Run a column’s workflow - -```bash -sim tables columns run [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | -| `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | -| `--limit ` | No | Optional cap on eligible rows to run. (JSON, or @path / @- to read a file or stdin). | - - - ## Update column ```bash @@ -229,26 +198,28 @@ sim tables groups update [options] -## Cancel table export +## Bulk delete tables and folders ```bash -sim tables exports cancel +sim tables batch-delete [options] ``` -**Arguments** +**Options** -| Argument | Required | Description | +| Option | Required | Description | | --- | --- | --- | -| `exportId` | Yes | Unique table-export identifier. | +| `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `-y, --yes` | Yes | Confirm this destructive operation. | -## Create table export +## Apply a distinct patch to each listed row ```bash -sim tables exports create [options] +sim tables rows update-each [options] ``` **Arguments** @@ -267,14 +238,14 @@ sim tables exports create [options] | Option | Required | Description | | --- | --- | --- | -| `--format ` | No | Export file format. Accepted values: `csv`, `json`. | +| `--updates ` | Yes | One merge patch per row. Each row identifier may appear at most once. (JSON, or @path / @- to read a file or stdin). | -## Get table export +## Create rows ```bash -sim tables exports get +sim tables rows create [options] ``` **Arguments** @@ -283,30 +254,25 @@ sim tables exports get | Argument | Required | Description | | --- | --- | --- | -| `exportId` | Yes | Unique table-export identifier. | +| `tableId` | Yes | Unique table identifier. | -## Get the download URL for a finished export - -```bash -sim tables exports download -``` - -**Arguments** +**Options** -| Argument | Required | Description | +| Option | Required | Description | | --- | --- | --- | -| `exportId` | Yes | Unique table-export identifier. | +| `--data ` | No | One row keyed by column name (JSON, or @path / @-; choose exactly one body flag). | +| `--rows ` | No | Several rows keyed by column name (JSON, or @path / @-; choose exactly one body flag). | -## Cancel table import +## Delete row ```bash -sim tables imports cancel +sim tables rows delete [options] ``` **Arguments** @@ -315,14 +281,25 @@ sim tables imports cancel | Argument | Required | Description | | --- | --- | --- | -| `importId` | Yes | Unique table-import identifier. | +| `tableId` | Yes | Unique table identifier. | +| `rowId` | Yes | Unique table row identifier. | -## Get table import +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Delete rows matching a filter, or an explicit list of ids ```bash -sim tables imports get +sim tables rows batch-delete [options] ``` **Arguments** @@ -331,14 +308,27 @@ sim tables imports get | Argument | Required | Description | | --- | --- | --- | -| `importId` | Yes | Unique table-import identifier. | +| `tableId` | Yes | Unique table identifier. | -## Stop every running column job +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Get row ```bash -sim tables cancel-runs [options] +sim tables rows get [options] ``` **Arguments** @@ -348,6 +338,7 @@ sim tables cancel-runs [options] | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | +| `rowId` | Yes | Unique table row identifier. | @@ -357,36 +348,43 @@ sim tables cancel-runs [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | -| `--row-id ` | No | Row whose runs should be canceled for row scope. | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | +| `--include-run-state` | No | Include per-workflow-group run state on the returned row. Off by default. | +| `--no-include-run-state` | No | Send --include-run-state as false. | -## Create table +## List rows ```bash -sim tables create [options] +sim tables rows list [options] ``` +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | + + + **Options** | Option | Required | Description | | --- | --- | --- | -| `--name ` | Yes | Identifier: letters, numbers, and underscores; cannot start with a number. | -| `--description ` | No | Optional table description. | -| `--schema ` | Yes | Table schema: {"columns":[{"name":"email","type":"string"}]} (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Caps `limit` at 200. | +| `--no-include-run-state` | No | Send --include-run-state as false. | -## Create a table folder at a path +## Query rows ```bash -sim tables folders create +sim tables rows query [options] ``` **Arguments** @@ -395,14 +393,28 @@ sim tables folders create | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `tableId` | Yes | Unique table identifier. | -## Delete folder +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Incompatible with `limit: 0`, and caps `limit` at 200. | +| `--no-include-run-state` | No | Send --include-run-state as false. | + + + +## Count rows matching a filter ```bash -sim tables folders delete [options] +sim tables rows count [options] ``` **Arguments** @@ -411,7 +423,7 @@ sim tables folders delete [options] | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `tableId` | Yes | Unique table identifier. | @@ -421,55 +433,60 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | -| `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -## List folders +## Run one row’s enrichment group ```bash -sim tables folders list [options] +sim tables rows enrich ``` -Also available as `sim tables folders ls`. - -**Options** +**Arguments** -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `--parent ` | No | Direct parent folder path. | -| `--search ` | No | Case-insensitive substring match against the folder name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `tableId` | Yes | Unique table identifier. | +| `rowId` | Yes | Unique table row identifier. | +| `groupId` | Yes | Workflow or enrichment group to run. | -## Rename or move a table folder +## Search cells for a value and return their coordinates ```bash -sim tables folders move +sim tables rows search [options] ``` -Also available as `sim tables folders mv`. - **Arguments** | Argument | Required | Description | | --- | --- | --- | -| `path` | Yes | Folder path as shown in the app; the leading / is optional | -| `destination` | Yes | Folder path as shown in the app; the leading / is optional | +| `tableId` | Yes | Unique table identifier. | -## Create rows +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--query ` | Yes | Value to search for. | +| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | + + + +## Update every row matching a filter ```bash -sim tables rows create [options] +sim tables rows batch-update [options] ``` **Arguments** @@ -488,15 +505,17 @@ sim tables rows create [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | No | One row keyed by column name (JSON, or @path / @-; choose exactly one body flag). | -| `--rows ` | No | Several rows keyed by column name (JSON, or @path / @-; choose exactly one body flag). | +| `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `-y, --yes` | Yes | Confirm this destructive operation. | -## Delete row +## Update row ```bash -sim tables rows delete [options] +sim tables rows update [options] ``` **Arguments** @@ -516,14 +535,14 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--data ` | Yes | Partial row-data patch keyed by column name. (JSON, or @path / @- to read a file or stdin). | -## Delete rows matching a filter, or an explicit list of ids +## Cancel a running dispatch ```bash -sim tables rows batch-delete [options] +sim tables dispatches cancel [options] ``` **Arguments** @@ -533,6 +552,7 @@ sim tables rows batch-delete [options] | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | +| `dispatchId` | Yes | Unique table run-dispatch identifier. | @@ -542,17 +562,14 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | | `-y, --yes` | Yes | Confirm this destructive operation. | -## Find rows matching a predicate +## Start a column or enrichment run ```bash -sim tables rows find [options] +sim tables dispatches create [options] ``` **Arguments** @@ -571,16 +588,19 @@ sim tables rows find [options] | Option | Required | Description | | --- | --- | --- | -| `--query ` | Yes | Value to find. | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--limit ` | No | Optional cap on eligible rows to run. (JSON, or @path / @- to read a file or stdin). | -## Get row +## Get run dispatch ```bash -sim tables rows get +sim tables dispatches get ``` **Arguments** @@ -590,14 +610,14 @@ sim tables rows get | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | -| `rowId` | Yes | Unique table row identifier. | +| `dispatchId` | Yes | Unique table run-dispatch identifier. | -## List rows +## List active run dispatches ```bash -sim tables rows list [options] +sim tables dispatches list ``` **Arguments** @@ -610,20 +630,27 @@ sim tables rows list [options] -**Options** +## Cancel table export + +```bash +sim tables exports cancel +``` + +**Arguments** -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `tableId` | Yes | Unique table identifier. | +| `exportId` | Yes | Unique table-export identifier. | -## Query rows +## Create table export ```bash -sim tables rows query [options] +sim tables exports create [options] ``` **Arguments** @@ -642,16 +669,14 @@ sim tables rows query [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--format ` | No | Export file format. Accepted values: `csv`, `json`. | -## Count rows matching a filter +## Get table export ```bash -sim tables rows count [options] +sim tables exports get ``` **Arguments** @@ -661,23 +686,31 @@ sim tables rows count [options] | Argument | Required | Description | | --- | --- | --- | | `tableId` | Yes | Unique table identifier. | +| `exportId` | Yes | Unique table-export identifier. | -**Options** +## Get the download URL for a finished export + +```bash +sim tables exports download +``` + +**Arguments** -| Option | Required | Description | +| Argument | Required | Description | | --- | --- | --- | -| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `tableId` | Yes | Unique table identifier. | +| `exportId` | Yes | Unique table-export identifier. | -## Run one row’s enrichment group +## Cancel table import ```bash -sim tables rows enrich +sim tables imports cancel ``` **Arguments** @@ -686,16 +719,30 @@ sim tables rows enrich | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | -| `rowId` | Yes | Unique table row identifier. | -| `groupId` | Yes | Workflow or enrichment group to run. | +| `importId` | Yes | Unique table-import identifier. | -## Update every row matching a filter +## Get table import ```bash -sim tables rows batch-update [options] +sim tables imports get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `importId` | Yes | Unique table-import identifier. | + + + +## Stop every running column job + +```bash +sim tables cancel-runs [options] ``` **Arguments** @@ -714,17 +761,36 @@ sim tables rows batch-update [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | +| `--row-id ` | No | Row whose runs should be canceled for row scope. | +| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | -## Update row +## Create table ```bash -sim tables rows update [options] +sim tables create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Identifier: letters, numbers, and underscores; cannot start with a number. | +| `--description ` | No | Optional table description. | +| `--schema ` | Yes | Table schema: {"columns":[{"name":"email","type":"string"}]} (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | + + + +## Create a table folder at a path + +```bash +sim tables folders create ``` **Arguments** @@ -733,8 +799,23 @@ sim tables rows update [options] | Argument | Required | Description | | --- | --- | --- | -| `tableId` | Yes | Unique table identifier. | -| `rowId` | Yes | Unique table row identifier. | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | + + + +## Delete folder + +```bash +sim tables folders delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -744,7 +825,64 @@ sim tables rows update [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Partial row-data patch keyed by column name. (JSON, or @path / @- to read a file or stdin). | +| `--recursive` | No | Delete the folder and its descendants. | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## List folders + +```bash +sim tables folders list [options] +``` + +Also available as `sim tables folders ls`. + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--parent ` | No | Direct parent folder path. | +| `--search ` | No | Case-insensitive substring match against the folder name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + +## Rename or move a table folder + +```bash +sim tables folders move +``` + +Also available as `sim tables folders mv`. + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | +| `destination` | Yes | Folder path as shown in the app; the leading / is optional | + + + +## Restore an archived table folder + +```bash +sim tables folders restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `path` | Yes | Folder path as shown in the app; the leading / is optional | @@ -892,6 +1030,24 @@ sim tables delete [options] +## Get enrichment run detail + +```bash +sim tables enrichment get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | +| `rowId` | Yes | Unique table row identifier. | +| `groupId` | Yes | Workflow or enrichment group to run. | + + + ## Get table ```bash @@ -920,6 +1076,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | @@ -928,6 +1085,40 @@ sim tables list [options] +## Move tables and folders + +```bash +sim tables move [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--to ` | No | Destination folder path; omit for root. | + + + +## Restore an archived table + +```bash +sim tables restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `tableId` | Yes | Unique table identifier. | + + + ## Update table ```bash diff --git a/apps/docs/content/docs/en/cli/tools.mdx b/apps/docs/content/docs/en/cli/tools.mdx new file mode 100644 index 00000000000..bc43a7dd711 --- /dev/null +++ b/apps/docs/content/docs/en/cli/tools.mdx @@ -0,0 +1,45 @@ +--- +title: Tools +description: Manage tools — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Get tool + +```bash +sim tools get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `toolId` | Yes | Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id. | + + + +## List tools + +```bash +sim tools list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the tool id, name, and description. | +| `--hosted-api-key ` | No | Restrict to tools by how their API key is supplied. Accepted values: `always`, `conditional`, `none`. | +| `--oauth-provider ` | No | Restrict to tools that authenticate against this OAuth service. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx new file mode 100644 index 00000000000..f75682def23 --- /dev/null +++ b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx @@ -0,0 +1,189 @@ +--- +title: Workflow Mcp Servers +description: Manage workflow mcp servers — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Create workflow MCP server + +```bash +sim workflow-mcp-servers create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Server display name, shown to connecting MCP clients. | +| `--description ` | No | Optional server description. | +| `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | +| `--no-is-public` | No | Send --is-public as false. | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | + + + +## Delete workflow MCP server + +```bash +sim workflow-mcp-servers delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `serverId` | Yes | Unique workflow-MCP server identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Publish workflow as MCP tool + +```bash +sim workflow-mcp-servers tools create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `serverId` | Yes | Unique workflow-MCP server identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow-id ` | Yes | Deployed workflow to publish. The workflow must already be deployed. | +| `--tool-name ` | No | Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted. | +| `--tool-description ` | No | Description shown to MCP clients. Derived from the workflow name when omitted. | +| `--parameter-descriptions ` | No | Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored. (JSON, or @path / @- to read a file or stdin). | + + + +## List workflow MCP tools + +```bash +sim workflow-mcp-servers tools list +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `serverId` | Yes | Unique workflow-MCP server identifier. | + + + +## Unpublish workflow MCP tool + +```bash +sim workflow-mcp-servers tools delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `serverId` | Yes | Unique workflow-MCP server identifier. | +| `workflowId` | Yes | Workflow published as a tool on this server. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Get workflow MCP server + +```bash +sim workflow-mcp-servers get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `serverId` | Yes | Unique workflow-MCP server identifier. | + + + +## List workflow MCP servers + +```bash +sim workflow-mcp-servers list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## Update workflow MCP server + +```bash +sim workflow-mcp-servers update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `serverId` | Yes | Unique workflow-MCP server identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Server display name, shown to connecting MCP clients. | +| `--description ` | No | New server description, or null to clear it. | +| `--is-public` | No | Whether the server answers MCP clients without a Sim API key. | +| `--no-is-public` | No | Send --is-public as false. | + + diff --git a/apps/docs/content/docs/en/cli/workflows.mdx b/apps/docs/content/docs/en/cli/workflows.mdx index 9741d563b0e..caa198f0d9b 100644 --- a/apps/docs/content/docs/en/cli/workflows.mdx +++ b/apps/docs/content/docs/en/cli/workflows.mdx @@ -9,6 +9,83 @@ import { CommandTable } from '@/components/ui/command-table' Every command below also accepts the [global options](/cli/commands#global-options). +## Activate workflow version + +```bash +sim workflows activate create +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | +| `version` | Yes | Numeric deployment version. | + + + +## Apply workflow operations + +```bash +sim workflows operations apply [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | +| `--no-dry-run` | No | Send --dry-run as false. | +| `--operations ` | Yes | Edits to apply, in a single batch. (JSON, or @path / @- to read a file or stdin). | +| `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. | +| `--no-atomic` | No | Send --atomic as false. | +| `--layout ` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. | +| `--set-block-enabled ` | No | Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined. (JSON, or @path / @- to read a file or stdin). | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Update workflow variables + +```bash +sim workflows variables update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--operations ` | Yes | Variable changes to apply, in order. (JSON, or @path / @- to read a file or stdin). | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ## Cancel a running workflow run ```bash @@ -62,6 +139,9 @@ Show run status (requested outputs are included in JSON or YAML output) | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | | `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | +| `--no-include-file-base64` | No | Send --include-file-base64 as false. | +| `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -248,7 +328,7 @@ Also available as `sim workflows folders mv`. ## Delete workflow ```bash -sim workflows delete [options] +sim workflows delete [options] ``` **Arguments** @@ -257,7 +337,7 @@ sim workflows delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -271,10 +351,90 @@ sim workflows delete [options] +## Take a workflow’s chat deployment offline + +```bash +sim workflows chat unpublish [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + +## Show a workflow’s chat deployment + +```bash +sim workflows chat status +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +## Publish or replace a workflow’s chat deployment + +```bash +sim workflows chat publish [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--identifier ` | Yes | URL slug the deployed chat answers on. Must be free across live deployments. | +| `--title ` | Yes | Title shown to visitors. | +| `--description ` | No | Description shown to visitors. Omitted clears it. | +| `--customizations ` | No | Presentation overrides. Omitted fields take platform defaults. (JSON, or @path / @- to read a file or stdin). | +| `--auth-type ` | No | How visitors are gated. `public` leaves the chat open to anyone holding the URL. Accepted values: `public`, `password`, `email`, `sso`. | +| `--password ` | No | Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back. | +| `--allowed-emails ` | No | Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes. (JSON, or @path / @- to read a file or stdin). | +| `--output-configs ` | No | Block outputs to surface to visitors. Omitted surfaces none. (JSON, or @path / @- to read a file or stdin). | +| `--include-thinking` | No | Allow visitors to receive provider thinking events. | +| `--no-include-thinking` | No | Send --include-thinking as false. | +| `--include-tool-calls` | No | Allow visitors to receive tool lifecycle events. | +| `--no-include-tool-calls` | No | Send --include-tool-calls as false. | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ## Deploy workflow ```bash -sim workflows deploy [options] +sim workflows deploy [options] ``` **Arguments** @@ -283,7 +443,7 @@ sim workflows deploy [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -298,10 +458,37 @@ sim workflows deploy [options] +## Duplicate workflow + +```bash +sim workflows duplicate create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Name for the copy. Defaults to the source name, deduplicated within the folder. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | + + + ## Run a deployed workflow ```bash -sim workflows run [options] +sim workflows run [options] ``` **Arguments** @@ -310,7 +497,7 @@ sim workflows run [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -326,7 +513,7 @@ sim workflows run [options] | `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | -| `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64. Rejected when `async` is true. | +| `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | | `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. | | `--include-thinking` | No | Show model reasoning while following (requires --follow). | | `--include-tool-calls` | No | Show tool calls while following (requires --follow). | @@ -336,7 +523,7 @@ sim workflows run [options] ## Print a workflow as a portable JSON document ```bash -sim workflows export +sim workflows export ``` **Arguments** @@ -345,14 +532,14 @@ sim workflows export | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | ## Get workflow ```bash -sim workflows get +sim workflows get ``` **Arguments** @@ -361,14 +548,72 @@ sim workflows get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | ## Show a workflow’s current deployment ```bash -sim workflows deployment status +sim workflows deployment status +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +## Update workflow public API access + +```bash +sim workflows deployment update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--is-public-api ` | Yes | Whether the deployed workflow should accept unauthenticated public API execution. Accepted values: `true`, `false`. | + + + +## Get workflow state + +```bash +sim workflows state get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +## Replace workflow state + +```bash +sim workflows state replace [options] ``` **Arguments** @@ -377,14 +622,31 @@ sim workflows deployment status | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | +| `--no-dry-run` | No | Send --dry-run as false. | +| `--blocks ` | Yes | Blocks keyed by block id. (JSON, or @path / @- to read a file or stdin). | +| `--edges ` | Yes | Directed connections between blocks. (JSON, or @path / @- to read a file or stdin). | +| `--loops ` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | +| `--parallels ` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | +| `--variables ` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). | +| `-y, --yes` | Yes | Confirm this destructive operation. | ## Get workflow version ```bash -sim workflows versions get +sim workflows versions get ``` **Arguments** @@ -393,7 +655,7 @@ sim workflows versions get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | | `version` | Yes | Numeric deployment version. | @@ -401,7 +663,7 @@ sim workflows versions get ## List workflow versions ```bash -sim workflows versions list [options] +sim workflows versions list [options] ``` **Arguments** @@ -410,7 +672,7 @@ sim workflows versions list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -424,6 +686,34 @@ sim workflows versions list [options] +## Update workflow version + +```bash +sim workflows versions update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | +| `version` | Yes | Numeric deployment version. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New label for the deployment version. | +| `--description ` | No | New release note for the deployment version, or null to clear it. | + + + ## Import workflow ```bash @@ -455,6 +745,7 @@ sim workflows list [options] | Option | Required | Description | | --- | --- | --- | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | @@ -465,10 +756,70 @@ sim workflows list [options] +## Move workflows + +```bash +sim workflows move [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--folder ` | Yes | Folder path as shown in the app; the leading / is optional. | + + + +## Restore an archived workflow + +```bash +sim workflows restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | + + + +## Revert workflow to version + +```bash +sim workflows revert create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `workflowId` | Yes | Unique workflow identifier. | +| `version` | Yes | Numeric deployment version, or `active` for the currently live version. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ## Rollback workflow ```bash -sim workflows rollback [options] +sim workflows rollback [options] ``` **Arguments** @@ -477,7 +828,7 @@ sim workflows rollback [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -494,7 +845,7 @@ sim workflows rollback [options] ## Take a workflow out of deployment ```bash -sim workflows undeploy +sim workflows undeploy ``` **Arguments** @@ -503,14 +854,14 @@ sim workflows undeploy | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | ## Update workflow ```bash -sim workflows update [options] +sim workflows update [options] ``` **Arguments** @@ -519,7 +870,7 @@ sim workflows update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | @@ -538,7 +889,7 @@ sim workflows update [options] ## Move a workflow to a folder ```bash -sim workflows mv +sim workflows mv ``` **Arguments** @@ -547,7 +898,7 @@ sim workflows mv | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique workflow identifier. | +| `workflowId` | Yes | Unique workflow identifier. | | `folder` | Yes | Folder path as shown in the app; the leading / is optional | diff --git a/apps/docs/content/docs/en/workflows/blocks/human-in-the-loop.mdx b/apps/docs/content/docs/en/workflows/blocks/human-in-the-loop.mdx index a5487ad3f36..1a9d61fba34 100644 --- a/apps/docs/content/docs/en/workflows/blocks/human-in-the-loop.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/human-in-the-loop.mdx @@ -174,7 +174,7 @@ Access resume data in downstream blocks using ``. ## API Execute Behavior -When triggering a workflow through `POST /api/v2/workflows/{id}/execute`, HITL blocks cause the execution to pause and return the `_resume` data in the v2 response envelope. The legacy `POST /api/workflows/{id}/execute` endpoint remains available for existing integrations. +When triggering a workflow through `POST /api/v2/workflows/{workflowId}/execute`, HITL blocks cause the execution to pause and return the `_resume` data in the v2 response envelope. The legacy `POST /api/workflows/{id}/execute` endpoint remains available for existing integrations. diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index e8a71db70ad..c4c431ae0e5 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -22,6 +22,8 @@ "(generated)/credentials", "(generated)/secrets", "(generated)/billing", + "(generated)/catalog", + "(generated)/meta", "(generated)/audit-logs" ] } diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index e8a71db70ad..c4c431ae0e5 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -22,6 +22,8 @@ "(generated)/credentials", "(generated)/secrets", "(generated)/billing", + "(generated)/catalog", + "(generated)/meta", "(generated)/audit-logs" ] } diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index e8a71db70ad..c4c431ae0e5 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -22,6 +22,8 @@ "(generated)/credentials", "(generated)/secrets", "(generated)/billing", + "(generated)/catalog", + "(generated)/meta", "(generated)/audit-logs" ] } diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index e8a71db70ad..c4c431ae0e5 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -22,6 +22,8 @@ "(generated)/credentials", "(generated)/secrets", "(generated)/billing", + "(generated)/catalog", + "(generated)/meta", "(generated)/audit-logs" ] } diff --git a/apps/docs/lib/redirects.ts b/apps/docs/lib/redirects.ts index a43a03b2471..c63f7820e8e 100644 --- a/apps/docs/lib/redirects.ts +++ b/apps/docs/lib/redirects.ts @@ -189,6 +189,29 @@ export const DOCS_REDIRECTS: DocsRedirect[] = [ * must resolve. */ // Pure operationId renames — same path and method, v1 -> v2. + { + // `/rows/find` became `/rows/search`: same operation, renamed once the + // surface settled on `query` for a structured predicate and `search` for + // text. + source: '/api-reference/tables/findTableRows', + destination: '/api-reference/tables/searchTableRows', + permanent: true, + }, + { + // `/columns/run` became `POST /tables/{tableId}/dispatches`: it always + // created a dispatch and was polled as one, and `GET .../dispatches` + // already sat at the path it now posts to. + // + // These two are the only operations that pass retired a *published* slug — + // confirmed by diffing operationIds in the committed specs, not by reading + // the diff, because a path can move while its operationId (and therefore + // its docs slug) stays put, and an operationId can change without the path + // moving. Everything else renamed alongside them was added and removed + // within the same unreleased branch. + source: '/api-reference/tables/runTableColumns', + destination: '/api-reference/tables/createTableDispatch', + permanent: true, + }, { source: '/api-reference/audit-logs/getAuditLogDetails', destination: '/api-reference/audit-logs/getAuditLog', diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index af4a4192405..d371b9b2c7a 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -43,9 +43,9 @@ "name": "workspaceId", "in": "query", "required": false, - "description": "Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.", + "description": "Workspace whose payer should be resolved. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.", "schema": { - "description": "Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.", + "description": "Workspace whose payer should be resolved. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.", "type": "string", "minLength": 1, "maxLength": 128 @@ -452,7 +452,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index b9a51bceab7..2968d388503 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -264,6 +264,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -330,6 +333,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -343,6 +349,92 @@ } }, "/api/v2/files/uploads/{uploadId}": { + "get": { + "operationId": "getFileUpload", + "summary": "Get File Upload", + "description": "Read an upload session's current state — whether it is still accepting bytes, has finalized into a file, or has failed. Use it to decide whether an interrupted transfer can be resumed or should be abandoned. Like every other upload control leg it requires the signed upload token, and is re-authorized against the workspace on each call.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the upload session.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." + } + } + ], + "responses": { + "200": { + "description": "Current upload-session state.", + "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/FileUploadResponse" + } + } + } + }, + "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" + } + } + }, "delete": { "operationId": "abortFileUpload", "summary": "Abort File Upload", @@ -526,6 +618,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -576,13 +671,287 @@ "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "Signed upload control token returned when the upload session was created." + } + } + ], + "responses": { + "200": { + "description": "The completed or finalizing upload session.", + "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/FileUploadResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/text": { + "get": { + "operationId": "readFileText", + "summary": "Read File Text", + "description": "Return a file's text content, parsed out of the stored bytes. This reads the file; it writes nothing — `POST /api/v2/files/{fileId}/unzip` is the endpoint that unzips an archive into the workspace. Answers `400` for a type no parser supports, naming the raw-bytes download as the escape hatch, and `413` for a file above the extraction ceiling. A generated document is extracted from its compiled artifact rather than its generation source, so one still compiling answers `409` and is worth retrying. **`degraded: true` means text extraction did not fully succeed and the returned text may be incomplete or synthesized from the file's raw bytes. Do not treat it as authoritative content.** The legacy `.doc` and `.ppt` parsers deliberately return best-effort content rather than failing, so this flag — not an error status — is how a partial extraction is reported. `truncated` separately reports that a parser limit stopped extraction early.", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "maxBytes", + "in": "query", + "required": false, + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "schema": { + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "type": "integer", + "minimum": 1, + "maximum": 26214400 + } + } + ], + "responses": { + "200": { + "description": "The extracted text and its extraction-quality flags.", + "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/FileTextResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/bulk-download": { + "get": { + "operationId": "bulkDownloadFiles", + "summary": "Bulk Download Files", + "description": "Stream a selection of workspace files as one zip. Select files by id and folders by path, each as one comma-separated parameter; a folder expands to all its descendants, and a path matching no folder is rejected rather than ignored. Each parameter accepts at most 100 entries — the same ceiling the resolved selection is held to — and the resolved file count and total bytes are checked again, so an over-broad selection answers `400` rather than streaming indefinitely. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace containing the selection.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the selection." + } + }, + { + "name": "fileIds", + "in": "query", + "required": false, + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "schema": { + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "type": "string" + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Folder paths to include with all their descendants, comma-separated. At most 100 entries, and the files they resolve to count against the same 100-file download ceiling. A path that matches no folder is rejected rather than ignored.", + "schema": { + "description": "Folder paths to include with all their descendants, comma-separated. At most 100 entries, and the files they resolve to count against the same 100-file download ceiling. A path that matches no folder is rejected rather than ignored.", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The selected files as a zip archive.", + "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "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/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/unzip": { + "post": { + "operationId": "unzipFile", + "summary": "Unzip File", + "description": "Unzip a `.zip` archive into a new folder beside it and answer counts plus the destination path. This writes new workspace files; it does not read anything out of the archive into the response — `GET /api/v2/files/{fileId}/text` is the endpoint that returns a file's text. The unpacked files are deliberately not returned — a large archive would materialize thousands of objects into one response — so page `GET /api/v2/files?folderPath=...` for the contents. Unzipping is slow: an archive near the size ceiling can run for minutes. Only one unzip of a given archive runs at a time; a concurrent attempt answers `409`. Archives past the size ceiling, and runs that outrun their time budget, answer `413`.", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } } ], + "requestBody": { + "required": true, + "description": "Workspace scope for the archive.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnzipFileBody" + } + } + } + }, "responses": { "200": { - "description": "The completed or finalizing upload session.", + "description": "Counts and destination folder for the unpacked archive.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -597,7 +966,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/FileUnzipResponse" } } } @@ -617,6 +986,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -871,6 +1246,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -955,6 +1333,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1232,7 +1613,7 @@ } } }, - "/api/v2/audit-logs/{id}": { + "/api/v2/audit-logs/{auditLogId}": { "get": { "operationId": "getAuditLog", "summary": "Get Audit Log", @@ -1240,7 +1621,7 @@ "tags": ["Audit Logs"], "parameters": [ { - "name": "id", + "name": "auditLogId", "in": "path", "required": true, "description": "Audit-log entry identifier.", @@ -1365,6 +1746,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1523,6 +1907,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1604,6 +1991,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1620,7 +2010,7 @@ "post": { "operationId": "bulkDeleteFiles", "summary": "Delete Files", - "description": "Delete up to 1,000 workspace files in one operation.", + "description": "Delete up to 1,000 workspace files in one operation. This is the same soft delete as `DELETE /api/v2/files/{fileId}`: files are archived, not erased, and `POST /api/v2/files/{fileId}/restore` reverses each one.", "tags": ["Files"], "requestBody": { "required": true, @@ -1667,12 +2057,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1689,7 +2079,7 @@ "get": { "operationId": "listFilesFolders", "summary": "List Folders", - "description": "List workspace file folders with optional parent-path filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List workspace file folders with optional parent-path filtering and sorting. Pass `scope=archived` to list folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Files"], "parameters": [ { @@ -1749,6 +2139,18 @@ "type": "string", "enum": ["asc", "desc"] } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "type": "string", + "enum": ["active", "archived"] + } } ], "responses": { @@ -1852,6 +2254,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1919,6 +2324,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2032,6 +2440,78 @@ } } } + }, + "/api/v2/files/folders/restore": { + "post": { + "operationId": "restoreFilesFolder", + "summary": "Restore Folder", + "description": "Restore a soft-deleted folder and everything archived with it. `DELETE /api/v2/files/folders` archives recursively, so this is what makes a recursive delete recoverable: without it the archived files stay visible through `GET /api/v2/files?scope=archived` but the folder structure cannot be rebuilt. Address the folder by the path reported by `GET /api/v2/files/folders?scope=archived`; a path that is not archived answers `404`.", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Workspace scope and archived folder path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreFileFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The restored folder and what it brought back.", + "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/FileFolderRestoreResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } } }, "components": { @@ -2219,6 +2699,22 @@ } } }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, "RateLimited": { "description": "The caller exceeded the request rate limit.", "headers": { @@ -2297,7 +2793,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." } }, "required": ["code", "message"], @@ -2606,7 +3102,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." }, "headers": { "type": "object", @@ -2760,7 +3256,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." }, "headers": { "type": "object", @@ -2839,6 +3335,143 @@ } ] }, + "V2FileText": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File the text was extracted from." + }, + "name": { + "type": "string", + "description": "File name, including its extension." + }, + "type": { + "type": "string", + "description": "Stored MIME type of the source file." + }, + "text": { + "type": "string", + "description": "Extracted text." + }, + "truncated": { + "type": "boolean", + "description": "True when a parser limit stopped extraction before the input was exhausted." + }, + "degraded": { + "type": "boolean", + "description": "True when text extraction did not fully succeed and `text` may be incomplete or synthesized from the raw bytes rather than read from the document. Never treat degraded text as authoritative content." + }, + "degradedReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Why extraction degraded, or null when it did not." + }, + "charCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Length of `text` in characters." + }, + "byteCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Source bytes read from storage before extraction." + } + }, + "required": [ + "fileId", + "name", + "type", + "text", + "truncated", + "degraded", + "degradedReason", + "charCount", + "byteCount" + ], + "additionalProperties": false, + "title": "Extracted file text", + "description": "Text extracted from a workspace file, with extraction-quality flags." + }, + "FileTextResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileText" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "File text response", + "description": "Text extracted from a workspace file." + }, + "V2FileUnzipResult": { + "type": "object", + "properties": { + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical path of the folder the archive was unpacked into. May differ from the archive name when a sibling folder already claimed it.", + "maxLength": 4096 + }, + "extractedFileCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of files written into the destination folder." + }, + "skippedFileCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of archive entries skipped as unsafe, empty, or noise." + } + }, + "required": ["folderPath", "extractedFileCount", "skippedFileCount"], + "additionalProperties": false, + "title": "Unzip result", + "description": "Outcome of unzipping a workspace archive into a folder." + }, + "FileUnzipResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileUnzipResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Unzip file response", + "description": "Counts and destination folder for the unpacked archive." + }, + "UnzipFileBody": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the archive." + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Unzip file body", + "description": "Workspace scope for the archive." + }, "V2DeleteFileResult": { "type": "object", "properties": { @@ -3223,7 +3856,7 @@ "type": "null" } ], - "description": "Identifier of the affected resource." + "description": "Identifier of the affected resource. Always null when `resourceType` is `folder`: folders are addressed by canonical path on this API, so their internal identifiers are withheld rather than published as an id no other endpoint accepts." }, "resourceName": { "anyOf": [ @@ -3248,7 +3881,7 @@ "description": "Human-readable description of the action." }, "metadata": { - "description": "Arbitrary per-action JSON metadata." + "description": "Arbitrary per-action JSON metadata. Internal folder identifiers are stripped at every nesting level, for the same reason `resourceId` is null on a folder entry." }, "createdAt": { "type": "string", @@ -3718,18 +4351,51 @@ "title": "File folder list response", "description": "Workspace file folders in the current page." }, - "FileFolderResponse": { + "V2FileFolderRestore": { + "type": "object", + "properties": { + "folder": { + "description": "The restored folder.", + "$ref": "#/components/schemas/V2Folder" + }, + "restoredItems": { + "type": "object", + "properties": { + "files": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files restored inside the folder tree." + }, + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Folders restored, including the one addressed." + } + }, + "required": ["files", "folders"], + "additionalProperties": false, + "description": "What the restore brought back." + } + }, + "required": ["folder", "restoredItems"], + "additionalProperties": false, + "title": "Folder restore result", + "description": "The restored folder and the counts of items it brought back." + }, + "FileFolderRestoreResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Folder" + "$ref": "#/components/schemas/V2FileFolderRestore" } }, "required": ["data"], "additionalProperties": false, - "title": "File folder response", - "description": "A single workspace file folder." + "title": "Folder restore response", + "description": "The restored folder and the counts of items it brought back." }, "NonRootFolderPathInput": { "title": "Non-root folder path input", @@ -3737,6 +4403,38 @@ "maxLength": 4096, "type": "string" }, + "RestoreFileFolderRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the archived folder." + }, + "path": { + "description": "Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + "required": ["workspaceId", "path"], + "additionalProperties": false, + "title": "Restore file folder request", + "description": "Workspace scope and archived folder path." + }, + "FileFolderResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Folder" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "File folder response", + "description": "A single workspace file folder." + }, "CreateFileFolderRequest": { "type": "object", "properties": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 79634b38de6..7b3dcccee12 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -36,7 +36,7 @@ "get": { "operationId": "listKnowledgeBases", "summary": "List Knowledge Bases", - "description": "List knowledge bases in a workspace with folder filtering, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List knowledge bases in a workspace with lifecycle scope, folder filtering, search, sorting, and opaque cursor pagination. `scope` defaults to `active`; pass `archived` to list knowledge bases a `DELETE` archived, each carrying the `deletedAt` instant it was archived, and recover one with `POST /api/v2/knowledge/{knowledgeBaseId}/restore`. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -51,6 +51,18 @@ "description": "Workspace whose knowledge bases should be listed." } }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "type": "string", + "enum": ["active", "archived"] + } + }, { "name": "folderPath", "in": "query", @@ -226,6 +238,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -238,7 +253,7 @@ } } }, - "/api/v2/knowledge/{id}": { + "/api/v2/knowledge/{knowledgeBaseId}": { "get": { "operationId": "getKnowledgeBase", "summary": "Get Knowledge Base", @@ -246,7 +261,7 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -323,7 +338,7 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -385,6 +400,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -403,7 +421,7 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -471,7 +489,7 @@ } } }, - "/api/v2/knowledge/{id}/connectors": { + "/api/v2/knowledge/{knowledgeBaseId}/connectors": { "get": { "operationId": "listKnowledgeConnectors", "summary": "List Knowledge Connectors", @@ -479,7 +497,7 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -602,7 +620,7 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -664,6 +682,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -676,7 +697,7 @@ } } }, - "/api/v2/knowledge/{id}/connectors/{connectorId}": { + "/api/v2/knowledge/{knowledgeBaseId}/connectors/{connectorId}": { "get": { "operationId": "getKnowledgeConnector", "summary": "Get Knowledge Connector", @@ -684,25 +705,25 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "connectorId", "in": "path", "required": true, - "description": "Knowledge base that owns the connector.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Knowledge base that owns the connector." + "description": "Connector selected for the operation." } }, { - "name": "connectorId", + "name": "knowledgeBaseId", "in": "path", "required": true, - "description": "Connector selected for the operation.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Connector selected for the operation." + "description": "Knowledge base that owns the connector." } }, { @@ -770,25 +791,25 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "connectorId", "in": "path", "required": true, - "description": "Knowledge base that owns the connector.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Knowledge base that owns the connector." + "description": "Connector selected for the operation." } }, { - "name": "connectorId", + "name": "knowledgeBaseId", "in": "path", "required": true, - "description": "Connector selected for the operation.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Connector selected for the operation." + "description": "Knowledge base that owns the connector." } } ], @@ -843,6 +864,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -861,25 +885,25 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "connectorId", "in": "path", "required": true, - "description": "Knowledge base that owns the connector.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Knowledge base that owns the connector." + "description": "Connector selected for the operation." } }, { - "name": "connectorId", + "name": "knowledgeBaseId", "in": "path", "required": true, - "description": "Connector selected for the operation.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Connector selected for the operation." + "description": "Knowledge base that owns the connector." } }, { @@ -951,7 +975,7 @@ } } }, - "/api/v2/knowledge/{id}/connectors/{connectorId}/sync": { + "/api/v2/knowledge/{knowledgeBaseId}/connectors/{connectorId}/sync": { "post": { "operationId": "syncKnowledgeConnector", "summary": "Sync Knowledge Connector", @@ -959,25 +983,25 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "connectorId", "in": "path", "required": true, - "description": "Knowledge base that owns the connector.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Knowledge base that owns the connector." + "description": "Connector selected for the operation." } }, { - "name": "connectorId", + "name": "knowledgeBaseId", "in": "path", "required": true, - "description": "Connector selected for the operation.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Connector selected for the operation." + "description": "Knowledge base that owns the connector." } } ], @@ -1032,6 +1056,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1044,7 +1071,7 @@ } } }, - "/api/v2/knowledge/{id}/connectors/{connectorId}/documents": { + "/api/v2/knowledge/{knowledgeBaseId}/connectors/{connectorId}/documents": { "get": { "operationId": "listKnowledgeConnectorDocuments", "summary": "List Knowledge Connector Documents", @@ -1052,25 +1079,25 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "connectorId", "in": "path", "required": true, - "description": "Knowledge base that owns the connector.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Knowledge base that owns the connector." + "description": "Connector selected for the operation." } }, { - "name": "connectorId", + "name": "knowledgeBaseId", "in": "path", "required": true, - "description": "Connector selected for the operation.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Connector selected for the operation." + "description": "Knowledge base that owns the connector." } }, { @@ -1172,25 +1199,25 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "connectorId", "in": "path", "required": true, - "description": "Knowledge base that owns the connector.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Knowledge base that owns the connector." + "description": "Connector selected for the operation." } }, { - "name": "connectorId", + "name": "knowledgeBaseId", "in": "path", "required": true, - "description": "Connector selected for the operation.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Connector selected for the operation." + "description": "Knowledge base that owns the connector." } } ], @@ -1242,6 +1269,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1311,6 +1341,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1323,7 +1356,7 @@ } } }, - "/api/v2/knowledge/{id}/tags": { + "/api/v2/knowledge/{knowledgeBaseId}/tags": { "get": { "operationId": "listKnowledgeTags", "summary": "List Tags", @@ -1331,7 +1364,7 @@ "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -1397,17 +1430,15 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/knowledge/{id}/documents": { - "get": { - "operationId": "listKnowledgeDocuments", - "summary": "List Documents", - "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.", + }, + "post": { + "operationId": "createKnowledgeTag", + "summary": "Create Tag", + "description": "Define one tag on a knowledge base; use `PUT` on this path to declare several at once. Define a tag here, write its `tagSlot` on a document with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`, then filter by its `displayName` on the document list or on search. Omit `tagSlot` to take the next free slot for the field type; a field type with no free slot left is a `400` naming it, since the remedy is a different type or a deleted definition rather than a retry. A `tagSlot` already taken, or a `displayName` already defined on this knowledge base, is a `409` naming which of the two to change. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -1416,115 +1447,22 @@ "minLength": 1, "description": "Unique knowledge base identifier." } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the knowledge base.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Workspace that owns the knowledge base." - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the document filename.", - "schema": { - "description": "Case-insensitive substring match against the document filename.", - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "enabledFilter", - "in": "query", - "required": false, - "description": "Filter by whether documents are enabled for search.", - "schema": { - "default": "all", - "description": "Filter by whether documents are enabled for search.", - "type": "string", - "enum": ["all", "enabled", "disabled"] - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "uploadedAt", - "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": [ - "filename", - "fileSize", - "tokenCount", - "chunkCount", - "uploadedAt", - "processingStatus", - "enabled" - ] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "desc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "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": "tagFilters", - "in": "query", - "required": false, - "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.", - "schema": { - "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.", - "examples": [ - "[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]" - ], - "type": "string" - } } ], + "requestBody": { + "required": true, + "description": "Workspace scope, display name, field type, and optional slot.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeTagRequest" + } + } + } + }, "responses": { - "200": { - "description": "A page of knowledge documents.", + "201": { + "description": "The created tag definition.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1539,7 +1477,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentListResponse" + "$ref": "#/components/schemas/V2KnowledgeTagResponse" } } } @@ -1556,6 +1494,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1567,14 +1514,14 @@ } } }, - "patch": { - "operationId": "bulkUpdateKnowledgeDocuments", - "summary": "Bulk Enable or Disable Documents", - "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{id}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", + "put": { + "operationId": "bulkSaveKnowledgeTagDefinitions", + "summary": "Bulk Save Tag Definitions", + "description": "Declare, in one request, several of the knowledge base's tag definitions. `POST` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in `originalDisplayName`; that is the only form that edits one in place. Without it the entry is a create, and a requested `tagSlot` another name already holds is not overwritten — the definition is created in the next free slot of its `fieldType` instead, so read the returned entry for the slot actually assigned. A create whose `displayName` already exists is refused in `errors`. Per-definition failures are reported in `errors` and still answer `200`. This writes the vocabulary, not one document's tag values — set those with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -1587,18 +1534,18 @@ ], "requestBody": { "required": true, - "description": "Operation and the documents it applies to.", + "description": "Workspace scope and the tag definitions to create or update.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkUpdateKnowledgeDocumentsRequest" + "$ref": "#/components/schemas/BulkSaveKnowledgeTagDefinitionsRequest" } } } }, "responses": { "200": { - "description": "The number and identifiers of the documents that changed.", + "description": "Definitions created and updated by the save.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1613,7 +1560,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2BulkKnowledgeDocumentsResponse" + "$ref": "#/components/schemas/V2BulkSaveKnowledgeTagDefinitionsResponse" } } } @@ -1633,6 +1580,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1644,14 +1594,14 @@ } } }, - "post": { - "operationId": "uploadKnowledgeDocument", - "summary": "Upload Document", - "description": "Upload one document as multipart form data. Processing continues asynchronously after the document is accepted.", + "delete": { + "operationId": "deleteKnowledgeTagDefinitions", + "summary": "Delete Tag Definitions", + "description": "Remove tag definitions from the knowledge base. `unused` defaults to `true`, which removes only the definitions no document still carries a value for — the recoverable half, since a definition with nothing behind it can simply be redefined. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. Delete one definition at a time with `DELETE /api/v2/knowledge/{knowledgeBaseId}/tags/{tagId}`. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -1672,22 +1622,21 @@ "maxLength": 128, "description": "Workspace that owns the knowledge base." } - } - ], - "requestBody": { - "required": true, - "description": "Multipart form containing the document file.", - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/UploadKnowledgeDocumentForm" - } + }, + { + "name": "unused", + "in": "query", + "required": false, + "description": "Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable.", + "schema": { + "description": "Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable.", + "type": "boolean" } } - }, + ], "responses": { - "201": { - "description": "The accepted document queued for processing.", + "200": { + "description": "Number of tag definitions removed.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1702,7 +1651,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentSummaryResponse" + "$ref": "#/components/schemas/V2DeleteKnowledgeTagDefinitionsResponse" } } } @@ -1713,21 +1662,12 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1740,15 +1680,15 @@ } } }, - "/api/v2/knowledge/{id}/documents/uploads": { - "post": { - "operationId": "createKnowledgeDocumentUpload", - "summary": "Create Document Upload", - "description": "Create a resumable upload session and receive direct PUT or multipart transfer instructions.", + "/api/v2/knowledge/{knowledgeBaseId}/documents": { + "get": { + "operationId": "listKnowledgeDocuments", + "summary": "List Documents", + "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -1757,22 +1697,115 @@ "minLength": 1, "description": "Unique knowledge base identifier." } - } - ], - "requestBody": { - "required": true, - "description": "Document metadata used to authorize and initialize the upload.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateKnowledgeDocumentUploadRequest" - } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the document filename.", + "schema": { + "description": "Case-insensitive substring match against the document filename.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "enabledFilter", + "in": "query", + "required": false, + "description": "Filter by whether documents are enabled for search.", + "schema": { + "default": "all", + "description": "Filter by whether documents are enabled for search.", + "type": "string", + "enum": ["all", "enabled", "disabled"] + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "uploadedAt", + "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": [ + "filename", + "fileSize", + "tokenCount", + "chunkCount", + "uploadedAt", + "processingStatus", + "enabled" + ] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "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": "tagFilters", + "in": "query", + "required": false, + "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.", + "schema": { + "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.", + "examples": [ + "[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]" + ], + "type": "string" } } - }, + ], "responses": { - "201": { - "description": "The created upload session and transfer instructions.", + "200": { + "description": "A page of knowledge documents.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1787,7 +1820,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2CreateKnowledgeDocumentUploadResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentListResponse" } } } @@ -1798,21 +1831,12 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1823,17 +1847,15 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/knowledge/{id}/documents/uploads/{uploadId}": { - "delete": { - "operationId": "abortKnowledgeDocumentUpload", - "summary": "Abort Document Upload", - "description": "Abort an incomplete upload and discard provider-side multipart state.", + }, + "patch": { + "operationId": "bulkUpdateKnowledgeDocuments", + "summary": "Bulk Enable or Disable Documents", + "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -1842,45 +1864,22 @@ "minLength": 1, "description": "Unique knowledge base identifier." } - }, - { - "name": "uploadId", - "in": "path", - "required": true, - "description": "Upload session identifier returned when the upload was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier returned when the upload was created." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the knowledge base.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge base." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." - } } ], + "requestBody": { + "required": true, + "description": "Operation and the documents it applies to.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUpdateKnowledgeDocumentsRequest" + } + } + } + }, "responses": { "200": { - "description": "The aborted upload session.", + "description": "The number and identifiers of the documents that changed.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1895,7 +1894,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentUploadResponse" + "$ref": "#/components/schemas/V2BulkKnowledgeDocumentsResponse" } } } @@ -1912,8 +1911,11 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1925,17 +1927,15 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/parts": { + }, "post": { - "operationId": "createKnowledgeDocumentUploadPartUrls", - "summary": "Create Document Upload Part URLs", - "description": "Issue short-lived signed PUT URLs for up to 100 multipart part numbers.", + "operationId": "uploadKnowledgeDocument", + "summary": "Upload Document", + "description": "Upload one document as multipart form data. Processing continues asynchronously after the document is accepted.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -1945,17 +1945,6 @@ "description": "Unique knowledge base identifier." } }, - { - "name": "uploadId", - "in": "path", - "required": true, - "description": "Upload session identifier returned when the upload was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier returned when the upload was created." - } - }, { "name": "workspaceId", "in": "query", @@ -1967,33 +1956,22 @@ "maxLength": 128, "description": "Workspace that owns the knowledge base." } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." - } } ], "requestBody": { "required": true, - "description": "Multipart part numbers for which signed URLs should be created.", + "description": "Multipart form containing the document file.", "content": { - "application/json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CreateKnowledgeDocumentUploadPartUrlsRequest" + "$ref": "#/components/schemas/UploadKnowledgeDocumentForm" } } } }, "responses": { - "200": { - "description": "Signed URLs for the requested upload parts.", + "201": { + "description": "The accepted document queued for processing.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2008,7 +1986,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentUploadPartUrlsResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentSummaryResponse" } } } @@ -2019,18 +1997,21 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2043,15 +2024,100 @@ } } }, - "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { + "/api/v2/knowledge/{knowledgeBaseId}/documents/uploads": { "post": { - "operationId": "completeKnowledgeDocumentUpload", - "summary": "Complete Document Upload", - "description": "Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.", + "operationId": "createKnowledgeDocumentUpload", + "summary": "Create Document Upload", + "description": "Create a resumable upload session and receive direct PUT or multipart transfer instructions.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Document metadata used to authorize and initialize the upload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeDocumentUploadRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created upload session and transfer instructions.", + "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/V2CreateKnowledgeDocumentUploadResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/knowledge/{knowledgeBaseId}/documents/uploads/{uploadId}": { + "delete": { + "operationId": "abortKnowledgeDocumentUpload", + "summary": "Abort Document Upload", + "description": "Abort an incomplete upload and discard provider-side multipart state.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -2098,7 +2164,7 @@ ], "responses": { "200": { - "description": "The completed upload and queued document.", + "description": "The aborted upload session.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2124,9 +2190,6 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, @@ -2148,15 +2211,15 @@ } } }, - "/api/v2/knowledge/{id}/documents/{documentId}": { - "get": { - "operationId": "getKnowledgeDocument", - "summary": "Get Document", - "description": "Retrieve document detail, processing state, and connector provenance.", + "/api/v2/knowledge/{knowledgeBaseId}/documents/uploads/{uploadId}/parts": { + "post": { + "operationId": "createKnowledgeDocumentUploadPartUrls", + "summary": "Create Document Upload Part URLs", + "description": "Issue short-lived signed PUT URLs for up to 100 multipart part numbers.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -2167,14 +2230,14 @@ } }, { - "name": "documentId", + "name": "uploadId", "in": "path", "required": true, - "description": "Unique knowledge document identifier.", + "description": "Upload session identifier returned when the upload was created.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge document identifier." + "description": "Upload session identifier returned when the upload was created." } }, { @@ -2185,13 +2248,36 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." + } } ], + "requestBody": { + "required": true, + "description": "Multipart part numbers for which signed URLs should be created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeDocumentUploadPartUrlsRequest" + } + } + } + }, "responses": { "200": { - "description": "The requested knowledge document.", + "description": "Signed URLs for the requested upload parts.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2206,7 +2292,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentUploadPartUrlsResponse" } } } @@ -2223,6 +2309,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2233,15 +2328,17 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "updateKnowledgeDocument", - "summary": "Update Document", - "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{id}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.", + } + }, + "/api/v2/knowledge/{knowledgeBaseId}/documents/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeKnowledgeDocumentUpload", + "summary": "Complete Document Upload", + "description": "Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "knowledgeBaseId", "in": "path", "required": true, "description": "Unique knowledge base identifier.", @@ -2252,31 +2349,43 @@ } }, { - "name": "documentId", + "name": "uploadId", "in": "path", "required": true, - "description": "Unique knowledge document identifier.", + "description": "Upload session identifier returned when the upload was created.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge document identifier." + "description": "Upload session identifier returned when the upload was created." } - } - ], - "requestBody": { - "required": true, - "description": "Filename, search state, tag slot values, or a processing retry.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateKnowledgeDocumentRequest" - } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { "200": { - "description": "The updated document, or the requeue acknowledgement.", + "description": "The completed upload and queued document.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2291,7 +2400,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2UpdateKnowledgeDocumentResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentUploadResponse" } } } @@ -2308,8 +2417,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" + "409": { + "$ref": "#/components/responses/Conflict" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -2321,33 +2430,35 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteKnowledgeDocument", - "summary": "Delete Document", - "description": "Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.", + } + }, + "/api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}": { + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve document detail, processing state, and connector provenance.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "id", + "name": "documentId", "in": "path", "required": true, - "description": "Unique knowledge base identifier.", + "description": "Unique knowledge document identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge base identifier." + "description": "Unique knowledge document identifier." } }, { - "name": "documentId", + "name": "knowledgeBaseId", "in": "path", "required": true, - "description": "Unique knowledge document identifier.", + "description": "Unique knowledge base identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge document identifier." + "description": "Unique knowledge base identifier." } }, { @@ -2364,7 +2475,7 @@ ], "responses": { "200": { - "description": "Knowledge document deletion acknowledgement.", + "description": "The requested knowledge document.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2379,7 +2490,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDeleteResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentResponse" } } } @@ -2406,25 +2517,201 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/knowledge/folders": { - "get": { - "operationId": "listKnowledgeFolders", - "summary": "List Folders", - "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + }, + "patch": { + "operationId": "updateKnowledgeDocument", + "summary": "Update Document", + "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "documentId", + "in": "path", "required": true, - "description": "Workspace whose folders should be listed.", + "description": "Unique knowledge document identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace whose folders should be listed." + "description": "Unique knowledge document identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Filename, search state, tag slot values, or a processing retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeDocumentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated document, or the requeue acknowledgement.", + "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/V2UpdateKnowledgeDocumentResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + } + } + ], + "responses": { + "200": { + "description": "Knowledge document deletion acknowledgement.", + "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/V2KnowledgeDeleteResponse" + } + } + } + }, + "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/knowledge/folders": { + "get": { + "operationId": "listKnowledgeFolders", + "summary": "List Folders", + "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose folders should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose folders should be listed." } }, { @@ -2578,6 +2865,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2645,6 +2935,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2761,79 +3054,1252 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } - }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", + "/api/v2/knowledge/{knowledgeBaseId}/restore": { + "post": { + "operationId": "restoreKnowledgeBase", + "summary": "Restore Knowledge Base", + "description": "Un-archive a soft-deleted knowledge base along with its documents and connectors. Idempotent: a knowledge base that is already active is returned unchanged with no audit entry recorded. Restoring into an archived workspace is a `409`, and a knowledge base whose folder is still archived is returned to the workspace root. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope for the knowledge base.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreKnowledgeBaseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The restored knowledge base.", + "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/V2KnowledgeBaseResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/knowledge/{knowledgeBaseId}/documents/from-workspace-files": { + "post": { + "operationId": "addWorkspaceFilesToKnowledgeBase", + "summary": "Index Workspace Files", + "description": "Index files the workspace already stores, without re-uploading their bytes. Each reference is authorized against the file it names, so a reference the caller cannot read, one over the 100 MB document limit, or one whose type is not supported is reported in `failed` while the rest are queued — a partial outcome is a `200`, not a multi-status. A queued document starts in the `pending` processing state; the entries returned here carry only its identity, so read `GET /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}` for its current state. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and the workspace file references to index.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddWorkspaceFilesToKnowledgeBaseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Files queued for indexing, with any that could not be.", + "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/V2AddWorkspaceFilesToKnowledgeBaseResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}/chunks": { + "get": { + "operationId": "listKnowledgeChunks", + "summary": "List Chunks", + "description": "List the passages a document was split into, with content search, enabled filtering, sorting, and opaque cursor pagination. Tag values are projected by slot; resolve slots to display names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against chunk content.", + "schema": { + "description": "Case-insensitive substring match against chunk content.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "enabled", + "in": "query", + "required": false, + "description": "Restrict to enabled or disabled chunks. `all` returns both.", + "schema": { + "default": "all", + "description": "Restrict to enabled or disabled chunks. `all` returns both.", + "type": "string", + "enum": ["true", "false", "all"] + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "chunkIndex", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["chunkIndex", "tokenCount", "enabled"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum chunks to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum chunks 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 + } + } + ], + "responses": { + "200": { + "description": "A page of document chunks.", + "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/V2KnowledgeChunkListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "createKnowledgeChunk", + "summary": "Create Chunk", + "description": "Append a chunk to a document. The text is embedded before the response returns, so the chunk is searchable immediately, and it inherits the document's tag values and the next `chunkIndex`. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and the text to embed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeChunkRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created chunk.", + "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/V2KnowledgeChunkResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "bulkUpdateKnowledgeChunks", + "summary": "Bulk Update Chunks", + "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is skipped rather than failing the request, so `processed` is the authoritative count. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope, the operation to apply, and the chunks to apply it to.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUpdateKnowledgeChunksRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Outcome of the bulk chunk operation.", + "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/V2BulkKnowledgeChunksResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}/chunks/{chunkId}": { + "get": { + "operationId": "getKnowledgeChunk", + "summary": "Get Chunk", + "description": "Retrieve one chunk of a document, including the exact text that was embedded. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + }, + { + "name": "chunkId", + "in": "path", + "required": true, + "description": "Unique chunk identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique chunk identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + } + ], + "responses": { + "200": { + "description": "The requested chunk.", + "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/V2KnowledgeChunkResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "updateKnowledgeChunk", + "summary": "Update Chunk", + "description": "Correct a chunk's text or take it out of search. Changing `content` re-embeds the chunk and re-derives the document's token and character counts, so the correction reaches search immediately; disabling keeps the chunk indexed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + }, + { + "name": "chunkId", + "in": "path", + "required": true, + "description": "Unique chunk identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique chunk identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and the fields to update. At least one is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeChunkRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated chunk.", + "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/V2KnowledgeChunkResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeChunk", + "summary": "Delete Chunk", + "description": "Permanently remove one chunk and subtract it from the document's counts. Deleting does not renumber the remaining chunks, so `chunkIndex` values stay stable but become non-contiguous. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + }, + { + "name": "chunkId", + "in": "path", + "required": true, + "description": "Unique chunk identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique chunk identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + } + ], + "responses": { + "200": { + "description": "Chunk deletion acknowledgement.", + "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/V2KnowledgeDeleteResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/knowledge/{knowledgeBaseId}/tags/{tagId}": { + "patch": { + "operationId": "updateKnowledgeTag", + "summary": "Update Tag", + "description": "Rename a tag, or change the value type stored in its slot. Renaming changes the name filters and document reads use; the slot, and every value in it, is untouched. A tag's slot is fixed for its lifetime and each slot holds one kind of value, so `fieldType` can only change to another type valid for the slot the tag already occupies — anything else is a `400`, and the way to get a tag of that type is to create one. A name another tag on this knowledge base already holds is a `409`. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "tagId", + "in": "path", + "required": true, + "description": "Unique tag definition identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique tag definition identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and the fields to update. At least one is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeTagRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated tag definition.", + "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/V2KnowledgeTagResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeTag", + "summary": "Delete Tag", + "description": "Remove a tag definition and clear its slot across every document and chunk in the knowledge base. Without a definition the slot has no meaning, so leaving the values would strand them under a raw slot name — this is not recoverable. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "tagId", + "in": "path", + "required": true, + "description": "Unique tag definition identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique tag definition identifier." + } + }, + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + } + ], + "responses": { + "200": { + "description": "Tag deletion acknowledgement.", + "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/V2DeleteKnowledgeTagResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/knowledge/{knowledgeBaseId}/tags/next-slot": { + "get": { + "operationId": "getNextKnowledgeTagSlot", + "summary": "Get Next Tag Slot", + "description": "Report which slot a create would take for a field type, and how many are left. Advisory rather than a claim: nothing is reserved, and `POST /api/v2/knowledge/{knowledgeBaseId}/tags` assigns the same slot when `tagSlot` is omitted. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "fieldType", + "in": "query", + "required": true, + "description": "Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3.", + "schema": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "description": "Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3.", + "examples": ["text"] + } + } + ], + "responses": { + "200": { + "description": "Slot availability for the requested field type.", + "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/V2NextKnowledgeTagSlotResponse" + } + } + } + }, + "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/knowledge/{knowledgeBaseId}/tags/usage": { + "get": { + "operationId": "listKnowledgeTagUsage", + "summary": "List Tag Usage", + "description": "Report how many documents and chunks carry a value for each defined tag, so a caller can tell a tag that is actually populated from one that was only declared. The bounded set is returned in one page; `nextCursor` is always null. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "knowledgeBaseId", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + } + ], + "responses": { + "200": { + "description": "Usage counts for every defined tag.", + "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/V2KnowledgeTagUsageListResponse" + } + } + } + }, + "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" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", "message": "Invalid request" } } @@ -2986,94 +4452,490 @@ "schema": { "$ref": "#/components/schemas/V2Error" }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "V2KnowledgeBase": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "name": { + "type": "string", + "description": "Human-readable knowledge base name.", + "examples": ["Product Documentation"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Knowledge base description, or null when none is set.", + "examples": ["All product documentation and guides"] + }, + "tokenCount": { + "type": "number", + "description": "Total tokens across indexed documents.", + "examples": [48213] + }, + "embeddingModel": { + "type": "string", + "description": "Embedding model used to index documents.", + "examples": ["text-embedding-3-small"] + }, + "embeddingDimension": { + "type": "number", + "description": "Dimensionality of the embedding vectors.", + "examples": [1536] + }, + "chunkingConfig": { + "$ref": "#/components/schemas/V2KnowledgeChunkingConfig" + }, + "docCount": { + "description": "Number of documents in the knowledge base.", + "examples": [12], + "type": "number" + }, + "connectorTypes": { + "description": "External connector types that have synced documents into the knowledge base.", + "examples": [["notion", "google_drive"]], + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the knowledge base was created.", + "format": "date-time", + "examples": ["2025-01-10T09:00:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the knowledge base was last modified.", + "format": "date-time", + "examples": ["2025-06-18T16:45:00Z"] + }, + "ownerEmail": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Current email address of the knowledge base owner.", + "examples": ["owner@example.com"] + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root. Resolved against active folders only, so an archived knowledge base whose containing folder was archived with it reports `/`.", + "maxLength": 4096, + "examples": ["/Product"] + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the knowledge base was archived by `DELETE /knowledge/{knowledgeBaseId}`, or null while the knowledge base is active. Only `GET /knowledge?scope=archived` returns knowledge bases with a non-null value.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "id", + "name", + "description", + "tokenCount", + "embeddingModel", + "embeddingDimension", + "chunkingConfig", + "createdAt", + "updatedAt", + "ownerEmail", + "folderPath", + "deletedAt" + ], + "additionalProperties": false, + "title": "Knowledge base", + "description": "A collection of documents indexed for vector and tag search." + }, + "V2KnowledgeChunkingConfig": { + "type": "object", + "properties": { + "maxSize": { + "type": "number", + "description": "Maximum chunk size in tokens.", + "examples": [1024] + }, + "minSize": { + "type": "number", + "description": "Minimum chunk size in characters.", + "examples": [100] + }, + "overlap": { + "type": "number", + "description": "Number of overlapping characters between adjacent chunks.", + "examples": [200] + }, + "strategy": { + "description": "Chunking strategy applied during document processing.", + "type": "string", + "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + }, + "strategyOptions": { + "description": "Strategy-specific tuning options.", + "type": "object", + "properties": { + "pattern": { + "description": "Regular expression used by the regex chunking strategy.", + "type": "string", + "maxLength": 500 + }, + "separators": { + "description": "Ordered separators used to split content into chunks.", + "type": "array", + "items": { + "type": "string" + } + }, + "recipe": { + "description": "Content-aware recipe used by the automatic chunking strategy.", + "type": "string", + "enum": ["plain", "markdown", "code"] + }, + "strictBoundaries": { + "description": "Whether regex matches must form strict chunk boundaries.", + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "required": ["maxSize", "minSize", "overlap"], + "additionalProperties": { + "description": "Additional forward-compatible chunking configuration property." + }, + "title": "Knowledge chunking configuration", + "description": "How documents in a knowledge base are split into chunks before embedding." + }, + "V2KnowledgeBaseListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeBase" + }, + "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 is nothing further to fetch. Never construct one yourself." } - } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Knowledge base list response", + "description": "A cursor-paginated page of knowledge bases." }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + "V2KnowledgeBaseResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeBase" } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } - } - } - } - } - }, - "schemas": { - "V2Error": { + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge base response", + "description": "A single knowledge base." + }, + "V2KnowledgeChunkingConfigInput": { "type": "object", "properties": { - "error": { + "maxSize": { + "default": 1024, + "description": "Maximum chunk size in tokens.", + "examples": [1024], + "type": "number", + "minimum": 100, + "maximum": 4000 + }, + "minSize": { + "default": 100, + "description": "Minimum chunk size in characters.", + "examples": [100], + "type": "number", + "minimum": 1, + "maximum": 2000 + }, + "overlap": { + "default": 200, + "description": "Number of overlapping characters between adjacent chunks.", + "examples": [200], + "type": "number", + "minimum": 0, + "maximum": 500 + }, + "strategy": { + "description": "Chunking strategy applied during document processing. `regex` additionally requires `strategyOptions.pattern`.", + "type": "string", + "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + }, + "strategyOptions": { + "description": "Strategy-specific tuning options. `strictBoundaries` is accepted only with `strategy: \"regex\"`.", "type": "object", "properties": { - "code": { + "pattern": { + "description": "Regular expression used by the regex chunking strategy.", "type": "string", - "description": "Stable machine-readable error code." + "maxLength": 500 }, - "message": { + "separators": { + "description": "Ordered separators used to split content into chunks.", + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "maxLength": 100 + } + }, + "recipe": { + "description": "Content-aware recipe used by the automatic chunking strategy.", "type": "string", - "description": "Human-readable explanation of the error." + "enum": ["plain", "markdown", "code"] }, - "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "strictBoundaries": { + "description": "Whether regex matches must form strict chunk boundaries.", + "type": "boolean" } }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." + "additionalProperties": false } }, - "required": ["error"], "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." - } + "title": "Knowledge chunking configuration input", + "description": "Chunking configuration applied when processing documents. On update this object is replaced wholesale rather than merged, so a caller preserving one key must read, modify, and write the whole object back." + }, + "CreateKnowledgeBaseRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the knowledge base." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable knowledge base name.", + "examples": ["Product Documentation"] + }, + "description": { + "description": "Optional knowledge base description.", + "examples": ["All product documentation and guides"], + "type": "string", + "maxLength": 10000 + }, + "chunkingConfig": { + "default": { + "maxSize": 1024, + "minSize": 100, + "overlap": 200 + }, + "description": "Chunking configuration; defaults are applied when omitted.", + "$ref": "#/components/schemas/V2KnowledgeChunkingConfigInput" + }, + "folderPath": { + "description": "Containing folder path; omission creates the knowledge base at the root.", + "$ref": "#/components/schemas/FolderPathInput" } - ] + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create knowledge base request", + "description": "Workspace, name, description, chunking configuration, and folder placement." }, - "FolderPathInput": { - "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" + "UpdateKnowledgeBaseRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + }, + "name": { + "description": "New knowledge base name.", + "examples": ["Updated Product Documentation"], + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "New knowledge base description.", + "examples": ["Refreshed product documentation and guides"], + "type": "string", + "maxLength": 10000 + }, + "chunkingConfig": { + "description": "New document chunking configuration.", + "$ref": "#/components/schemas/V2KnowledgeChunkingConfigInput" + }, + "folderPath": { + "description": "New containing-folder path.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update knowledge base request", + "description": "Workspace scope and fields to update. At least one mutable field is required." }, - "V2KnowledgeBase": { + "V2KnowledgeDeleteData": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique knowledge base identifier.", + "description": "Identifier of the deleted resource.", "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] }, - "name": { + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the resource was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Knowledge deletion data", + "description": "Acknowledgement for a deleted knowledge base or document." + }, + "V2KnowledgeDeleteResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge deletion response", + "description": "Deletion acknowledgement containing the removed resource identifier." + }, + "V2KnowledgeConnector": { + "type": "object", + "properties": { + "id": { "type": "string", - "description": "Human-readable knowledge base name.", - "examples": ["Product Documentation"] + "minLength": 1, + "description": "Unique connector identifier." + }, + "knowledgeBaseId": { + "type": "string", + "minLength": 1, + "description": "Knowledge base synced by the connector." + }, + "connectorType": { + "type": "string", + "minLength": 1, + "description": "Registered external source type." }, - "description": { + "credentialId": { "anyOf": [ { "type": "string" @@ -3082,150 +4944,130 @@ "type": "null" } ], - "description": "Knowledge base description, or null when none is set.", - "examples": ["All product documentation and guides"] + "description": "OAuth credential identifier, or null for API-key and unauthenticated sources." }, - "tokenCount": { - "type": "number", - "description": "Total tokens across indexed documents.", - "examples": [48213] + "sourceConfig": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Connector-specific source configuration value." + }, + "description": "Connector-specific source selection and filtering configuration." }, - "embeddingModel": { + "syncMode": { "type": "string", - "description": "Embedding model used to index documents.", - "examples": ["text-embedding-3-small"] + "description": "Synchronization mode used by the connector." }, - "embeddingDimension": { - "type": "number", - "description": "Dimensionality of the embedding vectors.", - "examples": [1536] + "syncIntervalMinutes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Scheduled synchronization interval in minutes; zero disables scheduled syncs." }, - "chunkingConfig": { - "$ref": "#/components/schemas/V2KnowledgeChunkingConfig" + "status": { + "type": "string", + "enum": ["active", "paused", "syncing", "error", "disabled"], + "description": "Current connector state." }, - "docCount": { - "description": "Number of documents in the knowledge base.", - "examples": [12], - "type": "number" + "lastSyncAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Time of the most recent synchronization, or null before the first sync." }, - "connectorTypes": { - "description": "External connector types that have synced documents into the knowledge base.", - "examples": [["notion", "google_drive"]], - "type": "array", - "items": { - "type": "string" - } + "lastSyncError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Most recent synchronization error, or null when none is recorded." + }, + "lastSyncDocCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ], + "description": "Documents observed by the most recent synchronization." + }, + "nextSyncAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Next scheduled synchronization time, or null when not scheduled." + }, + "consecutiveFailures": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of consecutive synchronization failures." }, "createdAt": { "type": "string", - "description": "ISO 8601 timestamp when the knowledge base was created.", "format": "date-time", - "examples": ["2025-01-10T09:00:00Z"] + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the connector was created." }, "updatedAt": { "type": "string", - "description": "ISO 8601 timestamp when the knowledge base was last modified.", "format": "date-time", - "examples": ["2025-06-18T16:45:00Z"] - }, - "ownerEmail": { - "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Current email address of the knowledge base owner.", - "examples": ["owner@example.com"] - }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Product"] + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the connector was last updated." } }, "required": [ "id", - "name", - "description", - "tokenCount", - "embeddingModel", - "embeddingDimension", - "chunkingConfig", + "knowledgeBaseId", + "connectorType", + "credentialId", + "sourceConfig", + "syncMode", + "syncIntervalMinutes", + "status", + "lastSyncAt", + "lastSyncError", + "lastSyncDocCount", + "nextSyncAt", + "consecutiveFailures", "createdAt", - "updatedAt", - "ownerEmail", - "folderPath" + "updatedAt" ], "additionalProperties": false, - "title": "Knowledge base", - "description": "A collection of documents indexed for vector and tag search." - }, - "V2KnowledgeChunkingConfig": { - "type": "object", - "properties": { - "maxSize": { - "type": "number", - "description": "Maximum chunk size in tokens.", - "examples": [1024] - }, - "minSize": { - "type": "number", - "description": "Minimum chunk size in characters.", - "examples": [100] - }, - "overlap": { - "type": "number", - "description": "Number of overlapping characters between adjacent chunks.", - "examples": [200] - }, - "strategy": { - "description": "Chunking strategy applied during document processing.", - "type": "string", - "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] - }, - "strategyOptions": { - "description": "Strategy-specific tuning options.", - "type": "object", - "properties": { - "pattern": { - "description": "Regular expression used by the regex chunking strategy.", - "type": "string", - "maxLength": 500 - }, - "separators": { - "description": "Ordered separators used to split content into chunks.", - "type": "array", - "items": { - "type": "string" - } - }, - "recipe": { - "description": "Content-aware recipe used by the automatic chunking strategy.", - "type": "string", - "enum": ["plain", "markdown", "code"] - }, - "strictBoundaries": { - "description": "Whether regex matches must form strict chunk boundaries.", - "type": "boolean" - } - }, - "additionalProperties": false - } - }, - "required": ["maxSize", "minSize", "overlap"], - "additionalProperties": { - "description": "Additional forward-compatible chunking configuration property." - }, - "title": "Knowledge chunking configuration", - "description": "How documents in a knowledge base are split into chunks before embedding." + "title": "Knowledge connector", + "description": "An external document source linked to a knowledge base, without secret material." }, - "V2KnowledgeBaseListResponse": { + "V2KnowledgeConnectorListResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2KnowledgeBase" + "$ref": "#/components/schemas/V2KnowledgeConnector" }, "description": "Items in the current page." }, @@ -3243,163 +5085,229 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Knowledge base list response", - "description": "A cursor-paginated page of knowledge bases." - }, - "V2KnowledgeBaseResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeBase" + "title": "Knowledge connector list response", + "description": "A cursor-paginated page of connectors without secret material.", + "examples": [ + { + "data": [ + { + "id": "kc-9f8e7d6c", + "knowledgeBaseId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "connectorType": "notion", + "credentialId": "cred-4b3a2c1d", + "sourceConfig": { + "pageIds": ["page-123"] + }, + "syncMode": "full", + "syncIntervalMinutes": 1440, + "status": "active", + "lastSyncAt": "2026-06-20T14:02:11.000Z", + "lastSyncError": null, + "lastSyncDocCount": 42, + "nextSyncAt": "2026-06-21T14:02:11.000Z", + "consecutiveFailures": 0, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Knowledge base response", - "description": "A single knowledge base." + ] }, - "V2KnowledgeChunkingConfigInput": { + "V2KnowledgeConnectorResponse": { "type": "object", "properties": { - "maxSize": { - "default": 1024, - "description": "Maximum chunk size in tokens.", - "examples": [1024], - "type": "number", - "minimum": 100, - "maximum": 4000 - }, - "minSize": { - "default": 100, - "description": "Minimum chunk size in characters.", - "examples": [100], - "type": "number", - "minimum": 1, - "maximum": 2000 - }, - "overlap": { - "default": 200, - "description": "Number of overlapping characters between adjacent chunks.", - "examples": [200], - "type": "number", - "minimum": 0, - "maximum": 500 + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnector" } }, - "title": "Knowledge chunking configuration input", - "description": "Chunking configuration applied when processing documents." + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge connector response", + "description": "A single connector without secret material.", + "examples": [ + { + "data": { + "id": "kc-9f8e7d6c", + "knowledgeBaseId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "connectorType": "notion", + "credentialId": "cred-4b3a2c1d", + "sourceConfig": { + "pageIds": ["page-123"] + }, + "syncMode": "full", + "syncIntervalMinutes": 1440, + "status": "active", + "lastSyncAt": "2026-06-20T14:02:11.000Z", + "lastSyncError": null, + "lastSyncDocCount": 42, + "nextSyncAt": "2026-06-21T14:02:11.000Z", + "consecutiveFailures": 0, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] }, - "CreateKnowledgeBaseRequest": { + "CreateKnowledgeConnectorRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the knowledge base." + "description": "Workspace that owns the knowledge base." }, - "name": { + "connectorType": { "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Human-readable knowledge base name.", - "examples": ["Product Documentation"] + "maxLength": 100, + "description": "Registered connector type." }, - "description": { - "description": "Optional knowledge base description.", - "examples": ["All product documentation and guides"], + "credentialId": { + "description": "OAuth credential identifier for connectors that require OAuth.", "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "apiKey": { + "description": "Write-only API key for connectors that use API-key authentication.", + "type": "string", + "minLength": 1, "maxLength": 10000 }, - "chunkingConfig": { - "default": { - "maxSize": 1024, - "minSize": 100, - "overlap": 200 + "sourceConfig": { + "type": "object", + "propertyNames": { + "type": "string" }, - "description": "Chunking configuration; defaults are applied when omitted.", - "$ref": "#/components/schemas/V2KnowledgeChunkingConfigInput" + "additionalProperties": { + "description": "Connector-specific source configuration value." + }, + "description": "Connector-specific source selection and filtering configuration." }, - "folderPath": { - "description": "Containing folder path; omission creates the knowledge base at the root.", - "$ref": "#/components/schemas/FolderPathInput" + "syncIntervalMinutes": { + "default": 1440, + "description": "Scheduled synchronization interval in minutes; zero disables scheduling.", + "type": "integer", + "minimum": 0, + "maximum": 525600 } }, - "required": ["workspaceId", "name"], + "required": ["workspaceId", "connectorType", "sourceConfig"], "additionalProperties": false, - "title": "Create knowledge base request", - "description": "Workspace, name, description, chunking configuration, and folder placement." + "title": "Create knowledge connector request", + "description": "Workspace, connector type, authentication reference, source configuration, and sync schedule.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "connectorType": "notion", + "credentialId": "cred-4b3a2c1d", + "sourceConfig": { + "pageIds": ["page-123"] + }, + "syncIntervalMinutes": 1440 + } + ] }, - "UpdateKnowledgeBaseRequest": { + "V2KnowledgeConnectorSyncLog": { "type": "object", "properties": { - "workspaceId": { + "id": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge base." + "description": "Unique synchronization log identifier." }, - "name": { - "description": "New knowledge base name.", - "examples": ["Updated Product Documentation"], + "connectorId": { "type": "string", "minLength": 1, - "maxLength": 255 + "description": "Connector that produced the log." }, - "description": { - "description": "New knowledge base description.", - "examples": ["Refreshed product documentation and guides"], + "status": { "type": "string", - "maxLength": 10000 - }, - "chunkingConfig": { - "description": "New document chunking configuration.", - "$ref": "#/components/schemas/V2KnowledgeChunkingConfigInput" + "minLength": 1, + "description": "Synchronization outcome or current state." }, - "folderPath": { - "description": "New containing-folder path.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update knowledge base request", - "description": "Workspace scope and fields to update. At least one mutable field is required." - }, - "V2KnowledgeDeleteData": { - "type": "object", - "properties": { - "id": { + "startedAt": { "type": "string", - "description": "Identifier of the deleted resource.", - "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time synchronization started." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the resource was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Knowledge deletion data", - "description": "Acknowledgement for a deleted knowledge base or document." - }, - "V2KnowledgeDeleteResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeDeleteData" + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Time synchronization completed, or null while it is running." + }, + "docsAdded": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents added." + }, + "docsUpdated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents updated." + }, + "docsDeleted": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents deleted." + }, + "docsUnchanged": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents unchanged." + }, + "docsFailed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents that failed to synchronize." + }, + "errorMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Synchronization error, or null." } }, - "required": ["data"], + "required": [ + "id", + "connectorId", + "status", + "startedAt", + "completedAt", + "docsAdded", + "docsUpdated", + "docsDeleted", + "docsUnchanged", + "docsFailed", + "errorMessage" + ], "additionalProperties": false, - "title": "Knowledge deletion response", - "description": "Deletion acknowledgement containing the removed resource identifier." + "title": "Knowledge connector sync log", + "description": "One synchronization attempt for a knowledge connector." }, - "V2KnowledgeConnector": { + "V2KnowledgeConnectorDetail": { "type": "object", "properties": { "id": { @@ -3520,6 +5428,13 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "description": "Time the connector was last updated." + }, + "syncLogs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeConnectorSyncLog" + }, + "description": "The ten most recent synchronization attempts." } }, "required": [ @@ -3537,77 +5452,25 @@ "nextSyncAt", "consecutiveFailures", "createdAt", - "updatedAt" + "updatedAt", + "syncLogs" ], "additionalProperties": false, - "title": "Knowledge connector", - "description": "An external document source linked to a knowledge base, without secret material." - }, - "V2KnowledgeConnectorListResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2KnowledgeConnector" - }, - "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 is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Knowledge connector list response", - "description": "A cursor-paginated page of connectors without secret material.", - "examples": [ - { - "data": [ - { - "id": "kc-9f8e7d6c", - "knowledgeBaseId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "connectorType": "notion", - "credentialId": "cred-4b3a2c1d", - "sourceConfig": { - "pageIds": ["page-123"] - }, - "syncMode": "full", - "syncIntervalMinutes": 1440, - "status": "active", - "lastSyncAt": "2026-06-20T14:02:11.000Z", - "lastSyncError": null, - "lastSyncDocCount": 42, - "nextSyncAt": "2026-06-21T14:02:11.000Z", - "consecutiveFailures": 0, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "nextCursor": null - } - ] + "title": "Knowledge connector detail", + "description": "A knowledge connector and its recent synchronization history." }, - "V2KnowledgeConnectorResponse": { + "V2KnowledgeConnectorDetailResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeConnector" + "$ref": "#/components/schemas/V2KnowledgeConnectorDetail" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge connector response", - "description": "A single connector without secret material.", + "title": "Knowledge connector detail response", + "description": "A connector and recent synchronization history without secret material.", "examples": [ { "data": { @@ -3627,12 +5490,13 @@ "nextSyncAt": "2026-06-21T14:02:11.000Z", "consecutiveFailures": 0, "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "syncLogs": [] } } ] }, - "CreateKnowledgeConnectorRequest": { + "UpdateKnowledgeConnectorRequest": { "type": "object", "properties": { "workspaceId": { @@ -3641,126 +5505,184 @@ "maxLength": 128, "description": "Workspace that owns the knowledge base." }, - "connectorType": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Registered connector type." - }, - "credentialId": { - "description": "OAuth credential identifier for connectors that require OAuth.", - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "apiKey": { - "description": "Write-only API key for connectors that use API-key authentication.", - "type": "string", - "minLength": 1, - "maxLength": 10000 - }, "sourceConfig": { + "description": "Replacement source selection and filtering configuration.", "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "description": "Connector-specific source configuration value." - }, - "description": "Connector-specific source selection and filtering configuration." + } }, "syncIntervalMinutes": { - "default": 1440, - "description": "Scheduled synchronization interval in minutes; zero disables scheduling.", + "description": "New scheduled synchronization interval in minutes.", "type": "integer", "minimum": 0, "maximum": 525600 + }, + "status": { + "description": "New connector state.", + "type": "string", + "enum": ["active", "paused"] } }, - "required": ["workspaceId", "connectorType", "sourceConfig"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Create knowledge connector request", - "description": "Workspace, connector type, authentication reference, source configuration, and sync schedule.", + "title": "Update knowledge connector request", + "description": "Workspace scope and at least one mutable connector field.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "connectorType": "notion", - "credentialId": "cred-4b3a2c1d", - "sourceConfig": { - "pageIds": ["page-123"] - }, - "syncIntervalMinutes": 1440 + "status": "paused" } ] }, - "V2KnowledgeConnectorSyncLog": { + "V2KnowledgeConnectorDeleteData": { "type": "object", "properties": { "id": { "type": "string", "minLength": 1, - "description": "Unique synchronization log identifier." + "description": "Deleted connector identifier." }, - "connectorId": { + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the connector was deleted." + }, + "documentsDeleted": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Connector documents deleted." + }, + "documentsKept": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Connector documents retained." + } + }, + "required": ["id", "deleted", "documentsDeleted", "documentsKept"], + "additionalProperties": false, + "title": "Knowledge connector deletion data", + "description": "Connector deletion acknowledgement and affected document counts." + }, + "V2KnowledgeConnectorDeleteResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnectorDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge connector delete response", + "description": "Deletion acknowledgement and affected document counts.", + "examples": [ + { + "data": { + "id": "kc-9f8e7d6c", + "deleted": true, + "documentsDeleted": 0, + "documentsKept": 42 + } + } + ] + }, + "V2KnowledgeConnectorSyncData": { + "type": "object", + "properties": { + "id": { "type": "string", "minLength": 1, - "description": "Connector that produced the log." + "description": "Connector queued for synchronization." }, - "status": { + "syncTriggered": { + "type": "boolean", + "const": true, + "description": "Whether synchronization was queued." + } + }, + "required": ["id", "syncTriggered"], + "additionalProperties": false, + "title": "Knowledge connector sync data", + "description": "Acknowledgement that connector synchronization was queued." + }, + "V2KnowledgeConnectorSyncResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnectorSyncData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge connector sync response", + "description": "Acknowledgement that synchronization was queued.", + "examples": [ + { + "data": { + "id": "kc-9f8e7d6c", + "syncTriggered": true + } + } + ] + }, + "SyncKnowledgeConnectorRequest": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", "minLength": 1, - "description": "Synchronization outcome or current state." + "maxLength": 128, + "description": "Workspace that owns the knowledge base." }, - "startedAt": { + "rehydrate": { + "default": false, + "description": "Re-fetch and re-index every existing connector document.", + "type": "boolean" + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Sync knowledge connector request", + "description": "Workspace scope and optional full rehydration control.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "rehydrate": false + } + ] + }, + "V2KnowledgeConnectorDocument": { + "type": "object", + "properties": { + "id": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time synchronization started." + "minLength": 1, + "description": "Unique document identifier." }, - "completedAt": { + "filename": { + "type": "string", + "minLength": 1, + "description": "Document filename." + }, + "externalId": { "anyOf": [ { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "type": "string" }, { "type": "null" } ], - "description": "Time synchronization completed, or null while it is running." - }, - "docsAdded": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Documents added." - }, - "docsUpdated": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Documents updated." - }, - "docsDeleted": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Documents deleted." - }, - "docsUnchanged": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Documents unchanged." - }, - "docsFailed": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Documents that failed to synchronize." + "description": "Identifier assigned by the external source." }, - "errorMessage": { + "sourceUrl": { "anyOf": [ { "type": "string" @@ -3769,45 +5691,52 @@ "type": "null" } ], - "description": "Synchronization error, or null." + "description": "Original external source URL." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for knowledge search." + }, + "userExcluded": { + "type": "boolean", + "description": "Whether a user explicitly excluded the document from connector sync results." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the document was first synchronized." + }, + "processingStatus": { + "type": "string", + "description": "Current document processing state." } }, "required": [ "id", - "connectorId", - "status", - "startedAt", - "completedAt", - "docsAdded", - "docsUpdated", - "docsDeleted", - "docsUnchanged", - "docsFailed", - "errorMessage" + "filename", + "externalId", + "sourceUrl", + "enabled", + "userExcluded", + "createdAt", + "processingStatus" ], "additionalProperties": false, - "title": "Knowledge connector sync log", - "description": "One synchronization attempt for a knowledge connector." + "title": "Knowledge connector document", + "description": "A knowledge document produced by an external connector." }, - "V2KnowledgeConnectorDetail": { + "V2KnowledgeConnectorDocumentListResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Unique connector identifier." - }, - "knowledgeBaseId": { - "type": "string", - "minLength": 1, - "description": "Knowledge base synced by the connector." - }, - "connectorType": { - "type": "string", - "minLength": 1, - "description": "Registered external source type." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeConnectorDocument" + }, + "description": "Items in the current page." }, - "credentialId": { + "nextCursor": { "anyOf": [ { "type": "string" @@ -3816,344 +5745,534 @@ "type": "null" } ], - "description": "OAuth credential identifier, or null for API-key and unauthenticated sources." - }, - "sourceConfig": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Connector-specific source configuration value." - }, - "description": "Connector-specific source selection and filtering configuration." - }, - "syncMode": { + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Knowledge connector document list response", + "description": "A cursor-paginated page of connector documents.", + "examples": [ + { + "data": [ + { + "id": "doc-8a7b6c5d", + "filename": "Product requirements", + "externalId": "page-123", + "sourceUrl": "https://www.notion.so/page-123", + "enabled": true, + "userExcluded": false, + "createdAt": "2026-06-01T09:15:00.000Z", + "processingStatus": "completed" + } + ], + "nextCursor": null + } + ] + }, + "V2KnowledgeConnectorDocumentsUpdateData": { + "type": "object", + "properties": { + "operation": { "type": "string", - "description": "Synchronization mode used by the connector." + "enum": ["restore", "exclude"], + "description": "Operation that was applied." }, - "syncIntervalMinutes": { + "updatedCount": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Scheduled synchronization interval in minutes; zero disables scheduled syncs." + "description": "Documents changed." }, - "status": { + "documentIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of documents changed." + } + }, + "required": ["operation", "updatedCount", "documentIds"], + "additionalProperties": false, + "title": "Knowledge connector documents update data", + "description": "Outcome of restoring or excluding connector documents." + }, + "V2KnowledgeConnectorDocumentsUpdateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnectorDocumentsUpdateData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge connector documents update response", + "description": "Operation result and identifiers actually changed.", + "examples": [ + { + "data": { + "operation": "exclude", + "updatedCount": 1, + "documentIds": ["doc-8a7b6c5d"] + } + } + ] + }, + "UpdateKnowledgeConnectorDocumentsRequest": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", - "enum": ["active", "paused", "syncing", "error", "disabled"], - "description": "Current connector state." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." }, - "lastSyncAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ], - "description": "Time of the most recent synchronization, or null before the first sync." + "operation": { + "type": "string", + "enum": ["restore", "exclude"], + "description": "Whether to restore or exclude the selected documents." }, - "lastSyncError": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Most recent synchronization error, or null when none is recorded." + "documentIds": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": "Connector document identifiers to update." + } + }, + "required": ["workspaceId", "operation", "documentIds"], + "additionalProperties": false, + "title": "Update knowledge connector documents request", + "description": "Workspace, restore or exclude operation, and selected document identifiers.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "operation": "exclude", + "documentIds": ["doc-8a7b6c5d"] + } + ] + }, + "V2KnowledgeSearchResult": { + "type": "object", + "properties": { + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base the matching chunk came from; a search may span up to 20.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] }, - "lastSyncDocCount": { + "documentId": { + "type": "string", + "description": "Identifier of the document containing the matching chunk.", + "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] + }, + "documentName": { "anyOf": [ { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": "string" }, { "type": "null" } ], - "description": "Documents observed by the most recent synchronization." + "description": "Filename of the source document, or null when unavailable.", + "examples": ["getting-started.pdf"] }, - "nextSyncAt": { + "sourceUrl": { "anyOf": [ { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "type": "string" }, { "type": "null" } ], - "description": "Next scheduled synchronization time, or null when not scheduled." + "description": "Original source URL, or null for a directly uploaded document." }, - "consecutiveFailures": { + "content": { + "type": "string", + "description": "Text content of the matching chunk.", + "examples": ["To reset your password, open Settings and choose Security."] + }, + "chunkIndex": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Number of consecutive synchronization failures." + "description": "Zero-based chunk index within the document.", + "examples": [3] }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the connector was created." + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "User-defined string, number, boolean, or date tag value." + }, + "description": "Document tag values keyed by tag display name.", + "examples": [ + { + "category": "billing", + "priority": 2 + } + ] }, - "updatedAt": { + "similarity": { + "type": "number", + "description": "Similarity score for vector search; tag-only matches use 1.", + "examples": [0.8423] + }, + "rerankerScore": { + "description": "Relevance score assigned by the reranker, present only on results a reranker ordered. Results are ordered by this score when it is present, which is why it can disagree with `similarity`.", + "examples": [0.9312], + "type": "number" + } + }, + "required": [ + "knowledgeBaseId", + "documentId", + "documentName", + "sourceUrl", + "content", + "chunkIndex", + "metadata", + "similarity" + ], + "additionalProperties": false, + "title": "Knowledge search result", + "description": "A matching document chunk returned by knowledge search." + }, + "V2KnowledgeSearchData": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeSearchResult" + }, + "description": "Matching chunks ordered by relevance." + }, + "query": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the connector was last updated." + "description": "Executed query, or an empty string for tag-only search.", + "examples": ["How do I reset my password?"] }, - "syncLogs": { + "knowledgeBaseIds": { "type": "array", "items": { - "$ref": "#/components/schemas/V2KnowledgeConnectorSyncLog" + "type": "string" }, - "description": "The ten most recent synchronization attempts." + "description": "Knowledge base identifiers that were searched.", + "examples": [["7c9e6679-7425-40de-944b-e07fc1f90ae7"]] + }, + "topK": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Maximum number of results requested.", + "examples": [10] + }, + "totalResults": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of results returned.", + "examples": [4] + }, + "rerankerStatus": { + "type": "string", + "enum": ["not_requested", "skipped", "unavailable", "applied"], + "description": "What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means it was attempted but could not complete, so results are in vector order with no `rerankerScore` — the search still succeeded, and is worth retrying. `skipped` means there was nothing to rank. `not_requested` means `rerankerEnabled` was absent or false.", + "examples": ["applied"] } }, "required": [ - "id", - "knowledgeBaseId", - "connectorType", - "credentialId", - "sourceConfig", - "syncMode", - "syncIntervalMinutes", - "status", - "lastSyncAt", - "lastSyncError", - "lastSyncDocCount", - "nextSyncAt", - "consecutiveFailures", - "createdAt", - "updatedAt", - "syncLogs" + "results", + "query", + "knowledgeBaseIds", + "topK", + "totalResults", + "rerankerStatus" ], "additionalProperties": false, - "title": "Knowledge connector detail", - "description": "A knowledge connector and its recent synchronization history." + "title": "Knowledge search data", + "description": "Results and execution context for a knowledge search." }, - "V2KnowledgeConnectorDetailResponse": { + "V2KnowledgeSearchResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeConnectorDetail" + "$ref": "#/components/schemas/V2KnowledgeSearchData" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge connector detail response", - "description": "A connector and recent synchronization history without secret material.", - "examples": [ - { - "data": { - "id": "kc-9f8e7d6c", - "knowledgeBaseId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "connectorType": "notion", - "credentialId": "cred-4b3a2c1d", - "sourceConfig": { - "pageIds": ["page-123"] - }, - "syncMode": "full", - "syncIntervalMinutes": 1440, - "status": "active", - "lastSyncAt": "2026-06-20T14:02:11.000Z", - "lastSyncError": null, - "lastSyncDocCount": 42, - "nextSyncAt": "2026-06-21T14:02:11.000Z", - "consecutiveFailures": 0, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "syncLogs": [] - } - } - ] + "title": "Knowledge search response", + "description": "Matching chunks and search execution context." }, - "UpdateKnowledgeConnectorRequest": { + "V2KnowledgeSearchTagFilter": { "type": "object", "properties": { - "workspaceId": { + "tagName": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge base." - }, - "sourceConfig": { - "description": "Replacement source selection and filtering configuration.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Connector-specific source configuration value." - } + "description": "Display name of the tag to filter.", + "examples": ["category"] }, - "syncIntervalMinutes": { - "description": "New scheduled synchronization interval in minutes.", - "type": "integer", - "minimum": 0, - "maximum": 525600 + "fieldType": { + "description": "Tag field type.", + "type": "string", + "enum": ["text", "number", "date", "boolean"] }, - "status": { - "description": "New connector state.", + "operator": { + "default": "eq", + "description": "Comparison operator; valid operators depend on the field type. Text tags accept eq, neq, contains, not_contains, starts_with, ends_with; number and date tags accept eq, neq, gt, gte, lt, lte, between; boolean tags accept eq, neq. An operator the tag's field type does not implement is rejected, never ignored.", + "examples": ["eq"], "type": "string", - "enum": ["active", "paused"] - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update knowledge connector request", - "description": "Workspace scope and at least one mutable connector field.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "status": "paused" + "enum": [ + "eq", + "neq", + "contains", + "not_contains", + "starts_with", + "ends_with", + "gt", + "gte", + "lt", + "lte", + "between" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "description": "Tag value to compare against.", + "examples": ["billing"] + }, + "valueTo": { + "description": "Upper bound for the `between` operator, and required whenever that operator is used.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] } - ] + }, + "required": ["tagName", "value"], + "additionalProperties": false, + "title": "Knowledge search tag filter", + "description": "A structured tag filter applied to knowledge search." }, - "V2KnowledgeConnectorDeleteData": { + "SearchKnowledgeRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", "minLength": 1, - "description": "Deleted connector identifier." + "maxLength": 128, + "description": "Workspace that owns the knowledge bases." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the connector was deleted." + "knowledgeBaseIds": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ], + "description": "One knowledge base identifier or an array of up to 20 identifiers.", + "examples": [["7c9e6679-7425-40de-944b-e07fc1f90ae7"]] }, - "documentsDeleted": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Connector documents deleted." + "query": { + "description": "Natural-language query; required when tag filters are omitted. At most 32768 characters — longer text exceeds the embedding model's per-input token ceiling and would be truncated before the billed search ran.", + "examples": ["How do I reset my password?"], + "type": "string", + "maxLength": 32768 }, - "documentsKept": { + "topK": { + "default": 10, + "description": "Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search.", + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "tagFilters": { + "description": "Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.", + "maxItems": 10, + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeSearchTagFilter" + } + }, + "searchMode": { + "description": "Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.", + "default": "vector", + "anyOf": [ + { + "type": "string", + "enum": ["vector", "hybrid"] + }, + { + "type": "null" + } + ] + }, + "rerankerEnabled": { + "description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response.", + "type": "boolean" + }, + "rerankerModel": { + "default": "rerank-v4.0-fast", + "description": "Reranking model to use when `rerankerEnabled` is true. Defaults to `rerank-v4.0-fast`.", + "type": "string", + "enum": ["rerank-v4.0-pro", "rerank-v4.0-fast", "rerank-v3.5"] + }, + "rerankerInputCount": { + "description": "How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from.", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Connector documents retained." - } - }, - "required": ["id", "deleted", "documentsDeleted", "documentsKept"], - "additionalProperties": false, - "title": "Knowledge connector deletion data", - "description": "Connector deletion acknowledgement and affected document counts." - }, - "V2KnowledgeConnectorDeleteResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeConnectorDeleteData" + "minimum": 1, + "maximum": 100 } }, - "required": ["data"], + "required": ["workspaceId", "knowledgeBaseIds"], "additionalProperties": false, - "title": "Knowledge connector delete response", - "description": "Deletion acknowledgement and affected document counts.", - "examples": [ - { - "data": { - "id": "kc-9f8e7d6c", - "deleted": true, - "documentsDeleted": 0, - "documentsKept": 42 - } - } - ] + "title": "Search knowledge request", + "description": "Knowledge bases, query, result limit, retrieval mode, and optional tag filters." }, - "V2KnowledgeConnectorSyncData": { + "V2KnowledgeTag": { "type": "object", "properties": { "id": { "type": "string", - "minLength": 1, - "description": "Connector queued for synchronization." + "description": "Tag definition identifier. Published because `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by it; without it those operations are unreachable from a list read." }, - "syncTriggered": { - "type": "boolean", - "const": true, - "description": "Whether synchronization was queued." + "displayName": { + "type": "string", + "description": "Display name used by tag filters and by tag values on document reads.", + "examples": ["category"] + }, + "tagSlot": { + "type": "string", + "description": "Storage slot the tag occupies. Document writes set tag values by slot (`tag1`..`tag7`).", + "examples": ["tag1"] + }, + "fieldType": { + "type": "string", + "description": "Value type stored in the slot; it determines the valid filter operators.", + "examples": ["text"] } }, - "required": ["id", "syncTriggered"], + "required": ["id", "displayName", "tagSlot", "fieldType"], "additionalProperties": false, - "title": "Knowledge connector sync data", - "description": "Acknowledgement that connector synchronization was queued." + "title": "Knowledge tag", + "description": "A tag defined on a knowledge base, and the slot it is stored in." }, - "V2KnowledgeConnectorSyncResponse": { + "V2KnowledgeTagListResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeConnectorSyncData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Knowledge connector sync response", - "description": "Acknowledgement that synchronization was queued.", - "examples": [ - { - "data": { - "id": "kc-9f8e7d6c", - "syncTriggered": true - } - } - ] - }, - "SyncKnowledgeConnectorRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge base." + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeTag" + }, + "description": "Items in the current page." }, - "rehydrate": { - "default": false, - "description": "Re-fetch and re-index every existing connector document.", - "type": "boolean" + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, - "required": ["workspaceId"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Sync knowledge connector request", - "description": "Workspace scope and optional full rehydration control.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "rehydrate": false - } - ] + "title": "Knowledge tag list response", + "description": "The full tag vocabulary of one knowledge base." }, - "V2KnowledgeConnectorDocument": { + "V2KnowledgeTaggedDocument": { "type": "object", "properties": { "id": { "type": "string", - "minLength": 1, - "description": "Unique document identifier." + "description": "Unique document identifier.", + "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base to which the document belongs.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] }, "filename": { "type": "string", - "minLength": 1, - "description": "Document filename." + "description": "Original filename of the uploaded document.", + "examples": ["getting-started.pdf"] + }, + "fileSize": { + "type": "number", + "description": "File size in bytes.", + "examples": [248913] + }, + "mimeType": { + "type": "string", + "description": "MIME type of the document file.", + "examples": ["application/pdf"] + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current document processing state.", + "examples": ["completed"] + }, + "chunkCount": { + "type": "number", + "description": "Number of indexed chunks; zero until processing completes.", + "examples": [24] + }, + "tokenCount": { + "type": "number", + "description": "Total tokens extracted from the document.", + "examples": [8123] + }, + "characterCount": { + "type": "number", + "description": "Total characters extracted from the document.", + "examples": [41205] + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "examples": [true] }, - "externalId": { + "createdAt": { "anyOf": [ { "type": "string" @@ -4162,59 +6281,66 @@ "type": "null" } ], - "description": "Identifier assigned by the external source." + "description": "ISO 8601 timestamp when the document was uploaded, or null.", + "format": "date-time", + "examples": ["2025-06-18T16:45:00Z"] }, - "sourceUrl": { - "anyOf": [ - { - "type": "string" - }, + "tags": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Tag value; dates are ISO 8601 strings and an unset tag is null." + }, + "description": "Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{knowledgeBaseId}/tags.", + "examples": [ { - "type": "null" + "category": "billing", + "priority": 2 } - ], - "description": "Original external source URL." - }, - "enabled": { - "type": "boolean", - "description": "Whether the document is enabled for knowledge search." - }, - "userExcluded": { - "type": "boolean", - "description": "Whether a user explicitly excluded the document from connector sync results." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the document was first synchronized." - }, - "processingStatus": { - "type": "string", - "description": "Current document processing state." + ] } }, "required": [ "id", + "knowledgeBaseId", "filename", - "externalId", - "sourceUrl", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", "enabled", - "userExcluded", "createdAt", - "processingStatus" + "tags" ], "additionalProperties": false, - "title": "Knowledge connector document", - "description": "A knowledge document produced by an external connector." + "title": "Knowledge document list item", + "description": "Document summary with the document tag values keyed by display name." }, - "V2KnowledgeConnectorDocumentListResponse": { + "V2KnowledgeDocumentListResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2KnowledgeConnectorDocument" + "$ref": "#/components/schemas/V2KnowledgeTaggedDocument" }, "description": "Items in the current page." }, @@ -4232,127 +6358,259 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Knowledge connector document list response", - "description": "A cursor-paginated page of connector documents.", - "examples": [ - { - "data": [ - { - "id": "doc-8a7b6c5d", - "filename": "Product requirements", - "externalId": "page-123", - "sourceUrl": "https://www.notion.so/page-123", - "enabled": true, - "userExcluded": false, - "createdAt": "2026-06-01T09:15:00.000Z", - "processingStatus": "completed" - } - ], - "nextCursor": null - } - ] + "title": "Knowledge document list response", + "description": "A cursor-paginated page of knowledge documents." }, - "V2KnowledgeConnectorDocumentsUpdateData": { + "V2BulkKnowledgeDocumentsData": { "type": "object", "properties": { "operation": { "type": "string", - "enum": ["restore", "exclude"], + "enum": ["enable", "disable"], "description": "Operation that was applied." }, "updatedCount": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Documents changed." + "description": "Number of documents the operation changed.", + "examples": [42] }, "documentIds": { + "description": "Identifiers of the documents the operation changed. Present only for an explicit `documentIds` request, which is bounded to 100 documents; a `selectAll` request omits it because the selection is unbounded, and reports `updatedCount` instead.", "type": "array", "items": { "type": "string" - }, - "description": "Identifiers of documents changed." + } } }, - "required": ["operation", "updatedCount", "documentIds"], + "required": ["operation", "updatedCount"], + "additionalProperties": false, + "title": "Bulk knowledge document update data", + "description": "Outcome of a bulk enable or disable across knowledge documents." + }, + "V2BulkKnowledgeDocumentsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2BulkKnowledgeDocumentsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Bulk knowledge document response", + "description": "Outcome of a bulk enable or disable." + }, + "BulkUpdateKnowledgeDocumentsRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + }, + "operation": { + "type": "string", + "enum": ["enable", "disable"], + "description": "Whether the selected documents become enabled or disabled for search." + }, + "documentIds": { + "description": "Documents to update, by identifier.", + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "selectAll": { + "description": "Update every document in the knowledge base instead of an explicit list, narrowed by `enabledFilter`.", + "type": "boolean", + "const": true + }, + "enabledFilter": { + "description": "With `selectAll`, restrict the update to documents in this state.", + "type": "string", + "enum": ["all", "enabled", "disabled"] + } + }, + "required": ["workspaceId", "operation"], + "additionalProperties": false, + "title": "Bulk knowledge document request", + "description": "Operation and the documents it applies to.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "operation": "disable", + "documentIds": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] + } + ] + }, + "V2KnowledgeDocumentSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base to which the document belongs.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "examples": ["getting-started.pdf"] + }, + "fileSize": { + "type": "number", + "description": "File size in bytes.", + "examples": [248913] + }, + "mimeType": { + "type": "string", + "description": "MIME type of the document file.", + "examples": ["application/pdf"] + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current document processing state.", + "examples": ["completed"] + }, + "chunkCount": { + "type": "number", + "description": "Number of indexed chunks; zero until processing completes.", + "examples": [24] + }, + "tokenCount": { + "type": "number", + "description": "Total tokens extracted from the document.", + "examples": [8123] + }, + "characterCount": { + "type": "number", + "description": "Total characters extracted from the document.", + "examples": [41205] + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "examples": [true] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the document was uploaded, or null.", + "format": "date-time", + "examples": ["2025-06-18T16:45:00Z"] + } + }, + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt" + ], "additionalProperties": false, - "title": "Knowledge connector documents update data", - "description": "Outcome of restoring or excluding connector documents." + "title": "Knowledge document summary", + "description": "Summary returned by document lists and upload acknowledgements." }, - "V2KnowledgeConnectorDocumentsUpdateResponse": { + "V2KnowledgeDocumentSummaryResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeConnectorDocumentsUpdateData" + "$ref": "#/components/schemas/V2KnowledgeDocumentSummary" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge connector documents update response", - "description": "Operation result and identifiers actually changed.", - "examples": [ - { - "data": { - "operation": "exclude", - "updatedCount": 1, - "documentIds": ["doc-8a7b6c5d"] - } - } - ] + "title": "Knowledge document summary response", + "description": "An accepted knowledge document summary." }, - "UpdateKnowledgeConnectorDocumentsRequest": { + "UploadKnowledgeDocumentForm": { "type": "object", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge base." - }, - "operation": { + "file": { "type": "string", - "enum": ["restore", "exclude"], - "description": "Whether to restore or exclude the selected documents." - }, - "documentIds": { - "minItems": 1, - "maxItems": 100, - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "description": "Connector document identifiers to update." + "format": "binary", + "contentEncoding": "binary", + "maxLength": 104857600, + "description": "Document file to upload; the maximum size is 100 MB." } }, - "required": ["workspaceId", "operation", "documentIds"], - "additionalProperties": false, - "title": "Update knowledge connector documents request", - "description": "Workspace, restore or exclude operation, and selected document identifiers.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "operation": "exclude", - "documentIds": ["doc-8a7b6c5d"] - } - ] + "required": ["file"], + "additionalProperties": { + "description": "Additional multipart form fields are ignored." + }, + "title": "Upload knowledge document form", + "description": "Multipart form containing the document file." }, - "V2KnowledgeSearchResult": { + "V2KnowledgeDocumentUpload": { "type": "object", "properties": { + "id": { + "type": "string", + "description": "Upload session identifier." + }, "knowledgeBaseId": { "type": "string", - "description": "Knowledge base the matching chunk came from; a search may span up to 20.", - "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + "description": "Knowledge base that will own the document." }, - "documentId": { + "status": { "type": "string", - "description": "Identifier of the document containing the matching chunk.", - "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] + "enum": [ + "uploading", + "completing", + "finalizing", + "completed", + "failed", + "aborting", + "aborted", + "expired" + ], + "description": "Current upload-session state." }, - "documentName": { + "name": { + "type": "string", + "description": "Filename recorded on the knowledge document." + }, + "contentType": { + "type": "string", + "description": "MIME type declared for the document." + }, + "size": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Exact file size in bytes." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 upload-session expiration time." + }, + "error": { "anyOf": [ { "type": "string" @@ -4361,342 +6619,342 @@ "type": "null" } ], - "description": "Filename of the source document, or null when unavailable.", - "examples": ["getting-started.pdf"] + "description": "Terminal upload error, or null when none occurred." }, - "sourceUrl": { + "document": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/V2KnowledgeDocumentSummary" }, { "type": "null" } ], - "description": "Original source URL, or null for a directly uploaded document." + "description": "Queued document after completion, or null before completion." + } + }, + "required": [ + "id", + "knowledgeBaseId", + "status", + "name", + "contentType", + "size", + "expiresAt", + "error", + "document" + ], + "additionalProperties": false, + "title": "Knowledge document upload", + "description": "State of a resumable knowledge-document upload session." + }, + "V2KnowledgeUploadTransfer": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2PutUploadTransfer" }, - "content": { + { + "$ref": "#/components/schemas/V2MultipartUploadTransfer" + } + ], + "description": "Provider transfer strategy for a knowledge document upload.", + "title": "Knowledge upload transfer" + }, + "V2PutUploadTransfer": { + "type": "object", + "properties": { + "method": { "type": "string", - "description": "Text content of the matching chunk.", - "examples": ["To reset your password, open Settings and choose Security."] + "const": "put", + "description": "Upload strategy discriminator." }, - "chunkIndex": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Zero-based chunk index within the document.", - "examples": [3] + "url": { + "type": "string", + "format": "uri", + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." }, - "metadata": { + "headers": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { - "description": "User-defined string, number, boolean, or date tag value." + "type": "string" }, - "description": "Document tag values keyed by tag display name.", - "examples": [ - { - "category": "billing", - "priority": 2 - } - ] - }, - "similarity": { - "type": "number", - "description": "Similarity score for vector search; tag-only matches use 1.", - "examples": [0.8423] + "description": "Headers that must be included with the upload request." }, - "rerankerScore": { - "description": "Relevance score assigned by the reranker, present only on results a reranker ordered. Results are ordered by this score when it is present, which is why it can disagree with `similarity`.", - "examples": [0.9312], - "type": "number" + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." } }, - "required": [ - "knowledgeBaseId", - "documentId", - "documentName", - "sourceUrl", - "content", - "chunkIndex", - "metadata", - "similarity" - ], + "required": ["method", "url", "headers", "expiresAt"], "additionalProperties": false, - "title": "Knowledge search result", - "description": "A matching document chunk returned by knowledge search." + "title": "Direct upload transfer", + "description": "Instructions for uploading bytes to one signed URL." }, - "V2KnowledgeSearchData": { + "V2MultipartUploadTransfer": { "type": "object", "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2KnowledgeSearchResult" - }, - "description": "Matching chunks ordered by relevance." - }, - "query": { + "method": { "type": "string", - "description": "Executed query, or an empty string for tag-only search.", - "examples": ["How do I reset my password?"] - }, - "knowledgeBaseIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Knowledge base identifiers that were searched.", - "examples": [["7c9e6679-7425-40de-944b-e07fc1f90ae7"]] + "const": "multipart", + "description": "Upload strategy discriminator." }, - "topK": { + "partSize": { "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991, - "description": "Maximum number of results requested.", - "examples": [10] + "description": "Required size of each non-final part in bytes." }, - "totalResults": { + "partCount": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of results returned.", - "examples": [4] + "exclusiveMinimum": 0, + "maximum": 640, + "description": "Total number of upload parts." + } + }, + "required": ["method", "partSize", "partCount"], + "additionalProperties": false, + "title": "Multipart upload transfer", + "description": "Instructions for splitting bytes into a multipart upload." + }, + "V2CreateKnowledgeDocumentUploadData": { + "type": "object", + "properties": { + "session": { + "$ref": "#/components/schemas/V2KnowledgeDocumentUpload" }, - "rerankerStatus": { + "uploadToken": { "type": "string", - "enum": ["not_requested", "skipped", "unavailable", "applied"], - "description": "What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means it was attempted but could not complete, so results are in vector order with no `rerankerScore` — the search still succeeded, and is worth retrying. `skipped` means there was nothing to rank. `not_requested` means `rerankerEnabled` was absent or false.", - "examples": ["applied"] + "minLength": 1, + "description": "Signed control token required by subsequent upload-session requests." + }, + "transfer": { + "$ref": "#/components/schemas/V2KnowledgeUploadTransfer" } }, - "required": [ - "results", - "query", - "knowledgeBaseIds", - "topK", - "totalResults", - "rerankerStatus" - ], + "required": ["session", "uploadToken", "transfer"], "additionalProperties": false, - "title": "Knowledge search data", - "description": "Results and execution context for a knowledge search." + "title": "Create knowledge document upload data", + "description": "Upload session, signed control token, and transfer instructions." }, - "V2KnowledgeSearchResponse": { + "V2CreateKnowledgeDocumentUploadResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeSearchData" + "$ref": "#/components/schemas/V2CreateKnowledgeDocumentUploadData" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge search response", - "description": "Matching chunks and search execution context." + "title": "Create knowledge document upload response", + "description": "Upload session, signed control token, and transfer instructions." }, - "V2KnowledgeSearchTagFilter": { + "CreateKnowledgeDocumentUploadRequest": { "type": "object", "properties": { - "tagName": { + "workspaceId": { "type": "string", - "description": "Display name of the tag to filter.", - "examples": ["category"] + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." }, - "fieldType": { - "description": "Tag field type.", + "name": { "type": "string", - "enum": ["text", "number", "date", "boolean"] + "minLength": 1, + "maxLength": 255, + "description": "Filename recorded on the knowledge document.", + "examples": ["getting-started.pdf"] }, - "operator": { - "default": "eq", - "description": "Comparison operator; valid operators depend on the field type. Text tags accept eq, neq, contains, not_contains, starts_with, ends_with; number and date tags accept eq, neq, gt, gte, lt, lte, between; boolean tags accept eq, neq. An operator the tag's field type does not implement is rejected, never ignored.", - "examples": ["eq"], + "contentType": { "type": "string", - "enum": [ - "eq", - "neq", - "contains", - "not_contains", - "starts_with", - "ends_with", - "gt", - "gte", - "lt", - "lte", - "between" - ] + "minLength": 1, + "maxLength": 255, + "description": "Supported MIME type for the document.", + "examples": ["application/pdf"] }, - "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "description": "Tag value to compare against.", - "examples": ["billing"] + "size": { + "type": "integer", + "minimum": 1, + "maximum": 104857600, + "description": "Exact file size in bytes.", + "examples": [248913] }, - "valueTo": { - "description": "Upper bound for the `between` operator, and required whenever that operator is used.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - } - }, - "required": ["tagName", "value"], - "additionalProperties": false, - "title": "Knowledge search tag filter", - "description": "A structured tag filter applied to knowledge search." - }, - "SearchKnowledgeRequest": { - "type": "object", - "properties": { - "workspaceId": { + "tag1": { + "description": "Value for tag slot 1.", "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge bases." + "maxLength": 1000 }, - "knowledgeBaseIds": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "minItems": 1, - "maxItems": 20, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - } - ], - "description": "One knowledge base identifier or an array of up to 20 identifiers.", - "examples": [["7c9e6679-7425-40de-944b-e07fc1f90ae7"]] + "tag2": { + "description": "Value for tag slot 2.", + "type": "string", + "maxLength": 1000 }, - "query": { - "description": "Natural-language query; required when tag filters are omitted. At most 32768 characters — longer text exceeds the embedding model's per-input token ceiling and would be truncated before the billed search ran.", - "examples": ["How do I reset my password?"], + "tag3": { + "description": "Value for tag slot 3.", "type": "string", - "maxLength": 32768 + "maxLength": 1000 }, - "topK": { - "default": 10, - "description": "Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search.", - "type": "number", - "minimum": 1, - "maximum": 100 + "tag4": { + "description": "Value for tag slot 4.", + "type": "string", + "maxLength": 1000 }, - "tagFilters": { - "description": "Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{id}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{id}/tags`.", - "maxItems": 10, - "type": "array", - "items": { - "$ref": "#/components/schemas/V2KnowledgeSearchTagFilter" - } + "tag5": { + "description": "Value for tag slot 5.", + "type": "string", + "maxLength": 1000 }, - "searchMode": { - "description": "Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.", - "default": "vector", - "anyOf": [ - { + "tag6": { + "description": "Value for tag slot 6.", + "type": "string", + "maxLength": 1000 + }, + "tag7": { + "description": "Value for tag slot 7.", + "type": "string", + "maxLength": 1000 + }, + "processingOptions": { + "description": "Optional processing recipe and language.", + "type": "object", + "properties": { + "recipe": { + "description": "Optional document processing recipe.", "type": "string", - "enum": ["vector", "hybrid"] + "maxLength": 255 }, - { - "type": "null" + "lang": { + "description": "Optional document language code.", + "type": "string", + "maxLength": 35 } - ] - }, - "rerankerEnabled": { - "description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response.", - "type": "boolean" + }, + "additionalProperties": false + } + }, + "required": ["workspaceId", "name", "contentType", "size"], + "additionalProperties": false, + "title": "Create knowledge document upload request", + "description": "Document metadata used to authorize and initialize the upload.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "getting-started.pdf", + "contentType": "application/pdf", + "size": 248913 + } + ] + }, + "V2KnowledgeDocumentUploadResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeDocumentUpload" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge document upload response", + "description": "Current state of a knowledge document upload session." + }, + "V2UploadPartUrl": { + "type": "object", + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Multipart part number." }, - "rerankerModel": { - "default": "rerank-v4.0-fast", - "description": "Reranking model to use when `rerankerEnabled` is true. Defaults to `rerank-v4.0-fast`.", + "url": { "type": "string", - "enum": ["rerank-v4.0-pro", "rerank-v4.0-fast", "rerank-v3.5"] + "format": "uri", + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." }, - "rerankerInputCount": { - "description": "How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from.", - "type": "integer", - "minimum": 1, - "maximum": 100 + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the part upload." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 expiration time for the signed URL." } }, - "required": ["workspaceId", "knowledgeBaseIds"], + "required": ["partNumber", "url", "headers", "expiresAt"], "additionalProperties": false, - "title": "Search knowledge request", - "description": "Knowledge bases, query, result limit, retrieval mode, and optional tag filters." + "title": "Upload part URL", + "description": "A signed URL and required headers for one multipart upload part." }, - "V2KnowledgeTag": { + "V2PartUrlsData": { "type": "object", "properties": { - "displayName": { - "type": "string", - "description": "Display name used by tag filters and by tag values on document reads.", - "examples": ["category"] - }, - "tagSlot": { - "type": "string", - "description": "Storage slot the tag occupies. Document writes set tag values by slot (`tag1`..`tag7`).", - "examples": ["tag1"] - }, - "fieldType": { - "type": "string", - "description": "Value type stored in the slot; it determines the valid filter operators.", - "examples": ["text"] + "parts": { + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/V2UploadPartUrl" + }, + "description": "Signed URLs for requested parts." } }, - "required": ["displayName", "tagSlot", "fieldType"], + "required": ["parts"], "additionalProperties": false, - "title": "Knowledge tag", - "description": "A tag defined on a knowledge base, and the slot it is stored in." + "title": "Upload part URLs", + "description": "Signed transfer URLs for the requested multipart upload parts." }, - "V2KnowledgeTagListResponse": { + "V2KnowledgeDocumentUploadPartUrlsResponse": { "type": "object", "properties": { "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PartUrlsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge document upload part URLs response", + "description": "Signed provider URLs for requested multipart parts." + }, + "CreateKnowledgeDocumentUploadPartUrlsRequest": { + "type": "object", + "properties": { + "partNumbers": { + "minItems": 1, + "maxItems": 100, "type": "array", "items": { - "$ref": "#/components/schemas/V2KnowledgeTag" + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + "description": "Multipart part numbers for which signed URLs should be created." } }, - "required": ["data", "nextCursor"], + "required": ["partNumbers"], "additionalProperties": false, - "title": "Knowledge tag list response", - "description": "The full tag vocabulary of one knowledge base." + "title": "Create upload part URLs request", + "description": "Multipart part numbers for which signed URLs should be created.", + "examples": [ + { + "partNumbers": [1, 2, 3] + } + ] }, - "V2KnowledgeTaggedDocument": { + "V2KnowledgeDocument": { "type": "object", "properties": { "id": { @@ -4785,203 +7043,74 @@ ], "description": "Tag value; dates are ISO 8601 strings and an unset tag is null." }, - "description": "Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{id}/tags.", + "description": "Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{knowledgeBaseId}/tags.", "examples": [ { "category": "billing", "priority": 2 } ] - } - }, - "required": [ - "id", - "knowledgeBaseId", - "filename", - "fileSize", - "mimeType", - "processingStatus", - "chunkCount", - "tokenCount", - "characterCount", - "enabled", - "createdAt", - "tags" - ], - "additionalProperties": false, - "title": "Knowledge document list item", - "description": "Document summary with the document tag values keyed by display name." - }, - "V2KnowledgeDocumentListResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2KnowledgeTaggedDocument" - }, - "description": "Items in the current page." }, - "nextCursor": { + "processingError": { "anyOf": [ { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Knowledge document list response", - "description": "A cursor-paginated page of knowledge documents." - }, - "V2BulkKnowledgeDocumentsData": { - "type": "object", - "properties": { - "operation": { - "type": "string", - "enum": ["enable", "disable"], - "description": "Operation that was applied." - }, - "updatedCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of documents the operation changed.", - "examples": [42] - }, - "documentIds": { - "description": "Identifiers of the documents the operation changed. Present only for an explicit `documentIds` request, which is bounded to 100 documents; a `selectAll` request omits it because the selection is unbounded, and reports `updatedCount` instead.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["operation", "updatedCount"], - "additionalProperties": false, - "title": "Bulk knowledge document update data", - "description": "Outcome of a bulk enable or disable across knowledge documents." - }, - "V2BulkKnowledgeDocumentsResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2BulkKnowledgeDocumentsData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Bulk knowledge document response", - "description": "Outcome of a bulk enable or disable." - }, - "BulkUpdateKnowledgeDocumentsRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge base." - }, - "operation": { - "type": "string", - "enum": ["enable", "disable"], - "description": "Whether the selected documents become enabled or disabled for search." - }, - "documentIds": { - "description": "Documents to update, by identifier.", - "minItems": 1, - "maxItems": 100, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "selectAll": { - "description": "Update every document in the knowledge base instead of an explicit list, narrowed by `enabledFilter`.", - "type": "boolean", - "const": true - }, - "enabledFilter": { - "description": "With `selectAll`, restrict the update to documents in this state.", - "type": "string", - "enum": ["all", "enabled", "disabled"] - } - }, - "required": ["workspaceId", "operation"], - "additionalProperties": false, - "title": "Bulk knowledge document request", - "description": "Operation and the documents it applies to.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "operation": "disable", - "documentIds": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] - } - ] - }, - "V2KnowledgeDocumentSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique document identifier.", - "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] - }, - "knowledgeBaseId": { - "type": "string", - "description": "Knowledge base to which the document belongs.", - "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] - }, - "filename": { - "type": "string", - "description": "Original filename of the uploaded document.", - "examples": ["getting-started.pdf"] - }, - "fileSize": { - "type": "number", - "description": "File size in bytes.", - "examples": [248913] - }, - "mimeType": { - "type": "string", - "description": "MIME type of the document file.", - "examples": ["application/pdf"] - }, - "processingStatus": { - "type": "string", - "enum": ["pending", "processing", "completed", "failed"], - "description": "Current document processing state.", - "examples": ["completed"] + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Processing error message, or null when processing has not failed." }, - "chunkCount": { - "type": "number", - "description": "Number of indexed chunks; zero until processing completes.", - "examples": [24] + "processingStartedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when processing started, or null.", + "format": "date-time", + "examples": ["2025-06-18T16:45:05Z"] }, - "tokenCount": { - "type": "number", - "description": "Total tokens extracted from the document.", - "examples": [8123] + "processingCompletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when processing completed, or null.", + "format": "date-time", + "examples": ["2025-06-18T16:45:42Z"] }, - "characterCount": { - "type": "number", - "description": "Total characters extracted from the document.", - "examples": [41205] + "connectorId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Connector identifier for a synced document, or null for a direct upload." }, - "enabled": { - "type": "boolean", - "description": "Whether the document is enabled for search.", - "examples": [true] + "connectorType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Connector type for a synced document, or null for a direct upload." }, - "createdAt": { + "sourceUrl": { "anyOf": [ { "type": "string" @@ -4990,9 +7119,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the document was uploaded, or null.", - "format": "date-time", - "examples": ["2025-06-18T16:45:00Z"] + "description": "Original source URL for a synced document, or null for a direct upload." } }, "required": [ @@ -5006,487 +7133,539 @@ "tokenCount", "characterCount", "enabled", - "createdAt" + "createdAt", + "tags", + "processingError", + "processingStartedAt", + "processingCompletedAt", + "connectorId", + "connectorType", + "sourceUrl" ], "additionalProperties": false, - "title": "Knowledge document summary", - "description": "Summary returned by document lists and upload acknowledgements." + "title": "Knowledge document", + "description": "Full document detail including processing state and connector provenance." }, - "V2KnowledgeDocumentSummaryResponse": { + "V2KnowledgeDocumentResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeDocumentSummary" + "$ref": "#/components/schemas/V2KnowledgeDocument" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge document summary response", - "description": "An accepted knowledge document summary." + "title": "Knowledge document response", + "description": "Full knowledge document detail." }, - "UploadKnowledgeDocumentForm": { + "V2KnowledgeDocumentProcessing": { "type": "object", "properties": { - "file": { + "id": { "type": "string", - "format": "binary", - "contentEncoding": "binary", - "maxLength": 104857600, - "description": "Document file to upload; the maximum size is 100 MB." + "description": "Identifier of the requeued document." + }, + "queued": { + "type": "boolean", + "const": true, + "description": "Confirms that processing was requeued." + }, + "processingStatus": { + "type": "string", + "description": "Processing state the document was moved to.", + "examples": ["pending"] + }, + "message": { + "type": "string", + "description": "Human-readable outcome of the requeue." } }, - "required": ["file"], - "additionalProperties": { - "description": "Additional multipart form fields are ignored." + "required": ["id", "queued", "processingStatus", "message"], + "additionalProperties": false, + "title": "Knowledge document processing acknowledgement", + "description": "Acknowledgement returned when a document is requeued for processing." + }, + "V2UpdateKnowledgeDocumentResponse": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2KnowledgeTaggedDocument" + }, + { + "$ref": "#/components/schemas/V2KnowledgeDocumentProcessing" + } + ], + "description": "Response data." + } }, - "title": "Upload knowledge document form", - "description": "Multipart form containing the document file." + "required": ["data"], + "additionalProperties": false, + "title": "Update knowledge document response", + "description": "The updated document, or the processing requeue acknowledgement." }, - "V2KnowledgeDocumentUpload": { + "UpdateKnowledgeDocumentRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", - "description": "Upload session identifier." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." }, - "knowledgeBaseId": { + "filename": { + "description": "New filename for the document.", + "examples": ["getting-started-v2.pdf"], "type": "string", - "description": "Knowledge base that will own the document." + "minLength": 1, + "maxLength": 255 }, - "status": { + "enabled": { + "description": "Whether the document participates in search. Disabling keeps it indexed.", + "type": "boolean" + }, + "tag1": { + "description": "New value for tag slot 1.", "type": "string", - "enum": [ - "uploading", - "completing", - "finalizing", - "completed", - "failed", - "aborting", - "aborted", - "expired" - ], - "description": "Current upload-session state." + "maxLength": 1000 }, - "name": { + "tag2": { + "description": "New value for tag slot 2.", "type": "string", - "description": "Filename recorded on the knowledge document." + "maxLength": 1000 }, - "contentType": { + "tag3": { + "description": "New value for tag slot 3.", "type": "string", - "description": "MIME type declared for the document." + "maxLength": 1000 }, - "size": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Exact file size in bytes." + "tag4": { + "description": "New value for tag slot 4.", + "type": "string", + "maxLength": 1000 }, - "expiresAt": { + "tag5": { + "description": "New value for tag slot 5.", "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 upload-session expiration time." + "maxLength": 1000 }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Terminal upload error, or null when none occurred." + "tag6": { + "description": "New value for tag slot 6.", + "type": "string", + "maxLength": 1000 + }, + "tag7": { + "description": "New value for tag slot 7.", + "type": "string", + "maxLength": 1000 + }, + "number1": { + "description": "New value for number tag slot 1.", + "type": "number" + }, + "number2": { + "description": "New value for number tag slot 2.", + "type": "number" + }, + "number3": { + "description": "New value for number tag slot 3.", + "type": "number" + }, + "number4": { + "description": "New value for number tag slot 4.", + "type": "number" + }, + "number5": { + "description": "New value for number tag slot 5.", + "type": "number" + }, + "date1": { + "description": "New value for date tag slot 1, formatted YYYY-MM-DD.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "date2": { + "description": "New value for date tag slot 2, formatted YYYY-MM-DD.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "boolean1": { + "description": "New value for boolean tag slot 1.", + "type": "boolean" }, - "document": { - "anyOf": [ - { - "$ref": "#/components/schemas/V2KnowledgeDocumentSummary" - }, - { - "type": "null" - } - ], - "description": "Queued document after completion, or null before completion." + "boolean2": { + "description": "New value for boolean tag slot 2.", + "type": "boolean" + }, + "boolean3": { + "description": "New value for boolean tag slot 3.", + "type": "boolean" + }, + "retryProcessing": { + "description": "Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document.", + "type": "boolean", + "const": true } }, - "required": [ - "id", - "knowledgeBaseId", - "status", - "name", - "contentType", - "size", - "expiresAt", - "error", - "document" - ], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Knowledge document upload", - "description": "State of a resumable knowledge-document upload session." - }, - "V2KnowledgeUploadTransfer": { - "oneOf": [ - { - "$ref": "#/components/schemas/V2PutUploadTransfer" - }, + "title": "Update knowledge document request", + "description": "Filename, search state, tag slot values, or a processing retry.", + "examples": [ { - "$ref": "#/components/schemas/V2MultipartUploadTransfer" + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "enabled": false, + "tag1": "billing" } - ], - "description": "Provider transfer strategy for a knowledge document upload.", - "title": "Knowledge upload transfer" + ] }, - "V2PutUploadTransfer": { + "V2Folder": { "type": "object", "properties": { - "method": { + "name": { "type": "string", - "const": "put", - "description": "Upload strategy discriminator." + "description": "Folder name." }, - "url": { + "path": { "type": "string", - "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - }, - "description": "Headers that must be included with the upload request." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." - } - }, - "required": ["method", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Direct upload transfer", - "description": "Instructions for uploading bytes to one signed URL." - }, - "V2MultipartUploadTransfer": { - "type": "object", - "properties": { - "method": { + "parentPath": { "type": "string", - "const": "multipart", - "description": "Upload strategy discriminator." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, - "partSize": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Required size of each non-final part in bytes." + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the folder was created.", + "format": "date-time" }, - "partCount": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 640, - "description": "Total number of upload parts." + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the folder was last updated.", + "format": "date-time" } }, - "required": ["method", "partSize", "partCount"], + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Multipart upload transfer", - "description": "Instructions for splitting bytes into a multipart upload." + "title": "Folder", + "description": "A canonical workspace folder." }, - "V2CreateKnowledgeDocumentUploadData": { + "V2KnowledgeFolderListResponse": { "type": "object", "properties": { - "session": { - "$ref": "#/components/schemas/V2KnowledgeDocumentUpload" - }, - "uploadToken": { - "type": "string", - "minLength": 1, - "description": "Signed control token required by subsequent upload-session requests." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Folder" + }, + "description": "Items in the current page." }, - "transfer": { - "$ref": "#/components/schemas/V2KnowledgeUploadTransfer" + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, - "required": ["session", "uploadToken", "transfer"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Create knowledge document upload data", - "description": "Upload session, signed control token, and transfer instructions." + "title": "Knowledge folder list response", + "description": "The whole bounded set of knowledge-base folders, in one page." }, - "V2CreateKnowledgeDocumentUploadResponse": { + "V2KnowledgeFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CreateKnowledgeDocumentUploadData" + "$ref": "#/components/schemas/V2Folder" } }, "required": ["data"], "additionalProperties": false, - "title": "Create knowledge document upload response", - "description": "Upload session, signed control token, and transfer instructions." + "title": "Knowledge folder response", + "description": "A single knowledge-base folder." }, - "CreateKnowledgeDocumentUploadRequest": { + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "CreateKnowledgeFolderRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the knowledge base." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Filename recorded on the knowledge document.", - "examples": ["getting-started.pdf"] + "description": "Workspace in which to create the folder." }, - "contentType": { + "path": { + "description": "Path of the folder to create.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + "required": ["workspaceId", "path"], + "additionalProperties": false, + "title": "Create knowledge folder request", + "description": "Workspace and canonical path for a new knowledge-base folder." + }, + "RelocateKnowledgeFolderRequest": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Supported MIME type for the document.", - "examples": ["application/pdf"] - }, - "size": { - "type": "integer", - "minimum": 1, - "maximum": 104857600, - "description": "Exact file size in bytes.", - "examples": [248913] - }, - "tag1": { - "description": "Value for tag slot 1.", - "type": "string", - "maxLength": 1000 - }, - "tag2": { - "description": "Value for tag slot 2.", - "type": "string", - "maxLength": 1000 - }, - "tag3": { - "description": "Value for tag slot 3.", - "type": "string", - "maxLength": 1000 - }, - "tag4": { - "description": "Value for tag slot 4.", - "type": "string", - "maxLength": 1000 + "maxLength": 128, + "description": "Workspace containing the folder." }, - "tag5": { - "description": "Value for tag slot 5.", - "type": "string", - "maxLength": 1000 + "path": { + "description": "Current folder path.", + "$ref": "#/components/schemas/NonRootFolderPathInput" }, - "tag6": { - "description": "Value for tag slot 6.", + "destinationPath": { + "description": "New full path for the folder and its descendants.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + "required": ["workspaceId", "path", "destinationPath"], + "additionalProperties": false, + "title": "Relocate knowledge folder request", + "description": "Current and destination canonical paths for a knowledge-base folder." + }, + "V2DeleteKnowledgeFolderData": { + "type": "object", + "properties": { + "path": { "type": "string", - "maxLength": 1000 + "title": "Folder path", + "description": "Canonical path of the deleted folder.", + "maxLength": 4096 }, - "tag7": { - "description": "Value for tag slot 7.", - "type": "string", - "maxLength": 1000 + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the folder was deleted." }, - "processingOptions": { - "description": "Optional processing recipe and language.", + "deletedItems": { "type": "object", "properties": { - "recipe": { - "description": "Optional document processing recipe.", - "type": "string", - "maxLength": 255 + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of deleted folders." }, - "lang": { - "description": "Optional document language code.", - "type": "string", - "maxLength": 35 + "knowledgeBases": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of deleted knowledge bases." } }, - "additionalProperties": false + "required": ["folders", "knowledgeBases"], + "additionalProperties": false, + "description": "Counts of deleted resources." } }, - "required": ["workspaceId", "name", "contentType", "size"], + "required": ["path", "deleted", "deletedItems"], "additionalProperties": false, - "title": "Create knowledge document upload request", - "description": "Document metadata used to authorize and initialize the upload.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "getting-started.pdf", - "contentType": "application/pdf", - "size": 248913 - } - ] + "title": "Delete knowledge folder data", + "description": "Folder deletion acknowledgement and deleted-resource counts." }, - "V2KnowledgeDocumentUploadResponse": { + "V2DeleteKnowledgeFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeDocumentUpload" + "$ref": "#/components/schemas/V2DeleteKnowledgeFolderData" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge document upload response", - "description": "Current state of a knowledge document upload session." + "title": "Delete knowledge folder response", + "description": "Folder deletion acknowledgement and deleted-resource counts." }, - "V2UploadPartUrl": { + "RestoreKnowledgeBaseRequest": { "type": "object", "properties": { - "partNumber": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "description": "Multipart part number." - }, - "url": { + "workspaceId": { "type": "string", - "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Restore knowledge base request", + "description": "Workspace scope for the knowledge base.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + ] + }, + "V2AddedWorkspaceFileDocument": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "description": "Identifier of the queued knowledge document." }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - }, - "description": "Headers that must be included with the part upload." + "filename": { + "type": "string", + "description": "Filename recorded on the knowledge document." }, - "expiresAt": { + "mimeType": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 expiration time for the signed URL." + "description": "MIME type of the source workspace file." + }, + "fileSize": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "File size in bytes." } }, - "required": ["partNumber", "url", "headers", "expiresAt"], + "required": ["documentId", "filename", "mimeType", "fileSize"], "additionalProperties": false, - "title": "Upload part URL", - "description": "A signed URL and required headers for one multipart upload part." + "title": "Indexed workspace file", + "description": "A workspace file that was queued for indexing into a knowledge base." }, - "V2PartUrlsData": { + "V2AddWorkspaceFilesToKnowledgeBaseData": { "type": "object", "properties": { - "parts": { - "maxItems": 100, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base the files were added to." + }, + "added": { "type": "array", "items": { - "$ref": "#/components/schemas/V2UploadPartUrl" + "$ref": "#/components/schemas/V2AddedWorkspaceFileDocument" }, - "description": "Signed URLs for requested parts." + "description": "Files queued for indexing, in request order." + }, + "failed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "References that could not be indexed, echoed exactly as they were sent." } }, - "required": ["parts"], + "required": ["knowledgeBaseId", "added", "failed"], "additionalProperties": false, - "title": "Upload part URLs", - "description": "Signed transfer URLs for the requested multipart upload parts." + "title": "Add workspace files data", + "description": "Outcome of indexing workspace files into a knowledge base." }, - "V2KnowledgeDocumentUploadPartUrlsResponse": { + "V2AddWorkspaceFilesToKnowledgeBaseResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PartUrlsData" + "$ref": "#/components/schemas/V2AddWorkspaceFilesToKnowledgeBaseData" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge document upload part URLs response", - "description": "Signed provider URLs for requested multipart parts." + "title": "Index workspace files response", + "description": "Documents queued for indexing and references that could not be." }, - "CreateKnowledgeDocumentUploadPartUrlsRequest": { + "AddWorkspaceFilesToKnowledgeBaseRequest": { "type": "object", "properties": { - "partNumbers": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns both the files and the base." + }, + "fileReferences": { "minItems": 1, "maxItems": 100, "type": "array", "items": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "type": "string", + "minLength": 1 }, - "description": "Multipart part numbers for which signed URLs should be created." + "description": "Workspace file identifiers or storage keys to index. Duplicates resolving to the same file are indexed once." } }, - "required": ["partNumbers"], + "required": ["workspaceId", "fileReferences"], "additionalProperties": false, - "title": "Create upload part URLs request", - "description": "Multipart part numbers for which signed URLs should be created.", + "title": "Index workspace files request", + "description": "Workspace scope and the workspace file references to index.", "examples": [ { - "partNumbers": [1, 2, 3] + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "fileReferences": ["handbook.pdf"] } ] }, - "V2KnowledgeDocument": { + "V2KnowledgeChunk": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique document identifier.", - "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] - }, - "knowledgeBaseId": { - "type": "string", - "description": "Knowledge base to which the document belongs.", - "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] - }, - "filename": { - "type": "string", - "description": "Original filename of the uploaded document.", - "examples": ["getting-started.pdf"] - }, - "fileSize": { - "type": "number", - "description": "File size in bytes.", - "examples": [248913] + "description": "Unique chunk identifier.", + "examples": ["4c1f9e77-2b3a-4f8d-9e10-6a2c8d4b1e05"] }, - "mimeType": { - "type": "string", - "description": "MIME type of the document file.", - "examples": ["application/pdf"] + "chunkIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Zero-based position of the chunk within its document.", + "examples": [3] }, - "processingStatus": { + "content": { "type": "string", - "enum": ["pending", "processing", "completed", "failed"], - "description": "Current document processing state.", - "examples": ["completed"] + "description": "Text content of the chunk, exactly as it was embedded.", + "examples": ["To reset your password, open Settings and choose Security."] }, - "chunkCount": { - "type": "number", - "description": "Number of indexed chunks; zero until processing completes.", - "examples": [24] + "contentLength": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Character count of `content`.", + "examples": [58] }, "tokenCount": { - "type": "number", - "description": "Total tokens extracted from the document.", - "examples": [8123] - }, - "characterCount": { - "type": "number", - "description": "Total characters extracted from the document.", - "examples": [41205] + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Tokens the chunk consumed when embedded.", + "examples": [14] }, "enabled": { "type": "boolean", - "description": "Whether the document is enabled for search.", - "examples": [true] + "description": "Whether the chunk participates in search. A disabled chunk stays indexed." }, - "createdAt": { + "startOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Character offset of the chunk within the extracted document text." + }, + "endOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Character offset just past the end of the chunk." + }, + "tag1": { "anyOf": [ { "type": "string" @@ -5495,41 +7674,9 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the document was uploaded, or null.", - "format": "date-time", - "examples": ["2025-06-18T16:45:00Z"] - }, - "tags": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "description": "Tag value; dates are ISO 8601 strings and an unset tag is null." - }, - "description": "Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{id}/tags.", - "examples": [ - { - "category": "billing", - "priority": 2 - } - ] + "description": "Text tag value inherited from the document, or null when the slot is unset." }, - "processingError": { + "tag2": { "anyOf": [ { "type": "string" @@ -5538,9 +7685,9 @@ "type": "null" } ], - "description": "Processing error message, or null when processing has not failed." + "description": "Text tag value inherited from the document, or null when the slot is unset." }, - "processingStartedAt": { + "tag3": { "anyOf": [ { "type": "string" @@ -5549,11 +7696,9 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when processing started, or null.", - "format": "date-time", - "examples": ["2025-06-18T16:45:05Z"] + "description": "Text tag value inherited from the document, or null when the slot is unset." }, - "processingCompletedAt": { + "tag4": { "anyOf": [ { "type": "string" @@ -5562,11 +7707,9 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when processing completed, or null.", - "format": "date-time", - "examples": ["2025-06-18T16:45:42Z"] + "description": "Text tag value inherited from the document, or null when the slot is unset." }, - "connectorId": { + "tag5": { "anyOf": [ { "type": "string" @@ -5575,9 +7718,9 @@ "type": "null" } ], - "description": "Connector identifier for a synced document, or null for a direct upload." + "description": "Text tag value inherited from the document, or null when the slot is unset." }, - "connectorType": { + "tag6": { "anyOf": [ { "type": "string" @@ -5586,9 +7729,9 @@ "type": "null" } ], - "description": "Connector type for a synced document, or null for a direct upload." + "description": "Text tag value inherited from the document, or null when the slot is unset." }, - "sourceUrl": { + "tag7": { "anyOf": [ { "type": "string" @@ -5597,94 +7740,158 @@ "type": "null" } ], - "description": "Original source URL for a synced document, or null for a direct upload." + "description": "Text tag value inherited from the document, or null when the slot is unset." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the chunk was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the chunk was last modified." } }, "required": [ "id", - "knowledgeBaseId", - "filename", - "fileSize", - "mimeType", - "processingStatus", - "chunkCount", + "chunkIndex", + "content", + "contentLength", "tokenCount", - "characterCount", "enabled", + "startOffset", + "endOffset", + "tag1", + "tag2", + "tag3", + "tag4", + "tag5", + "tag6", + "tag7", "createdAt", - "tags", - "processingError", - "processingStartedAt", - "processingCompletedAt", - "connectorId", - "connectorType", - "sourceUrl" + "updatedAt" ], "additionalProperties": false, - "title": "Knowledge document", - "description": "Full document detail including processing state and connector provenance." + "title": "Knowledge chunk", + "description": "One embedded passage of a knowledge document." }, - "V2KnowledgeDocumentResponse": { + "V2KnowledgeChunkListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeChunk" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Knowledge chunk list response", + "description": "A cursor-paginated page of document chunks." + }, + "V2KnowledgeChunkResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeDocument" + "$ref": "#/components/schemas/V2KnowledgeChunk" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge document response", - "description": "Full knowledge document detail." + "title": "Knowledge chunk response", + "description": "A single document chunk." }, - "V2KnowledgeDocumentProcessing": { + "CreateKnowledgeChunkRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", - "description": "Identifier of the requeued document." - }, - "queued": { - "type": "boolean", - "const": true, - "description": "Confirms that processing was requeued." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." }, - "processingStatus": { + "content": { "type": "string", - "description": "Processing state the document was moved to.", - "examples": ["pending"] + "minLength": 1, + "maxLength": 10000, + "description": "Text to embed. It is embedded on write, so the chunk is searchable immediately." }, - "message": { + "enabled": { + "default": true, + "description": "Whether the new chunk participates in search.", + "type": "boolean" + } + }, + "required": ["workspaceId", "content"], + "additionalProperties": false, + "title": "Create knowledge chunk request", + "description": "Workspace scope and the text to embed.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "To reset your password, open Settings and choose Security." + } + ] + }, + "V2BulkKnowledgeChunksData": { + "type": "object", + "properties": { + "operation": { "type": "string", - "description": "Human-readable outcome of the requeue." + "enum": ["enable", "disable", "delete"], + "description": "Operation that was applied." + }, + "processed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of chunks the operation changed.", + "examples": [12] + }, + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Per-chunk failures. A populated array still answers 200." } }, - "required": ["id", "queued", "processingStatus", "message"], + "required": ["operation", "processed", "errors"], "additionalProperties": false, - "title": "Knowledge document processing acknowledgement", - "description": "Acknowledgement returned when a document is requeued for processing." + "title": "Bulk knowledge chunk update data", + "description": "Outcome of a bulk enable, disable, or delete across knowledge chunks." }, - "V2UpdateKnowledgeDocumentResponse": { + "V2BulkKnowledgeChunksResponse": { "type": "object", "properties": { "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/V2KnowledgeTaggedDocument" - }, - { - "$ref": "#/components/schemas/V2KnowledgeDocumentProcessing" - } - ], - "description": "Response data." + "description": "Response data.", + "$ref": "#/components/schemas/V2BulkKnowledgeChunksData" } }, "required": ["data"], "additionalProperties": false, - "title": "Update knowledge document response", - "description": "The updated document, or the processing requeue acknowledgement." + "title": "Bulk knowledge chunk response", + "description": "Counts and per-chunk failures from a bulk chunk operation." }, - "UpdateKnowledgeDocumentRequest": { + "BulkUpdateKnowledgeChunksRequest": { "type": "object", "properties": { "workspaceId": { @@ -5693,154 +7900,316 @@ "maxLength": 128, "description": "Workspace that owns the knowledge base." }, - "filename": { - "description": "New filename for the document.", - "examples": ["getting-started-v2.pdf"], - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "enabled": { - "description": "Whether the document participates in search. Disabling keeps it indexed.", - "type": "boolean" - }, - "tag1": { - "description": "New value for tag slot 1.", + "operation": { "type": "string", - "maxLength": 1000 + "enum": ["enable", "disable", "delete"], + "description": "What to do with the selected chunks." }, - "tag2": { - "description": "New value for tag slot 2.", + "chunkIds": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Chunks to operate on, by identifier. Ids outside the document are ignored." + } + }, + "required": ["workspaceId", "operation", "chunkIds"], + "additionalProperties": false, + "title": "Bulk knowledge chunk request", + "description": "Workspace scope, the operation to apply, and the chunks to apply it to.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "operation": "disable", + "chunkIds": ["4c1f9e77-2b3a-4f8d-9e10-6a2c8d4b1e05"] + } + ] + }, + "UpdateKnowledgeChunkRequest": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", - "maxLength": 1000 + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." }, - "tag3": { - "description": "New value for tag slot 3.", + "content": { + "description": "Replacement text. Changing it re-embeds the chunk and re-derives its token and character counts.", "type": "string", - "maxLength": 1000 + "minLength": 1, + "maxLength": 10000 }, - "tag4": { - "description": "New value for tag slot 4.", + "enabled": { + "description": "Whether the chunk participates in search. Disabling keeps it indexed.", + "type": "boolean" + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update knowledge chunk request", + "description": "Workspace scope and the fields to update. At least one is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "enabled": false + } + ] + }, + "V2KnowledgeTagResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeTag" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge tag response", + "description": "A single tag definition." + }, + "CreateKnowledgeTagRequest": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", - "maxLength": 1000 + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." }, - "tag5": { - "description": "New value for tag slot 5.", + "displayName": { "type": "string", - "maxLength": 1000 + "minLength": 1, + "maxLength": 100, + "description": "Name tag filters and document reads use for this tag.", + "examples": ["category"] }, - "tag6": { - "description": "New value for tag slot 6.", + "fieldType": { + "default": "text", "type": "string", - "maxLength": 1000 + "enum": ["text", "number", "date", "boolean"], + "description": "Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3.", + "examples": ["text"] }, - "tag7": { - "description": "New value for tag slot 7.", + "tagSlot": { + "description": "Slot to store the tag in. Omit to take the next free slot for the field type; a slot that does not belong to the field type, or one already in use, is rejected.", "type": "string", - "maxLength": 1000 - }, - "number1": { - "description": "New value for number tag slot 1.", - "type": "number" - }, - "number2": { - "description": "New value for number tag slot 2.", - "type": "number" - }, - "number3": { - "description": "New value for number tag slot 3.", - "type": "number" - }, - "number4": { - "description": "New value for number tag slot 4.", - "type": "number" - }, - "number5": { - "description": "New value for number tag slot 5.", - "type": "number" - }, - "date1": { - "description": "New value for date tag slot 1, formatted YYYY-MM-DD.", + "enum": [ + "tag1", + "tag2", + "tag3", + "tag4", + "tag5", + "tag6", + "tag7", + "number1", + "number2", + "number3", + "number4", + "number5", + "date1", + "date2", + "boolean1", + "boolean2", + "boolean3" + ], + "examples": ["tag1"] + } + }, + "required": ["workspaceId", "displayName"], + "additionalProperties": false, + "title": "Create knowledge tag request", + "description": "Workspace scope, display name, field type, and optional slot.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "displayName": "category", + "fieldType": "text" + } + ] + }, + "UpdateKnowledgeTagRequest": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." }, - "date2": { - "description": "New value for date tag slot 2, formatted YYYY-MM-DD.", + "displayName": { + "description": "New tag display name.", "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" - }, - "boolean1": { - "description": "New value for boolean tag slot 1.", - "type": "boolean" - }, - "boolean2": { - "description": "New value for boolean tag slot 2.", - "type": "boolean" - }, - "boolean3": { - "description": "New value for boolean tag slot 3.", - "type": "boolean" + "minLength": 1, + "maxLength": 100, + "examples": ["category"] }, - "retryProcessing": { - "description": "Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document.", - "type": "boolean", - "const": true + "fieldType": { + "description": "New value type for the tag.", + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "examples": ["text"] } }, "required": ["workspaceId"], "additionalProperties": false, - "title": "Update knowledge document request", - "description": "Filename, search state, tag slot values, or a processing retry.", + "title": "Update knowledge tag request", + "description": "Workspace scope and the fields to update. At least one is required.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "enabled": false, - "tag1": "billing" + "displayName": "topic" } ] }, - "V2Folder": { + "V2DeleteKnowledgeTagData": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "description": "Folder name." + "description": "Identifier of the deleted tag definition." }, - "path": { + "tagSlot": { "type": "string", - "title": "Non-root folder path", - "description": "Canonical folder path used as the public folder identifier.", - "maxLength": 4096 + "description": "Slot the deleted tag occupied; its values are now cleared." }, - "parentPath": { + "displayName": { "type": "string", - "title": "Folder path", - "description": "Canonical parent path; `/` is the root.", - "maxLength": 4096 + "description": "Display name the deleted tag carried." }, - "createdAt": { + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the tag definition was deleted." + } + }, + "required": ["id", "tagSlot", "displayName", "deleted"], + "additionalProperties": false, + "title": "Delete knowledge tag data", + "description": "Acknowledgement for a deleted tag definition." + }, + "V2DeleteKnowledgeTagResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2DeleteKnowledgeTagData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete knowledge tag response", + "description": "Acknowledgement naming the deleted definition and the slot it freed." + }, + "V2NextKnowledgeTagSlotData": { + "type": "object", + "properties": { + "nextAvailableSlot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The free slot a create would take, or null when the field type is exhausted.", + "examples": ["tag3"] + }, + "fieldType": { "type": "string", - "description": "ISO 8601 timestamp when the folder was created.", - "format": "date-time" + "description": "Field type the slots were counted for." }, - "updatedAt": { + "usedSlots": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Slots of this field type already holding a tag." + }, + "totalSlots": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Total slots this field type has: 7 for text, 5 for number, 2 for date, 3 for boolean." + }, + "availableSlots": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Slots of this field type still free, or 0 when the field type is exhausted." + } + }, + "required": ["nextAvailableSlot", "fieldType", "usedSlots", "totalSlots", "availableSlots"], + "additionalProperties": false, + "title": "Next knowledge tag slot", + "description": "Slot availability for one tag field type." + }, + "V2NextKnowledgeTagSlotResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2NextKnowledgeTagSlotData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Next knowledge tag slot response", + "description": "Slot availability for one tag field type." + }, + "V2KnowledgeTagUsage": { + "type": "object", + "properties": { + "id": { "type": "string", - "description": "ISO 8601 timestamp when the folder was last updated.", - "format": "date-time" + "description": "Tag definition identifier. Published for the same reason the vocabulary read publishes it: `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by id, so without it a usage row cannot be acted on without a second read and a slot join.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "tagSlot": { + "type": "string", + "description": "Slot the tag occupies.", + "examples": ["tag1"] + }, + "displayName": { + "type": "string", + "description": "Tag display name.", + "examples": ["category"] + }, + "fieldType": { + "type": "string", + "description": "Value type stored in the slot.", + "examples": ["text"] + }, + "documentCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents in the knowledge base carrying a value in this slot." + }, + "chunkCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Indexed chunks carrying a value in this slot." } }, - "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "required": ["id", "tagSlot", "displayName", "fieldType", "documentCount", "chunkCount"], "additionalProperties": false, - "title": "Folder", - "description": "A canonical workspace folder." + "title": "Knowledge tag usage", + "description": "How widely one tag is populated across a knowledge base." }, - "V2KnowledgeFolderListResponse": { + "V2KnowledgeTagUsageListResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Folder" + "$ref": "#/components/schemas/V2KnowledgeTagUsage" }, "description": "Items in the current page." }, @@ -5858,122 +8227,172 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Knowledge folder list response", - "description": "The whole bounded set of knowledge-base folders, in one page." + "title": "Knowledge tag usage response", + "description": "Usage counts for every tag defined on one knowledge base." }, - "V2KnowledgeFolderResponse": { + "V2BulkSaveKnowledgeTagDefinitionsData": { + "type": "object", + "properties": { + "created": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeTag" + }, + "description": "Definitions that did not previously exist." + }, + "updated": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeTag" + }, + "description": "Definitions whose slot was already defined." + }, + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Per-definition failures. A populated array still answers 200." + } + }, + "required": ["created", "updated", "errors"], + "additionalProperties": false, + "title": "Bulk save knowledge tag definitions data", + "description": "Definitions created and updated by a bulk tag-definition save." + }, + "V2BulkSaveKnowledgeTagDefinitionsResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Folder" + "$ref": "#/components/schemas/V2BulkSaveKnowledgeTagDefinitionsData" } }, "required": ["data"], "additionalProperties": false, - "title": "Knowledge folder response", - "description": "A single knowledge-base folder." - }, - "NonRootFolderPathInput": { - "title": "Non-root folder path input", - "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" + "title": "Bulk save tag definitions response", + "description": "Definitions created and updated, with any per-definition failures." }, - "CreateKnowledgeFolderRequest": { + "V2BulkSaveKnowledgeTagDefinition": { "type": "object", "properties": { - "workspaceId": { + "tagSlot": { + "type": "string", + "enum": [ + "tag1", + "tag2", + "tag3", + "tag4", + "tag5", + "tag6", + "tag7", + "number1", + "number2", + "number3", + "number4", + "number5", + "date1", + "date2", + "boolean1", + "boolean2", + "boolean3" + ], + "description": "Storage slot the tag occupies. It must belong to the tag’s `fieldType`.", + "examples": ["tag1"] + }, + "displayName": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the folder." + "maxLength": 100, + "description": "Name tag filters and document reads use for this tag.", + "examples": ["category"] }, - "path": { - "description": "Path of the folder to create.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "description": "Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3.", + "examples": ["text"] + }, + "originalDisplayName": { + "description": "Previous display name, when this entry renames an existing definition.", + "type": "string", + "minLength": 1, + "maxLength": 100, + "examples": ["category"] } }, - "required": ["workspaceId", "path"], + "required": ["tagSlot", "displayName", "fieldType"], "additionalProperties": false, - "title": "Create knowledge folder request", - "description": "Workspace and canonical path for a new knowledge-base folder." + "title": "Knowledge tag definition input", + "description": "One tag definition declared in a bulk save." }, - "RelocateKnowledgeFolderRequest": { + "BulkSaveKnowledgeTagDefinitionsRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace containing the folder." - }, - "path": { - "description": "Current folder path.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "description": "Workspace that owns the knowledge base." }, - "destinationPath": { - "description": "New full path for the folder and its descendants.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "definitions": { + "minItems": 1, + "maxItems": 17, + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BulkSaveKnowledgeTagDefinition" + }, + "description": "Tag definitions to create or update on the knowledge base." } }, - "required": ["workspaceId", "path", "destinationPath"], + "required": ["workspaceId", "definitions"], "additionalProperties": false, - "title": "Relocate knowledge folder request", - "description": "Current and destination canonical paths for a knowledge-base folder." + "title": "Bulk save tag definitions request", + "description": "Workspace scope and the tag definitions to create or update.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "definitions": [ + { + "tagSlot": "tag1", + "displayName": "category", + "fieldType": "text" + } + ] + } + ] }, - "V2DeleteKnowledgeFolderData": { + "V2DeleteKnowledgeTagDefinitionsData": { "type": "object", "properties": { - "path": { - "type": "string", - "title": "Folder path", - "description": "Canonical path of the deleted folder.", - "maxLength": 4096 - }, - "deleted": { + "unused": { "type": "boolean", - "const": true, - "description": "Confirms that the folder was deleted." + "description": "Whether the delete was restricted to definitions no document still uses." }, - "deletedItems": { - "type": "object", - "properties": { - "folders": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of deleted folders." - }, - "knowledgeBases": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of deleted knowledge bases." - } - }, - "required": ["folders", "knowledgeBases"], - "additionalProperties": false, - "description": "Counts of deleted resources." + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of tag definitions removed." } }, - "required": ["path", "deleted", "deletedItems"], + "required": ["unused", "count"], "additionalProperties": false, - "title": "Delete knowledge folder data", - "description": "Folder deletion acknowledgement and deleted-resource counts." + "title": "Delete knowledge tag definitions data", + "description": "Outcome of a knowledge-base tag-definition delete." }, - "V2DeleteKnowledgeFolderResponse": { + "V2DeleteKnowledgeTagDefinitionsResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2DeleteKnowledgeFolderData" + "$ref": "#/components/schemas/V2DeleteKnowledgeTagDefinitionsData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete knowledge folder response", - "description": "Folder deletion acknowledgement and deleted-resource counts." + "title": "Delete tag definitions response", + "description": "Number of tag definitions that were removed." } } }, diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 13ab0b2b1b3..08535cf3ef1 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Logs", - "description": "Version 2 of the Sim REST API for listing workflow execution logs and retrieving complete diagnostic run snapshots.", + "description": "Version 2 of the Sim REST API for workflow execution logs: listing and sorting runs with filters, retrieving complete diagnostic run snapshots, and reading bucketed execution statistics.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -36,7 +36,7 @@ "get": { "operationId": "listLogs", "summary": "List Logs", - "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", + "description": "List workflow execution logs for a workspace with filters, selectable detail, sorting by start time, duration, cost, or status, and opaque cursor pagination. Chat and Sim-agent job runs join the sequence with `includeJobRuns=true`, which is accepted only under `sortBy=startedAt` — their cost is stored as a document and their status is not comparable, so they cannot participate in the other orderings. Each item's `files` lists only the files the run itself produced, addressed by `downloadPath`; input attachments a caller supplied are read through the files API instead. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -55,20 +55,20 @@ "name": "workflowIds", "in": "query", "required": false, - "description": "Comma-separated workflow identifiers to include. An empty entry is rejected.", + "description": "Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries.", "schema": { "type": "string", - "description": "Comma-separated workflow identifiers to include. An empty entry is rejected." + "description": "Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries." } }, { "name": "triggers", "in": "query", "required": false, - "description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.", + "description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries.", "schema": { "type": "string", - "description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`." + "description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries." } }, { @@ -168,12 +168,12 @@ "name": "details", "in": "query", "required": false, - "description": "Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.", + "description": "Response detail level. `full` adds the `workflow` summary to every workflow run; a job run never carries one, whatever this is set to. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.", "schema": { "default": "basic", "type": "string", "enum": ["basic", "full"], - "description": "Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly." + "description": "Response detail level. `full` adds the `workflow` summary to every workflow run; a job run never carries one, whatever this is set to. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly." } }, { @@ -219,15 +219,35 @@ } }, { - "name": "order", + "name": "status", "in": "query", "required": false, - "description": "Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", + "description": "Comma-separated execution statuses to include, from `pending` | `running` | `paused` | `redacting` | `completed` | `failed` | `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle.", "schema": { - "default": "desc", - "description": "Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "type": "string", - "enum": ["asc", "desc"] + "description": "Comma-separated execution statuses to include, from `pending` | `running` | `paused` | `redacting` | `completed` | `failed` | `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle." + } + }, + { + "name": "workflowName", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable." + } + }, + { + "name": "includeJobRuns", + "in": "query", + "required": false, + "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "schema": { + "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "type": "boolean" } }, { @@ -243,14 +263,38 @@ "description": "Exact run identifier to match." } }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`.", + "schema": { + "default": "startedAt", + "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`.", + "type": "string", + "enum": ["startedAt", "durationMs", "cost", "status"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, { "name": "folderPaths", "in": "query", "required": false, - "description": "Comma-separated workflow folder paths to include. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Comma-separated workflow folder paths to include. At most 100 entries. A path covers its whole subtree, so `/prod` also selects runs in `/prod/nested`. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { "type": "string", - "description": "Comma-separated workflow folder paths to include. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error." + "description": "Comma-separated workflow folder paths to include. At most 100 entries. A path covers its whole subtree, so `/prod` also selects runs in `/prod/nested`. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error." } } ], @@ -288,6 +332,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -304,7 +351,7 @@ "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.", + "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none. A workspace folder tree over 10,000 folders is a `413`. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", "tags": ["Logs"], "parameters": [ { @@ -355,6 +402,156 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/logs/stats": { + "get": { + "operationId": "getLogStats", + "summary": "Get Log Statistics", + "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans the oldest matching run through the later of the newest matching run and now, divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Logs"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose execution statistics to summarize.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose execution statistics to summarize." + } + }, + { + "name": "workflowIds", + "in": "query", + "required": false, + "description": "Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected.", + "schema": { + "type": "string", + "description": "Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected." + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Comma-separated workflow folder paths to include. At most 100 entries. A path covers its whole subtree. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "schema": { + "type": "string", + "description": "Comma-separated workflow folder paths to include. At most 100 entries. A path covers its whole subtree. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error." + } + }, + { + "name": "triggers", + "in": "query", + "required": false, + "description": "Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter.", + "schema": { + "type": "string", + "description": "Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter." + } + }, + { + "name": "level", + "in": "query", + "required": false, + "description": "Severity level to include.", + "schema": { + "type": "string", + "enum": ["info", "error"], + "description": "Severity level to include." + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "segmentCount", + "in": "query", + "required": false, + "description": "Number of equal time buckets to divide the window into, from 1 to 500. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.", + "schema": { + "default": 72, + "description": "Number of equal time buckets to divide the window into, from 1 to 500. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.", + "type": "integer", + "minimum": 1, + "maximum": 500 + } + } + ], + "responses": { + "200": { + "description": "Bucketed execution statistics for the workspace.", + "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/V2LogStatsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -496,6 +693,22 @@ } } }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, "RateLimited": { "description": "The caller exceeded the request rate limit.", "headers": { @@ -574,7 +787,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." } }, "required": ["code", "message"], @@ -598,6 +811,11 @@ "V2LogListItem": { "type": "object", "properties": { + "kind": { + "type": "string", + "enum": ["workflow", "job"], + "description": "Whether the run executed a workflow or a Chat / Sim-agent job. Job runs appear only when `includeJobRuns=true`." + }, "runId": { "type": "string", "description": "Unique run identifier." @@ -692,21 +910,21 @@ "type": "null" } ], - "description": "Cost charged for the run, or null when unavailable." + "description": "Cost charged for the run, or null when the run has neither a recorded total nor an itemized ledger." }, "files": { "anyOf": [ { "type": "array", "items": { - "description": "Attachment metadata captured for the execution." + "$ref": "#/components/schemas/V2LogFile" } }, { "type": "null" } ], - "description": "Files attached to the run, or null when none are recorded." + "description": "Files the run produced, or null when none are recorded. Only the run's own output files appear; input attachments a caller supplied are addressed through the files API instead." }, "workflow": { "type": "object", @@ -758,6 +976,7 @@ } }, "required": [ + "kind", "runId", "workflowId", "deploymentVersionId", @@ -774,6 +993,37 @@ "title": "Execution log summary", "description": "Summary information for one workflow execution log." }, + "V2LogFile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier to address this file by on the download endpoint." + }, + "name": { + "type": "string", + "description": "File name, including its extension." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "File size in bytes." + }, + "type": { + "type": "string", + "description": "MIME type recorded for the file." + }, + "downloadPath": { + "type": "string", + "description": "Path to fetch this file's bytes from, relative to the API host." + } + }, + "required": ["id", "name", "size", "type", "downloadPath"], + "additionalProperties": false, + "title": "Execution log file", + "description": "A file produced by the run this log records." + }, "LogTraceSpan": { "title": "Log trace span", "description": "One recursive operation span in a workflow execution trace.", @@ -972,6 +1222,7 @@ { "data": [ { + "kind": "workflow", "runId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "deploymentVersionId": "dep_2c4e6a8b0d1f", @@ -984,7 +1235,15 @@ "cost": { "total": 0.0032 }, - "files": null + "files": [ + { + "id": "f1c3a7d0-4b52-4a8e-9f61-2d7c8b3e5a04", + "name": "summary.pdf", + "size": 18422, + "type": "application/pdf", + "downloadPath": "/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/files/f1c3a7d0-4b52-4a8e-9f61-2d7c8b3e5a04" + } + ] } ], "nextCursor": "eyJzdGFydGVkQXQiOiIyMDI2LTAxLTE1VDEwOjMwOjAwMFoifQ==" @@ -1076,14 +1335,14 @@ { "type": "array", "items": { - "description": "Attachment metadata captured for the execution." + "$ref": "#/components/schemas/V2LogFile" } }, { "type": "null" } ], - "description": "Files attached to the run, or null when none are recorded." + "description": "Files the run produced, or null when none are recorded. Only the run's own output files appear; input attachments a caller supplied are addressed through the files API instead." }, "workflow": { "type": "object", @@ -1238,9 +1497,49 @@ "total": { "type": "number", "description": "Total execution cost in USD." + }, + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["fixed", "model", "tool"], + "description": "What the line is for: the run's base fee (`fixed`), one model's inference (`model`), or one metered tool or integration call (`tool`)." + }, + "description": { + "type": "string", + "description": "Human-readable name of the billed item, such as the model or tool id." + }, + "cost": { + "type": "number", + "description": "Amount billed for this line, in USD." + }, + "inputTokens": { + "description": "Input tokens attributed to this line. Absent for lines that do not bill tokens.", + "type": "number" + }, + "outputTokens": { + "description": "Output tokens attributed to this line. Absent for lines that do not bill tokens.", + "type": "number" + } + }, + "required": ["category", "description", "cost"], + "additionalProperties": false, + "description": "One billed line of a run, folded across every event that billed it." + } + }, + { + "type": "null" + } + ], + "description": "Billed lines reconciling to `total`, or null when no itemized ledger exists for the run." } }, - "required": ["total"], + "required": ["total", "items"], "additionalProperties": false }, { @@ -1249,6 +1548,17 @@ ], "description": "Cost charged for the run, or null when unavailable." }, + "workflowInput": { + "anyOf": [ + { + "description": "Caller-supplied trigger payload for the run." + }, + { + "type": "null" + } + ], + "description": "Input the run was triggered with, or null when the run recorded none. Credential-bearing and PII-masked values are redacted the same way `finalOutput` is." + }, "createdAt": { "type": "string", "format": "date-time", @@ -1272,6 +1582,7 @@ "traceSpans", "finalOutput", "cost", + "workflowInput", "createdAt" ], "additionalProperties": false, @@ -1323,12 +1634,224 @@ "result": "Hello, world!" }, "cost": { - "total": 0.0032 + "total": 0.0032, + "items": [ + { + "category": "fixed", + "description": "Base execution charge", + "cost": 0.001 + }, + { + "category": "model", + "description": "gpt-5", + "cost": 0.0022, + "inputTokens": 1840, + "outputTokens": 260 + } + ] + }, + "workflowInput": { + "ticketId": "T-4821" }, "createdAt": "2026-01-15T10:30:00.000Z" } } ] + }, + "V2WorkflowLogStats": { + "type": "object", + "properties": { + "workflowId": { + "type": "string", + "description": "Workflow identifier, or the literal `deleted` for the single series that collects runs whose workflow no longer exists." + }, + "workflowName": { + "type": "string", + "description": "Workflow name, or `Deleted Workflow`." + }, + "segments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2LogStatsSegment" + }, + "description": "One entry per bucket, in order, including buckets with no runs." + }, + "totalExecutions": { + "type": "number", + "description": "Runs for this workflow across the window." + }, + "totalSuccessful": { + "type": "number", + "description": "Runs for this workflow that did not error." + }, + "overallSuccessRate": { + "type": "number", + "description": "Percentage of runs that did not error, from 0 to 100. 100 when there were no runs." + } + }, + "required": [ + "workflowId", + "workflowName", + "segments", + "totalExecutions", + "totalSuccessful", + "overallSuccessRate" + ], + "additionalProperties": false, + "title": "Per-workflow log stats", + "description": "Bucketed run counts and success rate for one workflow." + }, + "V2LogStatsSegment": { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 start of the bucket." + }, + "totalExecutions": { + "type": "number", + "description": "Runs that started inside the bucket." + }, + "successfulExecutions": { + "type": "number", + "description": "Runs in the bucket that did not error." + }, + "avgDurationMs": { + "type": "number", + "description": "Mean duration of the bucket's runs in milliseconds, weighted by run count. Zero when no run in the bucket recorded a duration." + } + }, + "required": ["timestamp", "totalExecutions", "successfulExecutions", "avgDurationMs"], + "additionalProperties": false, + "title": "Log stats bucket", + "description": "Run counts and mean latency for one time bucket." + }, + "V2LogStats": { + "type": "object", + "properties": { + "workflows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2WorkflowLogStats" + }, + "description": "Per-workflow series, ordered by error rate descending then by name, capped at 200 entries." + }, + "workflowsTruncated": { + "type": "boolean", + "description": "Whether `workflows` was cut to 200 entries. The workspace totals and `aggregateSegments` are computed from every workflow before the cut, so they stay exact either way." + }, + "aggregateSegments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2LogStatsSegment" + }, + "description": "Workspace-wide totals per bucket, in the same order as each workflow series." + }, + "totalRuns": { + "type": "number", + "description": "Runs in the window across the whole workspace." + }, + "totalErrors": { + "type": "number", + "description": "Runs in the window that errored." + }, + "avgLatency": { + "type": "number", + "description": "Mean run duration in milliseconds across the window, weighted by run count." + }, + "timeBounds": { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 start of the window." + }, + "end": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 end of the window." + } + }, + "required": ["start", "end"], + "additionalProperties": false, + "description": "The window the buckets span: the oldest matching run through the later of the newest matching run and now. A workspace with no matching runs reports the trailing 24 hours." + }, + "segmentMs": { + "type": "number", + "description": "Width of one bucket in milliseconds." + } + }, + "required": [ + "workflows", + "workflowsTruncated", + "aggregateSegments", + "totalRuns", + "totalErrors", + "avgLatency", + "timeBounds", + "segmentMs" + ], + "additionalProperties": false, + "title": "Execution log statistics", + "description": "Bucketed success rate, error count, and latency for a workspace and each of its workflows." + }, + "V2LogStatsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2LogStats" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Log statistics response", + "description": "Bucketed success rate, error count, and latency for a workspace and its workflows.", + "examples": [ + { + "data": { + "workflows": [ + { + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowName": "Customer Support Agent", + "segments": [ + { + "timestamp": "2026-01-15T10:00:00.000Z", + "totalExecutions": 40, + "successfulExecutions": 38, + "avgDurationMs": 1180 + } + ], + "totalExecutions": 40, + "totalSuccessful": 38, + "overallSuccessRate": 95 + } + ], + "workflowsTruncated": false, + "aggregateSegments": [ + { + "timestamp": "2026-01-15T10:00:00.000Z", + "totalExecutions": 40, + "successfulExecutions": 38, + "avgDurationMs": 1180 + } + ], + "totalRuns": 40, + "totalErrors": 2, + "avgLatency": 1180, + "timeBounds": { + "start": "2026-01-15T10:00:00.000Z", + "end": "2026-01-15T22:00:00.000Z" + }, + "segmentMs": 600000 + } + } + ] } } }, diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index a1d045f0cc5..d27fa9a8371 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Workspace Resources", - "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, and write-only secrets.", + "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, write-only secrets, and the block, tool, and connector-type catalogs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -21,6 +21,10 @@ } ], "tags": [ + { + "name": "Meta", + "description": "Discover what the calling API key can reach." + }, { "name": "Workspaces", "description": "Read workspace metadata and its effective member roster." @@ -44,6 +48,10 @@ { "name": "Secrets", "description": "Set and manage write-only workspace and personal secret values." + }, + { + "name": "Catalog", + "description": "Discover the blocks, tools, and connector types this workspace can build with." } ], "security": [ @@ -314,7 +322,7 @@ "get": { "operationId": "listMcpServers", "summary": "List MCP Servers", - "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.", + "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{mcpServerId}/tools` runs a discovery.", "tags": ["MCP Servers"], "parameters": [ { @@ -438,7 +446,7 @@ "post": { "operationId": "createMcpServer", "summary": "Create MCP Server", - "description": "Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.", + "description": "Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{mcpServerId}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{mcpServerId}/tools` succeeds.", "tags": ["MCP Servers"], "requestBody": { "required": true, @@ -491,6 +499,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -503,7 +514,7 @@ } } }, - "/api/v2/mcp-servers/{id}": { + "/api/v2/mcp-servers/{mcpServerId}": { "get": { "operationId": "getMcpServer", "summary": "Get MCP Server", @@ -511,7 +522,7 @@ "tags": ["MCP Servers"], "parameters": [ { - "name": "id", + "name": "mcpServerId", "in": "path", "required": true, "description": "Unique MCP server identifier.", @@ -586,7 +597,7 @@ "tags": ["MCP Servers"], "parameters": [ { - "name": "id", + "name": "mcpServerId", "in": "path", "required": true, "description": "Unique MCP server identifier.", @@ -645,6 +656,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -663,7 +677,7 @@ "tags": ["MCP Servers"], "parameters": [ { - "name": "id", + "name": "mcpServerId", "in": "path", "required": true, "description": "Unique MCP server identifier.", @@ -732,7 +746,7 @@ } } }, - "/api/v2/mcp-servers/{id}/tools": { + "/api/v2/mcp-servers/{mcpServerId}/tools": { "get": { "operationId": "listMcpServerTools", "summary": "List MCP Server Tools", @@ -740,7 +754,7 @@ "tags": ["MCP Servers"], "parameters": [ { - "name": "id", + "name": "mcpServerId", "in": "path", "required": true, "description": "Unique MCP server identifier.", @@ -1003,6 +1017,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1015,7 +1032,7 @@ } } }, - "/api/v2/skills/{id}": { + "/api/v2/skills/{skillId}": { "get": { "operationId": "getSkill", "summary": "Get Skill", @@ -1023,7 +1040,7 @@ "tags": ["Skills"], "parameters": [ { - "name": "id", + "name": "skillId", "in": "path", "required": true, "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", @@ -1098,7 +1115,7 @@ "tags": ["Skills"], "parameters": [ { - "name": "id", + "name": "skillId", "in": "path", "required": true, "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", @@ -1160,6 +1177,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1178,7 +1198,7 @@ "tags": ["Skills"], "parameters": [ { - "name": "id", + "name": "skillId", "in": "path", "required": true, "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", @@ -1247,7 +1267,7 @@ } } }, - "/api/v2/skills/{id}/editors": { + "/api/v2/skills/{skillId}/editors": { "get": { "operationId": "listSkillEditors", "summary": "List Skill Editors", @@ -1255,7 +1275,7 @@ "tags": ["Skills"], "parameters": [ { - "name": "id", + "name": "skillId", "in": "path", "required": true, "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", @@ -1378,7 +1398,7 @@ "tags": ["Skills"], "parameters": [ { - "name": "id", + "name": "skillId", "in": "path", "required": true, "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", @@ -1458,6 +1478,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1476,7 +1499,7 @@ "tags": ["Skills"], "parameters": [ { - "name": "id", + "name": "skillId", "in": "path", "required": true, "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", @@ -1738,6 +1761,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1750,7 +1776,7 @@ } } }, - "/api/v2/custom-tools/{id}": { + "/api/v2/custom-tools/{customToolId}": { "get": { "operationId": "getCustomTool", "summary": "Get Custom Tool", @@ -1758,7 +1784,7 @@ "tags": ["Custom Tools"], "parameters": [ { - "name": "id", + "name": "customToolId", "in": "path", "required": true, "description": "Unique custom tool identifier.", @@ -1833,7 +1859,7 @@ "tags": ["Custom Tools"], "parameters": [ { - "name": "id", + "name": "customToolId", "in": "path", "required": true, "description": "Unique custom tool identifier.", @@ -1895,6 +1921,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1913,7 +1942,7 @@ "tags": ["Custom Tools"], "parameters": [ { - "name": "id", + "name": "customToolId", "in": "path", "required": true, "description": "Unique custom tool identifier.", @@ -2206,6 +2235,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2353,6 +2385,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2376,12 +2411,12 @@ "name": "credentialId", "in": "path", "required": true, - "description": "Credential to disconnect.", + "description": "Credential to update or disconnect.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, - "description": "Credential to disconnect." + "description": "Credential to update or disconnect." } }, { @@ -2441,13 +2476,109 @@ "$ref": "#/components/responses/ServiceUnavailable" } } + }, + "patch": { + "operationId": "updateCredential", + "summary": "Update Credential", + "description": "Rotate a service-account credential's secret material, or rename it. Send only the fields to change: an omitted field is left unchanged, and `description: null` clears the stored description. Secret fields are write-only and are never returned, and only a service-account credential has any: sending one for a credential of another type answers `400` rather than dropping it. The provider re-verifies replacement secret material before it replaces the stored secret, so a rejected secret leaves the stored one untouched and answers `400` with the provider's code in `error.details.providerErrorCode`; a provider that cannot be reached answers `503`. The credential ID is preserved, so every workflow, deployment, paused run, knowledge connector, and webhook that references it keeps working — which disconnecting and re-creating does not. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "parameters": [ + { + "name": "credentialId", + "in": "path", + "required": true, + "description": "Credential to update or disconnect.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential to update or disconnect." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace expected to own the credential.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." + } + } + ], + "requestBody": { + "required": true, + "description": "Replacement display metadata and the write-only fields declared by provider discovery.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCredentialRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated credential without secret material.", + "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/UpdateCredentialResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } } }, "/api/v2/secrets": { "get": { "operationId": "listSecrets", "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, description, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { @@ -2670,6 +2801,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2769,1724 +2903,1555 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } - }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "/api/v2/meta": { + "get": { + "operationId": "getApiMeta", + "summary": "Get API Capabilities", + "description": "Report facts about the calling API key: whether it is in the v2 rollout cohort, whether it is personal or workspace-scoped, and when it expires. Every other v2 endpoint answers 404 both when the path does not exist and when your credential is not in the rollout cohort; call this endpoint to tell the two apart. It is the one v2 endpoint the rollout gate does not apply to, and it still requires a valid key.", + "tags": ["Meta"], + "responses": { + "200": { + "description": "Rollout and lifecycle facts about the calling key.", + "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" + } }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetApiMetaResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "Unauthorized": { - "description": "The API key is missing or invalid.", - "content": { - "application/json": { + } + }, + "/api/v2/workflow-mcp-servers": { + "get": { + "operationId": "listWorkflowMcpServers", + "summary": "List Workflow MCP Servers", + "description": "List the MCP servers a workspace *publishes*. These serve deployed workflows as tools to outside MCP clients, which is the opposite direction from `GET /api/v2/mcp-servers` — that lists external servers Sim calls. Each entry carries the endpoint clients connect to and the tool names it exposes; those names are gathered under a 2,000-tool budget shared across the page, so on a page of unusually large servers the trailing entries can list fewer names than they publish. Read one server's full inventory with `GET /api/v2/workflow-mcp-servers/{serverId}/tools`. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose published MCP servers to list.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "API key required" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose published MCP servers to list." } - } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" - } - } + "default": "createdAt", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] } - } - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum workflow-MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "API key name already exists" - } + "default": 50, + "description": "Maximum workflow-MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 } - } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { + }, + { + "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": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } + "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 } } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + ], + "responses": { + "200": { + "description": "A page of published MCP servers.", + "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" + } }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkflowMcpServersResponse" } } } + }, + "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" } } }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" + "post": { + "operationId": "createWorkflowMcpServer", + "summary": "Create Workflow MCP Server", + "description": "Publish a new MCP server for a workspace, optionally seeding it with workflows to expose as tools. Every workflow named in `workflowIds` must already be deployed. Setting `isPublic` lets any MCP client holding the server URL execute the workflows it publishes without a Sim API key. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "requestBody": { + "required": true, + "description": "A new workspace-published MCP server and the workflows it exposes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowMcpServerRequest" } } } - } - }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } - } - } - } - } - }, - "schemas": { - "V2Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." + "responses": { + "201": { + "description": "The published MCP server.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." - } - }, - "required": ["error"], - "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowMcpServerResponse" + } + } } - } - ] - }, - "V2Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." }, - "name": { - "type": "string", - "description": "Workspace display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "color": { - "type": "string", - "description": "Workspace color as a hexadecimal color value." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "logoUrl": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workspace logo URL, or null when none is configured." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "memberCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of effective members, including inherited organization administrators." + "404": { + "$ref": "#/components/responses/NotFound" }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the workspace was created." + "409": { + "$ref": "#/components/responses/Conflict" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the workspace was last updated." - } - }, - "required": ["id", "name", "color", "logoUrl", "memberCount", "createdAt", "updatedAt"], - "additionalProperties": false, - "title": "Workspace", - "description": "Public metadata for an accessible workspace." - }, - "ListWorkspacesResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2Workspace" - }, - "description": "Items in the current page." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List workspaces response", - "description": "Public metadata for workspaces available to the API key.", - "examples": [ - { - "data": [ - { - "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Engineering", - "color": "#33C482", - "logoUrl": null, - "memberCount": 14, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "nextCursor": null - } - ] - }, - "GetWorkspaceResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Workspace" + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get workspace response", - "description": "Public metadata for one workspace.", - "examples": [ + } + } + }, + "/api/v2/workflow-mcp-servers/{serverId}": { + "get": { + "operationId": "getWorkflowMcpServer", + "summary": "Get Workflow MCP Server", + "description": "Read one published MCP server. The list is the only other place this state is published, so a caller holding a server id would otherwise have to page the collection and filter client-side. The tools it publishes are on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ { - "data": { - "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Engineering", - "color": "#33C482", - "logoUrl": null, - "memberCount": 14, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "name": "serverId", + "in": "path", + "required": true, + "description": "Unique workflow-MCP server identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow-MCP server identifier." } } - ] - }, - "V2WorkspaceMember": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Member email address and public member identifier." - }, - "name": { - "type": "string", - "description": "Member display name." - }, - "image": { - "anyOf": [ - { - "type": "string" + ], + "responses": { + "200": { + "description": "The MCP server.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "description": "Member profile image URL, or null when absent." + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkflowMcpServerResponse" + } + } + } }, - "role": { - "type": "string", - "enum": ["admin", "write", "read"], - "description": "Effective role in the workspace." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "isExternal": { - "type": "boolean", - "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "joinedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when access was granted." + "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" } - }, - "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], - "additionalProperties": false, - "title": "Workspace member", - "description": "An effective workspace member and their public access role." + } }, - "ListWorkspaceMembersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2WorkspaceMember" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "patch": { + "operationId": "updateWorkflowMcpServer", + "summary": "Update Workflow MCP Server", + "description": "Rename, re-describe, or change the public visibility of a published MCP server. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the description. Publishing and unpublishing the workflows it serves are separate operations on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "description": "Unique workflow-MCP server identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow-MCP server identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Merge-patch body for a published MCP server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowMcpServerRequest" } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } } }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List workspace members response", - "description": "A cursor-paginated page of effective workspace members.", - "examples": [ - { - "data": [ - { - "email": "jane@example.com", - "name": "Jane Smith", - "image": null, - "role": "admin", - "isExternal": false, - "joinedAt": "2026-01-15T10:30:00.000Z" + "responses": { + "200": { + "description": "The updated MCP server.", + "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" } - ], - "nextCursor": null - } - ] - }, - "V2McpServer": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique server identifier derived from the workspace and endpoint URL." + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowMcpServerResponse" + } + } + } }, - "name": { - "type": "string", - "description": "Server display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "Optional server description.", - "type": "string" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "transport": { - "default": "streamable-http", - "description": "Transport used to communicate with the server.", - "type": "string", - "enum": ["streamable-http"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "authType": { - "description": "Authentication method used by the server.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "404": { + "$ref": "#/components/responses/NotFound" }, - "url": { - "description": "Server endpoint URL.", - "type": "string" + "409": { + "$ref": "#/components/responses/Conflict" }, - "timeout": { - "description": "Per-request timeout in milliseconds.", - "type": "number" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "retries": { - "description": "Number of retries attempted per request.", - "type": "number" + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, - "enabled": { - "type": "boolean", - "description": "Whether the server tools are available to workflows." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "connectionStatus": { - "description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.", - "type": "string", - "enum": ["connected", "disconnected", "error"] + "500": { + "$ref": "#/components/responses/InternalError" }, - "lastError": { - "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", - "anyOf": [ - { - "type": "string" + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteWorkflowMcpServer", + "summary": "Delete Workflow MCP Server", + "description": "Unpublish an MCP server. Every tool it served stops answering and connected clients lose the endpoint. The workflows themselves are untouched — their own deployments stay live and executable through the workflow API. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "description": "Unique workflow-MCP server identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow-MCP server identifier." + } + } + ], + "responses": { + "200": { + "description": "The MCP server was unpublished.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "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/DeleteWorkflowMcpServerResponse" + } + } + } }, - "toolCount": { - "description": "Number of tools discovered on the server.", - "type": "number" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "lastToolsRefresh": { - "description": "ISO 8601 timestamp of the most recent tool-list refresh.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "lastConnected": { - "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "createdAt": { - "description": "ISO 8601 timestamp when the server was registered.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "404": { + "$ref": "#/components/responses/NotFound" }, - "updatedAt": { - "description": "ISO 8601 timestamp when the server was last updated.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "409": { + "$ref": "#/components/responses/Conflict" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier, when configured.", - "type": "string" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "hasHeaders": { - "type": "boolean", - "description": "Whether any request headers are configured." + "500": { + "$ref": "#/components/responses/InternalError" }, - "headerNames": { - "type": "array", - "items": { + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflow-mcp-servers/{serverId}/tools": { + "get": { + "operationId": "listWorkflowMcpTools", + "summary": "List Workflow MCP Tools", + "description": "Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the `workflowId` that `DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}` addresses. Returned in one page rather than paged — so `nextCursor` is always null — and capped at 2,000 tools, which is far above any real server's inventory. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "description": "Unique workflow-MCP server identifier.", + "schema": { "type": "string", - "description": "Configured header name." - }, - "description": "Names of configured request headers. Header values are never returned." - }, - "hasOauthClientSecret": { - "type": "boolean", - "description": "Whether an OAuth client secret is stored. The value is never returned." + "minLength": 1, + "description": "Unique workflow-MCP server identifier." + } } - }, - "required": [ - "id", - "name", - "transport", - "enabled", - "createdAt", - "updatedAt", - "hasHeaders", - "headerNames", - "hasOauthClientSecret" ], - "additionalProperties": false, - "title": "MCP server", - "description": "Public MCP server configuration without write-only credential values." - }, - "ListMcpServersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpServer" + "responses": { + "200": { + "description": "The tools this server publishes.", + "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" + } }, - "description": "Items in the current page." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkflowMcpToolsResponse" + } + } + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "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" + } + } + }, + "post": { + "operationId": "deployWorkflowMcpTool", + "summary": "Publish Workflow As MCP Tool", + "description": "Publish a deployed workflow as a tool on an MCP server. The tool's input schema is generated from the deployed workflow's input format, so the workflow must already be deployed. Idempotent per workflow: a server carries at most one tool per workflow, so a repeat call replaces the existing tool and answers `200` with `updated: true` rather than conflicting. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "description": "Unique workflow-MCP server identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow-MCP server identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "The workflow to publish and the tool metadata MCP clients see.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployWorkflowMcpToolRequest" } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } } }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List MCP servers response", - "description": "MCP servers registered in the workspace.", - "examples": [ - { - "data": [ - { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "responses": { + "200": { + "description": "The published tool.", + "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/DeployWorkflowMcpToolResponse" + } } - ], - "nextCursor": null - } - ] - }, - "CreateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create MCP server response", - "description": "The registered MCP server without write-only credentials.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "disconnected", - "lastError": null, - "toolCount": 0, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false } - } - ] - }, - "CreateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to register the server." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + "400": { + "$ref": "#/components/responses/BadRequest" }, - "transport": { - "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "404": { + "$ref": "#/components/responses/NotFound" }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { - "type": "string", - "minLength": 1 - }, - "additionalProperties": { - "type": "string", - "description": "Header value sent to the MCP server." - } + "409": { + "$ref": "#/components/responses/Conflict" }, - "timeout": { - "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "retries": { - "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, - "enabled": { - "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", - "default": true, - "type": "boolean" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 - }, - { - "type": "null" - } - ] + "500": { + "$ref": "#/components/responses/InternalError" }, - "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", - "writeOnly": true, - "anyOf": [ - { - "type": "string", - "maxLength": 2048 - }, - { - "type": "null" - } - ] + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["workspaceId", "name", "url"], - "additionalProperties": false, - "title": "Create MCP server request", - "description": "Configuration for a new MCP server.", - "examples": [ + } + } + }, + "/api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}": { + "delete": { + "operationId": "undeployWorkflowMcpTool", + "summary": "Unpublish Workflow MCP Tool", + "description": "Remove a workflow from an MCP server. Addressed by workflow rather than by tool identifier, because a server carries at most one live tool per workflow. The workflow's own deployment is untouched. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Docs server", - "url": "https://mcp.example.com/sse", - "authType": "headers", - "headers": { - "Authorization": "Bearer YOUR_TOKEN" + "name": "serverId", + "in": "path", + "required": true, + "description": "Unique workflow-MCP server identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow-MCP server identifier." } - } - ] - }, - "GetMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get MCP server response", - "description": "One MCP server without write-only credentials.", - "examples": [ + }, { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "name": "workflowId", + "in": "path", + "required": true, + "description": "Workflow published as a tool on this server.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workflow published as a tool on this server." } } - ] - }, - "UpdateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update MCP server response", - "description": "The updated MCP server.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": false, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + ], + "responses": { + "200": { + "description": "The tool was removed.", + "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/UndeployWorkflowMcpToolResponse" + } + } } - } - ] - }, - "UpdateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the MCP server." }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "transport": { - "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "url": { - "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", - "type": "string", - "minLength": 1, - "maxLength": 2048 + "404": { + "$ref": "#/components/responses/NotFound" }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "409": { + "$ref": "#/components/responses/Conflict" }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { - "type": "string", - "minLength": 1 - }, - "additionalProperties": { + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/blocks": { + "get": { + "operationId": "listBlocks", + "summary": "List Blocks", + "description": "List the blocks available in a workspace, built-in and workspace-deployed alike, discriminated by `source`. Availability is caller-specific: the workspace’s integration allowlist, the organization’s revealed preview blocks, and the deployment’s allowlist all narrow the result. Use `capability=trigger` for the blocks that can start a workflow. Summaries name their tools and operations by id — resolve one with Get Block or Get Tool.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { "type": "string", - "description": "Header value sent to the MCP server." + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." } }, - "timeout": { - "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 - }, - "retries": { - "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the block id, name, and description.", + "schema": { + "description": "Case-insensitive substring match against the block id, name, and description.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "enabled": { - "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", - "default": true, - "type": "boolean" + { + "name": "category", + "in": "query", + "required": false, + "description": "Restrict to one toolbar category.", + "schema": { + "description": "Restrict to one toolbar category.", + "type": "string", + "enum": ["blocks", "tools", "triggers"] + } }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 - }, - { - "type": "null" - } - ] + { + "name": "capability", + "in": "query", + "required": false, + "description": "Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields.", + "schema": { + "description": "Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields.", + "type": "string", + "enum": ["trigger"] + } }, - "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", - "writeOnly": true, - "anyOf": [ - { - "type": "string", - "maxLength": 2048 - }, - { - "type": "null" - } - ] - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update MCP server request", - "description": "MCP server fields to change; omitted fields retain their stored values.", - "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "enabled": false - } - ] - }, - "V2McpServerDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted MCP server." + "name": "source", + "in": "query", + "required": false, + "description": "Restrict to shipped blocks or to this workspace’s deployed custom blocks.", + "schema": { + "description": "Restrict to shipped blocks or to this workspace’s deployed custom blocks.", + "type": "string", + "enum": ["builtin", "custom"] + } }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the server was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete MCP server data", - "description": "MCP server deletion acknowledgement." - }, - "DeleteMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServerDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete MCP server response", - "description": "Acknowledgement that the MCP server was deleted.", - "examples": [ { - "data": { - "id": "mcp-3f7a9c21", - "deleted": true + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "id", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["id", "name", "category"] } - } - ] - }, - "V2McpTool": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Tool name, as the MCP server reports it." }, - "description": { - "description": "Tool description reported by the server.", - "type": "string" + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "inputSchema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "object", - "description": "JSON Schema type of the argument object. MCP requires `object`." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum blocks to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum blocks 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 + } + } + ], + "responses": { + "200": { + "description": "A page of blocks available in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "properties": { - "description": "Argument schemas keyed by argument name.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Server-defined JSON Schema for one tool argument." - } + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - "required": { - "description": "Names of the arguments the tool requires.", - "type": "array", - "items": { - "type": "string", - "description": "Name of a required argument." - } + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "required": ["type"], - "additionalProperties": { - "description": "Additional JSON Schema keyword reported by the server." - }, - "description": "JSON Schema for the tool's arguments, as reported by the server." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBlocksResponse" + } + } + } }, - "serverId": { - "type": "string", - "description": "Identifier of the MCP server exposing the tool." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "serverName": { - "type": "string", - "description": "Display name of the MCP server exposing the tool." + "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" } - }, - "required": ["name", "inputSchema", "serverId", "serverName"], - "additionalProperties": false, - "title": "MCP tool", - "description": "A tool exposed by a registered MCP server." - }, - "ListMcpServerToolsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpTool" - }, - "description": "Items in the current page." + } + } + }, + "/api/v2/blocks/{blockId}": { + "get": { + "operationId": "getBlock", + "summary": "Get Block", + "description": "Read one block’s full configuration shape: its fields and their conditions, its operations with the tool each runs, every tool’s parameters and outputs, and its triggers. An unversioned base type resolves to the newest version this caller can see — `confluence` answers with `confluence_v2` — and the returned `id` is always the resolved one, matching Get Tool. A block this caller cannot see answers 404, identically to one that does not exist.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "blockId", + "in": "path", + "required": true, + "description": "Block type identifier. An unversioned base type resolves to the newest version, and the response echoes the resolved id.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Block type identifier. An unversioned base type resolves to the newest version, and the response echoes the resolved id." + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." + } + } + ], + "responses": { + "200": { + "description": "The block.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List MCP server tools response", - "description": "Tools exposed by the MCP server.", - "examples": [ - { - "data": [ - { - "name": "search_docs", - "description": "Search the internal documentation", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search terms" - } - }, - "required": ["query"] - }, - "serverId": "mcp-3f7a9c21", - "serverName": "Docs server" + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBlockResponse" + } } - ], - "nextCursor": null - } - ] - }, - "V2SkillSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + } }, - "name": { - "type": "string", - "description": "Kebab-case name that agents use to reference the skill." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "type": "string", - "description": "One-line summary of when the skill applies." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "readOnly": { - "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + "404": { + "$ref": "#/components/responses/NotFound" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." - } - }, - "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], - "additionalProperties": false, - "title": "Skill summary", - "description": "Public summary metadata for a workspace or built-in skill." - }, - "ListSkillsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2SkillSummary" - }, - "description": "Items in the current page." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List skills response", - "description": "Skill summaries available in the workspace.", - "examples": [ - { - "data": [ - { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "nextCursor": null - } - ] - }, - "V2Skill": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + } + } + }, + "/api/v2/tools": { + "get": { + "operationId": "listTools", + "summary": "List Tools", + "description": "List the built-in tools available in a workspace. Built-in tools only: a workspace’s MCP tools are discovered per server on List MCP Server Tools, and its code-backed custom tools are on List Custom Tools. A tool is available when a block the caller can see exposes it, so the same allowlist and visibility rules as List Blocks apply.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." + } }, - "name": { - "type": "string", - "description": "Kebab-case name that agents use to reference the skill." + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the tool id, name, and description.", + "schema": { + "description": "Case-insensitive substring match against the tool id, name, and description.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "description": { - "type": "string", - "description": "One-line summary of when the skill applies." + { + "name": "hostedApiKey", + "in": "query", + "required": false, + "description": "Restrict to tools by how their API key is supplied.", + "schema": { + "description": "Restrict to tools by how their API key is supplied.", + "type": "string", + "enum": ["always", "conditional", "none"] + } }, - "readOnly": { - "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." + { + "name": "oauthProvider", + "in": "query", + "required": false, + "description": "Restrict to tools that authenticate against this OAuth service.", + "schema": { + "description": "Restrict to tools that authenticate against this OAuth service.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "id", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["id", "name"] + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "content": { - "type": "string", - "description": "Skill body containing the instructions given to the agent." - } - }, - "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], - "additionalProperties": false, - "title": "Skill", - "description": "A workspace or built-in skill including its instruction body." - }, - "CreateSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create skill response", - "description": "The created skill including its content.", - "examples": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum tools to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum tools 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 } } - ] - }, - "CreateSkillRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the skill." + ], + "responses": { + "200": { + "description": "A page of built-in tools available in the workspace.", + "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/ListToolsResponse" + } + } + } }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", - "description": "Kebab-case name, unique within the workspace and not reserved by a built-in skill." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "One-line summary of when the skill applies." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "content": { - "type": "string", - "minLength": 1, - "maxLength": 50000, - "description": "Skill body containing the instructions given to the agent." - } - }, - "required": ["workspaceId", "name", "description", "content"], - "additionalProperties": false, - "title": "Create skill request", - "description": "Definition of a new skill.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "refund-policy", - "description": "How support should handle refund requests", - "content": "# Refund policy\n\nAlways check the order date first." - } - ] - }, - "GetSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" + "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" } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get skill response", - "description": "One skill including its full content.", - "examples": [ + } + } + }, + "/api/v2/tools/{toolId}": { + "get": { + "operationId": "getTool", + "summary": "Get Tool", + "description": "Read one built-in tool’s declared parameters and outputs. A name that is itself a registered id answers as that exact tool; a name that is not resolves to the newest version of its family. The returned `id` is always the one that answered, so a caller can see which version it got. A tool the workspace’s visible blocks do not expose answers `404`, identically to one that does not exist.", + "tags": ["Catalog"], + "parameters": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "name": "toolId", + "in": "path", + "required": true, + "description": "Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id." } - } - ] - }, - "UpdateSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update skill response", - "description": "The updated skill including its full content.", - "examples": [ + }, { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "Updated refund guidance", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." } } - ] - }, - "UpdateSkillRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the skill." + ], + "responses": { + "200": { + "description": "The tool.", + "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/GetToolResponse" + } + } + } }, - "name": { - "description": "New kebab-case skill name.", - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "New one-line summary of when the skill applies.", - "type": "string", - "minLength": 1, - "maxLength": 1024 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "content": { - "description": "Replacement skill body.", - "type": "string", - "minLength": 1, - "maxLength": 50000 + "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" } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update skill request", - "description": "Skill fields to change; at least one editable field is required.", - "examples": [ + } + } + }, + "/api/v2/connector-types": { + "get": { + "operationId": "listConnectorTypes", + "summary": "List Connector Types", + "description": "List every knowledge-base connector type and the source configuration each accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with `multi: true` stores a `string[]` rather than a `string`, and a `canonicalParamId` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by `canonicalParamId` rather than by the field's own `id`. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Catalog"], + "parameters": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "description": "Updated refund guidance" - } - ] - }, - "V2SkillDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted skill." + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." + } }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the skill was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete skill data", - "description": "Skill deletion acknowledgement." - }, - "DeleteSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SkillDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete skill response", - "description": "Acknowledgement that the skill was deleted.", - "examples": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the connector name.", + "schema": { + "description": "Case-insensitive substring match against the connector name.", + "type": "string", + "minLength": 1, + "maxLength": 200 } } - ] - }, - "V2SkillEditor": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Email address of the skill editor." - }, - "name": { - "anyOf": [ - { - "type": "string" + ], + "responses": { + "200": { + "description": "The connector-type catalog.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" - } - ], - "description": "Display name of the skill editor." - }, - "image": { - "anyOf": [ - { - "type": "string" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - { - "type": "null" + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "description": "Profile image URL of the skill editor." - }, - "isWorkspaceAdmin": { - "type": "boolean", - "description": "Whether editor access is derived from workspace administration." - } - }, - "required": ["email", "name", "image", "isWorkspaceAdmin"], - "additionalProperties": false, - "title": "Skill editor", - "description": "Public identity fields for a user who can edit a skill." - }, - "ListSkillEditorsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2SkillEditor" }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListConnectorTypesResponse" + } } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "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" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List skill editors response", - "description": "Public identity fields for users who can edit the skill.", - "examples": [ - { - "data": [ - { - "email": "jane@example.com", - "name": "Jane Smith", - "image": null, - "isWorkspaceAdmin": false + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" } - ], - "nextCursor": null + } } - ] + } }, - "GrantSkillEditorResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SkillEditor" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Grant skill editor response", - "description": "Public identity fields for the editor.", - "examples": [ - { - "data": { - "email": "jane@example.com", - "name": "Jane Smith", - "image": null, - "isWorkspaceAdmin": false + "Unauthorized": { + "description": "The API key is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } - ] + } }, - "GrantSkillEditorRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the skill." - }, - "email": { - "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Email address of a current workspace member." + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } } - }, - "required": ["workspaceId", "email"], - "additionalProperties": false, - "title": "Grant skill editor request", - "description": "Workspace scope and email of the member to grant.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "email": "jane@example.com" + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } } - ] + } }, - "V2SkillEditorDeleteData": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Email address whose explicit editor grant was revoked." - }, - "revoked": { - "type": "boolean", - "const": true, - "description": "Whether the explicit editor grant was revoked." + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "API key name already exists" + } + } } - }, - "required": ["email", "revoked"], - "additionalProperties": false, - "title": "Revoke skill editor data", - "description": "Skill editor revocation acknowledgement." + } }, - "RevokeSkillEditorResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SkillEditorDeleteData" + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Revoke skill editor response", - "description": "Acknowledgement that the explicit editor grant was revoked.", - "examples": [ - { - "data": { - "email": "jane@example.com", - "revoked": true + } + }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } } } - ] + } }, - "V2CustomTool": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique custom tool identifier." - }, - "title": { - "type": "string", - "description": "Display title, unique within the workspace." - }, - "schema": { + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "properties": { + "error": { "type": "object", "properties": { - "type": { + "code": { "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "description": "Stable machine-readable error code." }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." } }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "V2Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." }, - "code": { + "name": { "type": "string", - "description": "Tool implementation executed in the sandboxed function runtime." + "description": "Workspace display name." + }, + "color": { + "type": "string", + "description": "Workspace color as a hexadecimal color value." + }, + "logoUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workspace logo URL, or null when none is configured." + }, + "memberCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of effective members, including inherited organization administrators." }, "createdAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the tool was created." + "description": "ISO 8601 timestamp when the workspace was created." }, "updatedAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the tool was last updated." + "description": "ISO 8601 timestamp when the workspace was last updated." } }, - "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "required": ["id", "name", "color", "logoUrl", "memberCount", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Custom tool", - "description": "A workspace custom tool and its callable function declaration." + "title": "Workspace", + "description": "Public metadata for an accessible workspace." }, - "ListCustomToolsResponse": { + "ListWorkspacesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2Workspace" }, "description": "Items in the current page." }, @@ -4504,32 +4469,18 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List custom tools response", - "description": "Custom tools defined in the workspace.", + "title": "List workspaces response", + "description": "Public metadata for workspaces available to the API key.", "examples": [ { "data": [ { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", + "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Engineering", + "color": "#33C482", + "logoUrl": null, + "memberCount": 14, + "createdAt": "2026-01-15T10:30:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } ], @@ -4537,415 +4488,168 @@ } ] }, - "CreateCustomToolResponse": { + "GetWorkspaceResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2Workspace" } }, "required": ["data"], "additionalProperties": false, - "title": "Create custom tool response", - "description": "The created custom tool.", + "title": "Get workspace response", + "description": "Public metadata for one workspace.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", + "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Engineering", + "color": "#33C482", + "logoUrl": null, + "memberCount": 14, + "createdAt": "2026-01-15T10:30:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "CreateCustomToolRequest": { + "V2WorkspaceMember": { "type": "object", "properties": { - "workspaceId": { + "email": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the custom tool." + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Member email address and public member identifier." }, - "title": { + "name": { "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Display title, unique within the workspace." + "description": "Member display name." }, - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "image": { + "anyOf": [ + { + "type": "string" }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." + { + "type": "null" } - }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + ], + "description": "Member profile image URL, or null when absent." }, - "code": { + "role": { "type": "string", - "maxLength": 100000, - "description": "Tool implementation executed in the sandboxed function runtime." + "enum": ["admin", "write", "read"], + "description": "Effective role in the workspace." + }, + "isExternal": { + "type": "boolean", + "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when access was granted." } }, - "required": ["workspaceId", "title", "schema", "code"], + "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], "additionalProperties": false, - "title": "Create custom tool request", - "description": "Definition and implementation of a new custom tool.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }" - } - ] + "title": "Workspace member", + "description": "An effective workspace member and their public access role." }, - "GetCustomToolResponse": { + "ListWorkspaceMembersResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get custom tool response", - "description": "One custom tool.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } + "type": "array", + "items": { + "$ref": "#/components/schemas/V2WorkspaceMember" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] - }, - "UpdateCustomToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Update custom tool response", - "description": "The updated custom tool.", + "title": "List workspace members response", + "description": "A cursor-paginated page of effective workspace members.", "examples": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: false }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } + "data": [ + { + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "role": "admin", + "isExternal": false, + "joinedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null } ] }, - "UpdateCustomToolRequest": { + "V2McpServer": { "type": "object", "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the custom tool." + "description": "Unique server identifier derived from the workspace and endpoint URL." }, - "title": { - "description": "New display title for the tool.", + "name": { "type": "string", - "minLength": 1, - "maxLength": 200 + "description": "Server display name." }, - "schema": { - "description": "Replacement function declaration.", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." - }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." - } - }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - } + "description": { + "description": "Optional server description.", + "type": "string" }, - "code": { - "description": "Replacement tool implementation.", - "type": "string", - "maxLength": 100000 - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update custom tool request", - "description": "Custom tool fields to change; at least one editable field is required.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "code": "return { ok: false }" - } - ] - }, - "V2CustomToolDeleteData": { - "type": "object", - "properties": { - "id": { + "transport": { + "default": "streamable-http", + "description": "Transport used to communicate with the server.", "type": "string", - "description": "Identifier of the deleted custom tool." + "enum": ["streamable-http"] }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the custom tool was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete custom tool data", - "description": "Custom tool deletion acknowledgement." - }, - "DeleteCustomToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomToolDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete custom tool response", - "description": "Acknowledgement that the custom tool was deleted.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true - } - } - ] - }, - "V2Credential": { - "type": "object", - "properties": { - "id": { + "authType": { + "description": "Authentication method used by the server.", "type": "string", - "description": "Unique credential identifier." + "enum": ["none", "headers", "oauth"] }, - "type": { - "type": "string", - "enum": ["oauth", "service_account"], - "description": "Authenticated connection type." + "url": { + "description": "Server endpoint URL.", + "type": "string" }, - "displayName": { - "type": "string", - "description": "Credential display name." + "timeout": { + "description": "Per-request timeout in milliseconds.", + "type": "number" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional credential description." + "retries": { + "description": "Number of retries attempted per request.", + "type": "number" }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Integration provider authenticated by this credential." + "enabled": { + "type": "boolean", + "description": "Whether the server tools are available to workflows." }, - "accountId": { + "connectionStatus": { + "description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.", + "type": "string", + "enum": ["connected", "disconnected", "error"] + }, + "lastError": { + "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", "anyOf": [ { "type": "string" @@ -4953,54 +4657,79 @@ { "type": "null" } - ], - "description": "Linked account identifier for OAuth credentials." + ] }, - "hasServiceAccountKey": { - "type": "boolean", - "description": "Whether a service-account payload is stored. Its contents are never returned." + "toolCount": { + "description": "Number of tools discovered on the server.", + "type": "number" }, - "role": { + "lastToolsRefresh": { + "description": "ISO 8601 timestamp of the most recent tool-list refresh.", "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the credential." + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "lastConnected": { + "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, "createdAt": { + "description": "ISO 8601 timestamp when the server was registered.", "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was created." + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, "updatedAt": { + "description": "ISO 8601 timestamp when the server was last updated.", "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was last updated." + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier, when configured.", + "type": "string" + }, + "hasHeaders": { + "type": "boolean", + "description": "Whether any request headers are configured." + }, + "headerNames": { + "type": "array", + "items": { + "type": "string", + "description": "Configured header name." + }, + "description": "Names of configured request headers. Header values are never returned." + }, + "hasOauthClientSecret": { + "type": "boolean", + "description": "Whether an OAuth client secret is stored. The value is never returned." } }, "required": [ "id", - "type", - "displayName", - "description", - "providerId", - "accountId", - "hasServiceAccountKey", - "role", + "name", + "transport", + "enabled", "createdAt", - "updatedAt" + "updatedAt", + "hasHeaders", + "headerNames", + "hasOauthClientSecret" ], "additionalProperties": false, - "title": "Credential", - "description": "Public authenticated-connection metadata without secret material." + "title": "MCP server", + "description": "Public MCP server configuration without write-only credential values." }, - "ListCredentialsResponse": { + "ListMcpServersResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/V2McpServer" }, "description": "Items in the current page." }, @@ -5018,276 +4747,3412 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List credentials response", - "description": "Credential metadata visible to the caller.", + "title": "List MCP servers response", + "description": "MCP servers registered in the workspace.", "examples": [ { "data": [ { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } ], "nextCursor": null } ] }, - "V2CredentialProvider": { - "oneOf": [ + "CreateMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2McpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create MCP server response", + "description": "The registered MCP server without write-only credentials.", + "examples": [ { + "data": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "disconnected", + "lastError": null, + "toolCount": 0, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + ] + }, + "CreateMcpServerRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to register the server." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Server display name." + }, + "description": { + "description": "Optional server description.", + "type": "string", + "maxLength": 2000 + }, + "transport": { + "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", + "default": "streamable-http", + "type": "string", + "enum": ["streamable-http"] + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + }, + "authType": { + "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", + "writeOnly": true, "type": "object", - "properties": { - "type": { - "type": "string", - "const": "oauth", - "description": "Browser-based OAuth connection method." - }, - "serviceId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Stable credential-provider identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Credential provider display name." - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Credential provider description." - }, - "providerFamily": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Owning provider family identifier." - }, - "available": { - "type": "boolean", - "description": "Whether this caller can connect the provider in the current deployment." - }, - "supportsReconnect": { - "type": "boolean", - "description": "Whether existing credentials for this service can be reconnected." - }, - "authorizationOptions": { - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { - "type": "object", - "properties": { - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact OAuth provider identifier accepted by the connection endpoint." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable authorization-server label." - } - }, - "required": ["providerId", "label"], - "additionalProperties": false - }, - "description": "Authorization servers available for this OAuth service." - } + "propertyNames": { + "type": "string", + "minLength": 1 }, - "required": [ - "type", - "serviceId", - "name", - "description", - "providerFamily", - "available", - "supportsReconnect", - "authorizationOptions" - ], - "additionalProperties": false + "additionalProperties": { + "type": "string", + "description": "Header value sent to the MCP server." + } }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "service_account", - "description": "Direct service-account credential method." - }, - "serviceId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Stable credential-provider identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Credential provider display name." - }, - "description": { + "timeout": { + "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", + "default": 30000, + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", + "default": true, + "type": "boolean" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", + "anyOf": [ + { "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Credential provider description." + "maxLength": 512 }, - "providerFamily": { + { + "type": "null" + } + ] + }, + "oauthClientSecret": { + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", + "writeOnly": true, + "anyOf": [ + { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Owning provider family identifier." - }, - "available": { - "type": "boolean", - "description": "Whether this caller can connect the provider in the current deployment." + "maxLength": 2048 }, - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact service-account provider ID accepted by credential creation." - }, - "docsUrl": { + { + "type": "null" + } + ] + } + }, + "required": ["workspaceId", "name", "url"], + "additionalProperties": false, + "title": "Create MCP server request", + "description": "Configuration for a new MCP server.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Docs server", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + ] + }, + "GetMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2McpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get MCP server response", + "description": "One MCP server without write-only credentials.", + "examples": [ + { + "data": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + ] + }, + "UpdateMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2McpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update MCP server response", + "description": "The updated MCP server.", + "examples": [ + { + "data": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": false, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + ] + }, + "UpdateMcpServerRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the MCP server." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Server display name." + }, + "description": { + "description": "Optional server description.", + "type": "string", + "maxLength": 2000 + }, + "transport": { + "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", + "default": "streamable-http", + "type": "string", + "enum": ["streamable-http"] + }, + "url": { + "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "authType": { + "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", + "writeOnly": true, + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "description": "Header value sent to the MCP server." + } + }, + "timeout": { + "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", + "default": 30000, + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", + "default": true, + "type": "boolean" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", + "anyOf": [ + { "type": "string", - "format": "uri", - "description": "Setup guide for the provider." + "maxLength": 512 }, - "helpText": { - "description": "Provider-specific setup guidance.", + { + "type": "null" + } + ] + }, + "oauthClientSecret": { + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", + "writeOnly": true, + "anyOf": [ + { "type": "string", - "minLength": 1, - "maxLength": 2000 - }, - "requiresClientGeneratedCredentialId": { - "type": "boolean", - "description": "Whether the caller must generate and submit the credential ID before setup." + "maxLength": 2048 }, - "fields": { - "minItems": 1, - "maxItems": 20, - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact create-body field name." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable field label." - }, - "placeholder": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Suggested input placeholder." - }, - "required": { - "type": "boolean", - "description": "Whether the field is required for the selected flow." - }, - "secret": { - "type": "boolean", - "description": "Whether the submitted field is write-only secret material." - }, - "multiline": { - "type": "boolean", - "description": "Whether the field is intended for multi-line input." - }, - "requiredForAuthMethods": { - "description": "Authentication methods for which this field is required.", - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "options": { - "description": "Fixed values accepted by a selector field.", - "minItems": 1, - "maxItems": 20, - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Submitted option value." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable option label." - } - }, - "required": ["value", "label"], - "additionalProperties": false - } - }, - "hint": { - "description": "Provider-specific setup guidance.", + { + "type": "null" + } + ] + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update MCP server request", + "description": "MCP server fields to change; omitted fields retain their stored values.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "enabled": false + } + ] + }, + "V2McpServerDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted MCP server." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the server was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete MCP server data", + "description": "MCP server deletion acknowledgement." + }, + "DeleteMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2McpServerDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete MCP server response", + "description": "Acknowledgement that the MCP server was deleted.", + "examples": [ + { + "data": { + "id": "mcp-3f7a9c21", + "deleted": true + } + } + ] + }, + "V2McpTool": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Tool name, as the MCP server reports it." + }, + "description": { + "description": "Tool description reported by the server.", + "type": "string" + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "object", + "description": "JSON Schema type of the argument object. MCP requires `object`." + }, + "properties": { + "description": "Argument schemas keyed by argument name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Server-defined JSON Schema for one tool argument." + } + }, + "required": { + "description": "Names of the arguments the tool requires.", + "type": "array", + "items": { + "type": "string", + "description": "Name of a required argument." + } + } + }, + "required": ["type"], + "additionalProperties": { + "description": "Additional JSON Schema keyword reported by the server." + }, + "description": "JSON Schema for the tool's arguments, as reported by the server." + }, + "serverId": { + "type": "string", + "description": "Identifier of the MCP server exposing the tool." + }, + "serverName": { + "type": "string", + "description": "Display name of the MCP server exposing the tool." + } + }, + "required": ["name", "inputSchema", "serverId", "serverName"], + "additionalProperties": false, + "title": "MCP tool", + "description": "A tool exposed by a registered MCP server." + }, + "ListMcpServerToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2McpTool" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List MCP server tools response", + "description": "Tools exposed by the MCP server.", + "examples": [ + { + "data": [ + { + "name": "search_docs", + "description": "Search the internal documentation", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string", - "minLength": 1, - "maxLength": 2000 + "description": "Search terms" } }, - "required": ["id", "label", "placeholder", "required", "secret", "multiline"], - "additionalProperties": false + "required": ["query"] }, - "description": "Create-body fields accepted by this provider. Secret fields are write-only." + "serverId": "mcp-3f7a9c21", + "serverName": "Docs server" + } + ], + "nextCursor": null + } + ] + }, + "V2SkillSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + }, + "name": { + "type": "string", + "description": "Kebab-case name that agents use to reference the skill." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "Whether this is a built-in skill that cannot be modified or deleted." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + } + }, + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Skill summary", + "description": "Public summary metadata for a workspace or built-in skill." + }, + "ListSkillsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2SkillSummary" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List skills response", + "description": "Skill summaries available in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "V2Skill": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + }, + "name": { + "type": "string", + "description": "Kebab-case name that agents use to reference the skill." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "Whether this is a built-in skill that cannot be modified or deleted." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + }, + "content": { + "type": "string", + "description": "Skill body containing the instructions given to the agent." + } + }, + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], + "additionalProperties": false, + "title": "Skill", + "description": "A workspace or built-in skill including its instruction body." + }, + "CreateSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create skill response", + "description": "The created skill including its content.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + ] + }, + "CreateSkillRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the skill." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Kebab-case name, unique within the workspace and not reserved by a built-in skill." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "Skill body containing the instructions given to the agent." + } + }, + "required": ["workspaceId", "name", "description", "content"], + "additionalProperties": false, + "title": "Create skill request", + "description": "Definition of a new skill.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first." + } + ] + }, + "GetSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get skill response", + "description": "One skill including its full content.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + ] + }, + "UpdateSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update skill response", + "description": "The updated skill including its full content.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "Updated refund guidance", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + ] + }, + "UpdateSkillRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the skill." + }, + "name": { + "description": "New kebab-case skill name.", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "description": { + "description": "New one-line summary of when the skill applies.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "content": { + "description": "Replacement skill body.", + "type": "string", + "minLength": 1, + "maxLength": 50000 + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update skill request", + "description": "Skill fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "description": "Updated refund guidance" + } + ] + }, + "V2SkillDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted skill." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the skill was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete skill data", + "description": "Skill deletion acknowledgement." + }, + "DeleteSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SkillDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete skill response", + "description": "Acknowledgement that the skill was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + ] + }, + "V2SkillEditor": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Email address of the skill editor." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the skill editor." + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL of the skill editor." + }, + "isWorkspaceAdmin": { + "type": "boolean", + "description": "Whether editor access is derived from workspace administration." + } + }, + "required": ["email", "name", "image", "isWorkspaceAdmin"], + "additionalProperties": false, + "title": "Skill editor", + "description": "Public identity fields for a user who can edit a skill." + }, + "ListSkillEditorsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2SkillEditor" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List skill editors response", + "description": "Public identity fields for users who can edit the skill.", + "examples": [ + { + "data": [ + { + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "isWorkspaceAdmin": false + } + ], + "nextCursor": null + } + ] + }, + "GrantSkillEditorResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SkillEditor" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Grant skill editor response", + "description": "Public identity fields for the editor.", + "examples": [ + { + "data": { + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "isWorkspaceAdmin": false + } + } + ] + }, + "GrantSkillEditorRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the skill." + }, + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Email address of a current workspace member." + } + }, + "required": ["workspaceId", "email"], + "additionalProperties": false, + "title": "Grant skill editor request", + "description": "Workspace scope and email of the member to grant.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "email": "jane@example.com" + } + ] + }, + "V2SkillEditorDeleteData": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Email address whose explicit editor grant was revoked." + }, + "revoked": { + "type": "boolean", + "const": true, + "description": "Whether the explicit editor grant was revoked." + } + }, + "required": ["email", "revoked"], + "additionalProperties": false, + "title": "Revoke skill editor data", + "description": "Skill editor revocation acknowledgement." + }, + "RevokeSkillEditorResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SkillEditorDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revoke skill editor response", + "description": "Acknowledgement that the explicit editor grant was revoked.", + "examples": [ + { + "data": { + "email": "jane@example.com", + "revoked": true + } + } + ] + }, + "V2CustomTool": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique custom tool identifier." + }, + "title": { + "type": "string", + "description": "Display title, unique within the workspace." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "description": "Tool implementation executed in the sandboxed function runtime." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the tool was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the tool was last updated." + } + }, + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Custom tool", + "description": "A workspace custom tool and its callable function declaration." + }, + "ListCustomToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CustomTool" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List custom tools response", + "description": "Custom tools defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create custom tool response", + "description": "The created custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the custom tool." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "Tool implementation executed in the sandboxed function runtime." + } + }, + "required": ["workspaceId", "title", "schema", "code"], + "additionalProperties": false, + "title": "Create custom tool request", + "description": "Definition and implementation of a new custom tool.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }" + } + ] + }, + "GetCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get custom tool response", + "description": "One custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update custom tool response", + "description": "The updated custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: false }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the custom tool." + }, + "title": { + "description": "New display title for the tool.", + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "schema": { + "description": "Replacement function declaration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + } + }, + "code": { + "description": "Replacement tool implementation.", + "type": "string", + "maxLength": 100000 + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update custom tool request", + "description": "Custom tool fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "code": "return { ok: false }" + } + ] + }, + "V2CustomToolDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted custom tool." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the custom tool was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete custom tool data", + "description": "Custom tool deletion acknowledgement." + }, + "DeleteCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomToolDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete custom tool response", + "description": "Acknowledgement that the custom tool was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + ] + }, + "V2Credential": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique credential identifier." + }, + "type": { + "type": "string", + "enum": ["oauth", "service_account"], + "description": "Authenticated connection type." + }, + "displayName": { + "type": "string", + "description": "Credential display name." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional credential description." + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Integration provider authenticated by this credential." + }, + "accountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Linked account identifier for OAuth credentials." + }, + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account payload is stored. Its contents are never returned." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the credential." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was last updated." + } + }, + "required": [ + "id", + "type", + "displayName", + "description", + "providerId", + "accountId", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Credential", + "description": "Public authenticated-connection metadata without secret material." + }, + "ListCredentialsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Credential" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List credentials response", + "description": "Credential metadata visible to the caller.", + "examples": [ + { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "V2CredentialProvider": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false + }, + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "service_account", + "description": "Direct service-account credential method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID accepted by credential creation." + }, + "docsUrl": { + "type": "string", + "format": "uri", + "description": "Setup guide for the provider." + }, + "helpText": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "requiresClientGeneratedCredentialId": { + "type": "boolean", + "description": "Whether the caller must generate and submit the credential ID before setup." + }, + "fields": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } + }, + "required": ["value", "label"], + "additionalProperties": false + } + }, + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false + }, + "description": "Create-body fields accepted by this provider. Secret fields are write-only." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false + } + ], + "title": "Credential Provider", + "description": "An OAuth or service-account connection method available to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List credential providers response", + "description": "OAuth and service-account connection methods.", + "examples": [ + { + "data": [ + { + "type": "oauth", + "serviceId": "salesforce", + "name": "Salesforce", + "description": "Connect to Salesforce CRM data and operations.", + "providerFamily": "salesforce", + "available": true, + "supportsReconnect": true, + "authorizationOptions": [ + { + "providerId": "salesforce", + "label": "Production" + }, + { + "providerId": "salesforce-sandbox", + "label": "Sandbox" + } + ] + }, + { + "type": "service_account", + "serviceId": "zoom-service-account", + "providerId": "zoom-service-account", + "name": "Zoom server-to-server app", + "description": "Connect Zoom with a server-to-server app.", + "providerFamily": "zoom", + "available": true, + "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", + "requiresClientGeneratedCredentialId": false, + "fields": [ + { + "id": "clientId", + "label": "Client ID", + "placeholder": "Paste the client ID", + "required": true, + "secret": false, + "multiline": false + }, + { + "id": "clientSecret", + "label": "Client secret", + "placeholder": "Paste the client secret", + "required": true, + "secret": true, + "multiline": false + }, + { + "id": "orgId", + "label": "Account ID", + "placeholder": "Paste the account ID", + "required": true, + "secret": false, + "multiline": false + } + ] + } + ], + "nextCursor": null + } + ] + }, + "CreateServiceAccountCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateServiceAccountCredentialRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "type": { + "type": "string", + "const": "service_account", + "description": "Service-account credential discriminator." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." + }, + "displayName": { + "description": "Optional name; providers may derive one from the verified account identity.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 + }, + "id": { + "description": "Required only when provider discovery requests a client-generated ID.", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "credentials": { + "type": "string", + "minLength": 1, + "maxLength": 131072, + "description": "Write-only JSON object string containing the fields declared by credential-provider discovery.", + "writeOnly": true + } + }, + "required": ["workspaceId", "type", "providerId", "credentials"], + "additionalProperties": false, + "title": "Create service-account credential request", + "description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery." + }, + "V2CredentialConnectionAuthorization": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the connection link expires." + } + }, + "required": ["authorizationUrl", "expiresAt"], + "additionalProperties": false, + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." + }, + "CreateCredentialConnectionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", + "examples": [ + { + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } + } + ] + }, + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider ID returned by credential-provider discovery." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + } + }, + "required": ["workspaceId", "providerId", "displayName"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." + }, + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place." + } + }, + "required": ["workspaceId", "credentialId"], + "additionalProperties": false + } + ], + "title": "Create credential connection body", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, + "V2CredentialDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Disconnected credential identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the credential was disconnected." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete credential data", + "description": "Credential disconnection acknowledgement." + }, + "DeleteCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Disconnect credential response", + "description": "Acknowledgement that the credential was disconnected.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } + } + ] + }, + "V2Secret": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the secret." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was last updated." + } + }, + "required": ["name", "scope", "description", "role", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Secret metadata", + "description": "Public secret metadata without the stored secret value." + }, + "ListSecretsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Secret" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List secrets response", + "description": "Secret metadata visible to the caller without stored values.", + "examples": [ + { + "data": [ + { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "description": "Production billing key — rotate quarterly.", + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "SetSecretResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Secret" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Set secret response", + "description": "Metadata for the created or replaced secret without its value.", + "examples": [ + { + "data": { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "description": "Production billing key — rotate quarterly.", + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "SetSecretRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 65536, + "description": "Write-only secret value. It is never returned.", + "writeOnly": true + }, + "description": { + "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + } + }, + "required": ["workspaceId", "scope", "value"], + "additionalProperties": false, + "title": "Set secret request", + "description": "Ownership scope and write-only value for the secret.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "value": "YOUR_SECRET_VALUE" + } + ] + }, + "V2SecretDeleteData": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the secret was deleted." + } + }, + "required": ["name", "scope", "deleted"], + "additionalProperties": false, + "title": "Delete secret data", + "description": "Secret deletion acknowledgement without the stored value." + }, + "DeleteSecretResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SecretDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete secret response", + "description": "Acknowledgement that the secret was deleted.", + "examples": [ + { + "data": { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "deleted": true + } + } + ] + }, + "V2Meta": { + "type": "object", + "properties": { + "v2Enabled": { + "type": "boolean", + "description": "Whether this credential is in the v2 rollout cohort. When false, every other v2 endpoint answers 404 for this credential." + }, + "keyType": { + "type": "string", + "enum": ["personal", "workspace"], + "description": "Whether the calling key carries the full authority of its owner across their workspaces, or is scoped to one workspace." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the calling key expires, or null when it never does." + } + }, + "required": ["v2Enabled", "keyType", "expiresAt"], + "additionalProperties": false, + "title": "API capabilities", + "description": "Rollout and lifecycle facts about the calling API key." + }, + "GetApiMetaResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Meta" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "API capabilities response", + "description": "Rollout cohort, key type, and expiry for the calling key.", + "examples": [ + { + "data": { + "v2Enabled": true, + "keyType": "personal", + "expiresAt": null + } + } + ] + }, + "WorkflowMcpServerListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow-MCP server identifier." + }, + "name": { + "type": "string", + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional server description, or null when unset." + }, + "isPublic": { + "type": "boolean", + "description": "Whether the server answers MCP clients without a Sim API key." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to. Published here so callers never build it.", + "examples": ["https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2"] + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was last modified.", + "format": "date-time" + }, + "toolCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of workflows published as tools." + }, + "toolNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names this server publishes, alphabetically ordered." + } + }, + "required": [ + "id", + "name", + "description", + "isPublic", + "mcpServerUrl", + "createdAt", + "updatedAt", + "toolCount", + "toolNames" + ], + "additionalProperties": false, + "title": "Workflow MCP server list item", + "description": "A published MCP server together with the tool names it exposes." + }, + "ListWorkflowMcpServersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowMcpServerListItem" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List workflow MCP servers response", + "description": "A cursor-paginated page of published MCP servers.", + "examples": [ + { + "data": [ + { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z", + "toolCount": 1, + "toolNames": ["triage_ticket"] + } + ], + "nextCursor": null + } + ] + }, + "WorkflowMcpServer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow-MCP server identifier." + }, + "name": { + "type": "string", + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional server description, or null when unset." + }, + "isPublic": { + "type": "boolean", + "description": "Whether the server answers MCP clients without a Sim API key." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to. Published here so callers never build it.", + "examples": ["https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2"] + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "isPublic", + "mcpServerUrl", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP server", + "description": "A workspace-published MCP server exposing deployed workflows as tools." + }, + "CreateWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create workflow MCP server response", + "description": "The published MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "CreateWorkflowMcpServerRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to publish the server." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "description": "Optional server description.", + "type": "string", + "maxLength": 2000 + }, + "isPublic": { + "description": "Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL.", + "default": false, + "type": "boolean" + }, + "workflowIds": { + "description": "Deployed workflows to publish as tools on the new server.", + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create workflow MCP server request", + "description": "A new workspace-published MCP server and the workflows it exposes.", + "examples": [ + { + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "name": "Support agents", + "workflowIds": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } + ] + }, + "GetWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get workflow MCP server response", + "description": "A single published MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "WorkflowMcpToolListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tool identifier." + }, + "serverId": { + "type": "string", + "description": "Server that publishes this tool." + }, + "workflowId": { + "type": "string", + "description": "Workflow this tool executes." + }, + "toolName": { + "type": "string", + "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." + }, + "toolDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Description shown to MCP clients." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to." + }, + "apiEndpoint": { + "type": "string", + "description": "Sim execution endpoint this tool calls through." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "serverId", + "workflowId", + "toolName", + "toolDescription", + "mcpServerUrl", + "apiEndpoint", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP tool list item", + "description": "A tool a server publishes, as returned by a read." + }, + "ListWorkflowMcpToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowMcpToolListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List workflow MCP tools response", + "description": "The tools a published MCP server exposes.", + "examples": [ + { + "data": [ + { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket", + "toolDescription": "Execute Ticket triage workflow", + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "UpdateWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow MCP server response", + "description": "The updated MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": true, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "UpdateWorkflowMcpServerRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "description": "New server description, or null to clear it.", + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ] + }, + "isPublic": { + "description": "Whether the server answers MCP clients without a Sim API key.", + "type": "boolean" + } + }, + "additionalProperties": false, + "title": "Update workflow MCP server request", + "description": "Merge-patch body for a published MCP server.", + "examples": [ + { + "isPublic": true + } + ] + }, + "DeleteWorkflowMcpServerResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the unpublished server." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the server was unpublished." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete workflow MCP server result", + "description": "Unpublish acknowledgement." + }, + "DeleteWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/DeleteWorkflowMcpServerResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete workflow MCP server response", + "description": "Acknowledgement that the MCP server was unpublished.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "deleted": true + } + } + ] + }, + "WorkflowMcpTool": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tool identifier." + }, + "serverId": { + "type": "string", + "description": "Server that publishes this tool." + }, + "workflowId": { + "type": "string", + "description": "Workflow this tool executes." + }, + "toolName": { + "type": "string", + "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." + }, + "toolDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Description shown to MCP clients." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to." + }, + "apiEndpoint": { + "type": "string", + "description": "Sim execution endpoint this tool calls through." + }, + "updated": { + "type": "boolean", + "description": "False when the workflow was newly published on this server, true when an existing tool was replaced. Publishing is idempotent per workflow, so a repeat call answers 200 with true rather than conflicting." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "serverId", + "workflowId", + "toolName", + "toolDescription", + "mcpServerUrl", + "apiEndpoint", + "updated", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP tool", + "description": "A deployed workflow published as a tool on a workflow-MCP server." + }, + "DeployWorkflowMcpToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Publish workflow as MCP tool response", + "description": "The published tool.", + "examples": [ + { + "data": { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket", + "toolDescription": "Execute Ticket triage workflow", + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", + "updated": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "DeployWorkflowMcpToolRequest": { + "type": "object", + "properties": { + "workflowId": { + "type": "string", + "minLength": 1, + "description": "Deployed workflow to publish. The workflow must already be deployed." + }, + "toolName": { + "description": "Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "toolDescription": { + "description": "Description shown to MCP clients. Derived from the workflow name when omitted.", + "type": "string", + "maxLength": 2000 + }, + "parameterDescriptions": { + "description": "Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored.", + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Input field of the deployed workflow to describe." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2000, + "description": "Text MCP clients see for that field." + } + }, + "required": ["name", "description"], + "additionalProperties": false + } + } + }, + "required": ["workflowId"], + "additionalProperties": false, + "title": "Publish workflow as MCP tool request", + "description": "The workflow to publish and the tool metadata MCP clients see.", + "examples": [ + { + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket" + } + ] + }, + "UndeployWorkflowMcpToolResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the removed tool." + }, + "serverId": { + "type": "string", + "description": "Server the tool was removed from." + }, + "workflowId": { + "type": "string", + "description": "Workflow that is no longer published." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the tool was removed." + } + }, + "required": ["id", "serverId", "workflowId", "deleted"], + "additionalProperties": false, + "title": "Unpublish workflow MCP tool result", + "description": "Tool removal acknowledgement." + }, + "UndeployWorkflowMcpToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UndeployWorkflowMcpToolResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Unpublish workflow MCP tool response", + "description": "Acknowledgement that the tool was removed.", + "examples": [ + { + "data": { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true + } + } + ] + }, + "UpdateCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update credential response", + "description": "Updated credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateCredentialRequest": { + "type": "object", + "properties": { + "displayName": { + "description": "New name shown for the credential in Sim.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "New credential description. Send null to clear the stored one.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + }, + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "domain": { + "description": "Provider account domain.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "botToken": { + "description": "Write-only bot token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "clientId": { + "description": "OAuth client identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "description": "Write-only OAuth client secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "certificateId": { + "description": "Provider certificate mapping identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "orgId": { + "description": "Provider organization ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "description": "Provider data center.", + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "authMethod": { + "description": "Provider authentication method.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "privateKey": { + "description": "Write-only PEM private key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "username": { + "description": "Provider run-as username.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "additionalProperties": false, + "title": "Update credential request", + "description": "Replacement display metadata and the write-only fields declared by provider discovery.", + "examples": [ + { + "clientSecret": "YOUR_ROTATED_CLIENT_SECRET" + } + ] + }, + "V2BlockSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block type identifier, used as a workflow block’s `type`." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "One-line summary of what the block does." + }, + "longDescription": { + "description": "Extended explanation, when the block has one.", + "type": "string" + }, + "category": { + "type": "string", + "description": "Toolbar category: `blocks`, `tools`, or `triggers`." + }, + "integrationType": { + "description": "Integration category, e.g. `communication`, `databases`.", + "type": "string" + }, + "source": { + "type": "string", + "enum": ["builtin", "custom"], + "description": "Where the block comes from: `builtin` is the shipped registry, `custom` is a workflow this workspace deployed as a block." + }, + "authMode": { + "description": "How the block authenticates: `oauth`, `api_key`, or `bot_token`.", + "type": "string" + }, + "triggerAllowed": { + "type": "boolean", + "description": "Whether the block declares itself usable as a trigger." + }, + "triggerCapable": { + "type": "boolean", + "description": "Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of the triggers this block supports." + }, + "toolIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + }, + "operationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + }, + "preview": { + "type": "boolean", + "description": "Whether the block is unreleased and revealed only to this caller." + }, + "sunset": { + "description": "Post-release lifecycle state. Absent for a block in normal support.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["legacy", "deprecated"], + "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." + }, + "replacedBy": { + "description": "Block type to migrate to, when one exists.", + "type": "string" } }, - "required": [ - "type", - "serviceId", - "name", - "description", - "providerFamily", - "available", - "providerId", - "docsUrl", - "requiresClientGeneratedCredentialId", - "fields" - ], + "required": ["status"], "additionalProperties": false + }, + "docsLink": { + "description": "Sim documentation page for the integration.", + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Catalog tags, e.g. `messaging`, `version-control`." } + }, + "required": [ + "id", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags" ], - "title": "Credential Provider", - "description": "An OAuth or service-account connection method available to a workspace." + "additionalProperties": false, + "title": "Block summary", + "description": "List view of a block: what it is and what it references, by id." }, - "ListCredentialProvidersResponse": { + "ListBlocksResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2CredentialProvider" + "$ref": "#/components/schemas/V2BlockSummary" }, "description": "Items in the current page." }, @@ -5300,343 +8165,1117 @@ "type": "null" } ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List credential providers response", - "description": "OAuth and service-account connection methods.", + "title": "List blocks response", + "description": "Blocks available in the workspace.", "examples": [ { "data": [ { - "type": "oauth", - "serviceId": "salesforce", - "name": "Salesforce", - "description": "Connect to Salesforce CRM data and operations.", - "providerFamily": "salesforce", - "available": true, - "supportsReconnect": true, - "authorizationOptions": [ - { - "providerId": "salesforce", - "label": "Production" + "id": "slack", + "name": "Slack", + "description": "Send messages and read channels in Slack.", + "category": "tools", + "integrationType": "communication", + "source": "builtin", + "authMode": "oauth", + "triggerAllowed": true, + "triggerCapable": true, + "triggerIds": ["slack_webhook"], + "toolIds": ["slack_message", "slack_canvas_read"], + "operationIds": ["send", "read"], + "preview": false, + "docsLink": "https://docs.sim.ai/tools/slack", + "tags": ["messaging"] + } + ], + "nextCursor": null + } + ] + }, + "V2BlockField": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Field identifier, and the key its value is stored under." + }, + "type": { + "type": "string", + "description": "Editor control the field renders as, e.g. `short-input`." + }, + "title": { + "description": "Human-readable label.", + "type": "string" + }, + "required": { + "description": "Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.", + "type": "boolean" + }, + "requiredWhen": { + "description": "Condition under which the field is required.", + "$ref": "#/components/schemas/V2CatalogCondition" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "mode": { + "description": "Where the field renders: `basic`, `advanced`, `both`, `trigger`, or `trigger-advanced`.", + "type": "string" + }, + "hidden": { + "description": "Whether the field is hidden in the editor.", + "type": "boolean" + }, + "condition": { + "description": "Condition under which the field applies at all.", + "$ref": "#/components/schemas/V2CatalogCondition" + }, + "options": { + "description": "Selectable options. Absent on fields whose options are fetched per workspace at edit time.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "description": "Human-readable option label.", + "type": "string" + }, + "hasIcon": { + "description": "Whether the option renders with an icon. The icon itself is not published.", + "type": "boolean" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "min": { + "description": "Minimum accepted numeric value.", + "type": "number" + }, + "max": { + "description": "Maximum accepted numeric value.", + "type": "number" + }, + "step": { + "description": "Increment for numeric controls.", + "type": "number" + }, + "integer": { + "description": "Whether the numeric value must be a whole number.", + "type": "boolean" + }, + "rows": { + "description": "Visible row count for multi-line text.", + "type": "number" + }, + "password": { + "description": "Whether the stored value is masked in the editor.", + "type": "boolean" + }, + "multiSelect": { + "description": "Whether more than one option may be selected.", + "type": "boolean" + }, + "language": { + "description": "Language of a code field.", + "type": "string" + }, + "generationType": { + "description": "Kind of content AI assistance generates here.", + "type": "string" + }, + "serviceId": { + "description": "OAuth service this credential field authenticates.", + "type": "string" + }, + "requiredScopes": { + "description": "OAuth scopes the credential selected here must carry.", + "type": "array", + "items": { + "type": "string" + } + }, + "mimeType": { + "description": "MIME type filter applied to a file picker.", + "type": "string" + }, + "acceptedTypes": { + "description": "Accepted file extensions for an upload field.", + "type": "string" + }, + "multiple": { + "description": "Whether more than one file may be supplied.", + "type": "boolean" + }, + "maxSize": { + "description": "Maximum upload size in megabytes.", + "type": "number" + }, + "connectionDroppable": { + "description": "Whether another block’s output can be dropped onto this field.", + "type": "boolean" + }, + "columns": { + "description": "Column headings for a table field.", + "type": "array", + "items": { + "type": "string" + } + }, + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "all": { + "description": "Every listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } }, - { - "providerId": "salesforce-sandbox", - "label": "Sandbox" + "any": { + "description": "At least one listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } } - ] + }, + "additionalProperties": false + } + ] + }, + "canonicalParamId": { + "description": "Shared key for a picker/manual-entry pair. Both fields write the same value, so supply exactly one of the pair.", + "type": "string" + }, + "defaultValue": { + "description": "Value used when the field is left unset.", + "anyOf": [ + { + "type": "string" }, { - "type": "service_account", - "serviceId": "zoom-service-account", - "providerId": "zoom-service-account", - "name": "Zoom server-to-server app", - "description": "Connect Zoom with a server-to-server app.", - "providerFamily": "zoom", - "available": true, - "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", - "requiresClientGeneratedCredentialId": false, - "fields": [ + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Member of an object-valued default. Shape varies by field type." + } + }, + { + "type": "array", + "items": { + "description": "Element of an array-valued default. Shape varies by field type." + } + } + ] + }, + "hasComputedDefault": { + "description": "Whether the field derives its value from the block’s other values. The deriving function is not published.", + "type": "boolean" + } + }, + "required": ["id", "type"], + "additionalProperties": false, + "title": "Block field", + "description": "One configuration field on a block." + }, + "V2CatalogCondition": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Sibling field id whose value decides this condition." + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + ], + "description": "Value, or set of accepted values, the named field must hold." + }, + "not": { + "description": "Invert the match: every value EXCEPT `value`.", + "type": "boolean" + }, + "and": { + "description": "A second clause that must hold as well.", + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Sibling field id for the second clause." + }, + "value": { + "description": "Value the second clause matches. Absent means \"holds any value\".", + "anyOf": [ { - "id": "clientId", - "label": "Client ID", - "placeholder": "Paste the client ID", - "required": true, - "secret": false, - "multiline": false + "type": "string" }, { - "id": "clientSecret", - "label": "Client secret", - "placeholder": "Paste the client secret", - "required": true, - "secret": true, - "multiline": false + "type": "number" }, { - "id": "orgId", - "label": "Account ID", - "placeholder": "Paste the account ID", - "required": true, - "secret": false, - "multiline": false + "type": "boolean" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } } ] + }, + "not": { + "description": "Invert the second clause.", + "type": "boolean" } - ], - "nextCursor": null + }, + "required": ["field"], + "additionalProperties": false } - ] + }, + "required": ["field", "value"], + "additionalProperties": false, + "title": "Catalog condition", + "description": "When a configuration field applies, expressed against a sibling field." }, - "CreateServiceAccountCredentialResponse": { + "V2OperationInput": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Credential" + "type": { + "type": "string", + "description": "Value type." + }, + "required": { + "description": "Whether the value must be supplied.", + "type": "boolean" + }, + "visibility": { + "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", + "type": "string" + }, + "description": { + "description": "What the value means.", + "type": "string" + }, + "default": { + "description": "Value used when this input is omitted." + }, + "items": { + "description": "JSON-Schema-shaped constraints declared by the tool parameter." + }, + "schema": { + "description": "JSON-Schema-shaped structure declared by the block input." } }, - "required": ["data"], + "required": ["type"], "additionalProperties": false, - "title": "Create service-account credential response", - "description": "Verified credential metadata without secret material.", - "examples": [ - { - "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] + "title": "Operation input", + "description": "One value a block operation needs, from its tool or its block-level inputs." }, - "CreateServiceAccountCredentialRequest": { + "V2ToolOutput": { "type": "object", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." - }, "type": { "type": "string", - "const": "service_account", - "description": "Service-account credential discriminator." + "description": "Value type of the output field." }, - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact service-account provider ID returned by provider discovery." + "description": { + "description": "What the field holds.", + "type": "string" }, - "displayName": { - "description": "Optional name; providers may derive one from the verified account identity.", - "type": "string", - "minLength": 1, - "maxLength": 255 + "optional": { + "description": "Whether the field may be absent.", + "type": "boolean" }, - "description": { - "description": "Optional credential description.", - "type": "string", - "maxLength": 500 + "nullable": { + "description": "Whether the field may be null.", + "type": "boolean" }, - "id": { - "description": "Required only when provider discovery requests a client-generated ID.", - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + "properties": { + "description": "Members of an object-typed output, keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Nested output field, in this same shape." + } }, - "credentials": { - "type": "string", - "minLength": 1, - "maxLength": 131072, - "description": "Write-only JSON object string containing the fields declared by credential-provider discovery.", - "writeOnly": true + "items": { + "description": "Element shape of an array-typed output.", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Element value type." + }, + "description": { + "description": "What an element holds.", + "type": "string" + }, + "properties": { + "description": "Members of an object-typed element, keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Nested output field, in this same shape." + } + } + }, + "required": ["type"], + "additionalProperties": false + }, + "fileConfig": { + "description": "File metadata for a file-typed output.", + "type": "object", + "properties": { + "mimeType": { + "description": "MIME type of the produced file.", + "type": "string" + }, + "extension": { + "description": "File extension of the produced file.", + "type": "string" + } + }, + "additionalProperties": false } }, - "required": ["workspaceId", "type", "providerId", "credentials"], + "required": ["type"], "additionalProperties": false, - "title": "Create service-account credential request", - "description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery." + "title": "Tool output", + "description": "One declared output field of a built-in tool." }, - "V2CredentialConnectionAuthorization": { + "V2ToolDetail": { "type": "object", "properties": { - "authorizationUrl": { + "id": { "type": "string", - "format": "uri", - "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + "description": "Registered tool identifier, including its version suffix." }, - "expiresAt": { + "name": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the connection link expires." + "description": "Display name." + }, + "description": { + "type": "string", + "description": "What the tool does." + }, + "version": { + "description": "Tool version.", + "type": "string" + }, + "hostedApiKey": { + "type": "string", + "enum": ["always", "conditional", "none"], + "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." + }, + "oauth": { + "description": "OAuth requirement, when the tool has one.", + "type": "object", + "properties": { + "required": { + "type": "boolean", + "description": "Whether the tool cannot run without an OAuth credential." + }, + "provider": { + "type": "string", + "description": "OAuth service the credential must authenticate." + }, + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["required", "provider"], + "additionalProperties": false + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolParam" + }, + "description": "Parameters the tool accepts." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolOutput" + }, + "description": "Fields the tool produces." } }, - "required": ["authorizationUrl", "expiresAt"], + "required": ["id", "name", "description", "hostedApiKey", "params", "outputs"], "additionalProperties": false, - "title": "Credential Connection Authorization", - "description": "A short-lived browser entrypoint for an OAuth connection flow." + "title": "Tool", + "description": "A built-in tool with its declared parameters and outputs." }, - "CreateCredentialConnectionResponse": { + "V2ToolParam": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + "type": { + "type": "string", + "description": "Parameter value type." + }, + "required": { + "description": "Whether the parameter must be supplied.", + "type": "boolean" + }, + "visibility": { + "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", + "type": "string" + }, + "description": { + "description": "What the parameter means.", + "type": "string" + }, + "default": { + "description": "Value used when the parameter is omitted." + }, + "items": { + "description": "JSON-Schema-shaped constraints for structured params." } }, - "required": ["data"], + "required": ["type"], "additionalProperties": false, - "title": "Create credential connection response", - "description": "Short-lived Sim browser entrypoint and its expiry.", - "examples": [ - { - "data": { - "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", - "expiresAt": "2026-06-20T14:17:11.000Z" - } - } - ] + "title": "Tool parameter", + "description": "One declared parameter of a built-in tool." }, - "CreateCredentialConnectionBody": { - "anyOf": [ - { + "V2BlockDetail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block type identifier, used as a workflow block’s `type`." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "One-line summary of what the block does." + }, + "longDescription": { + "description": "Extended explanation, when the block has one.", + "type": "string" + }, + "category": { + "type": "string", + "description": "Toolbar category: `blocks`, `tools`, or `triggers`." + }, + "integrationType": { + "description": "Integration category, e.g. `communication`, `databases`.", + "type": "string" + }, + "source": { + "type": "string", + "enum": ["builtin", "custom"], + "description": "Where the block comes from: `builtin` is the shipped registry, `custom` is a workflow this workspace deployed as a block." + }, + "authMode": { + "description": "How the block authenticates: `oauth`, `api_key`, or `bot_token`.", + "type": "string" + }, + "triggerAllowed": { + "type": "boolean", + "description": "Whether the block declares itself usable as a trigger." + }, + "triggerCapable": { + "type": "boolean", + "description": "Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of the triggers this block supports." + }, + "toolIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + }, + "operationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + }, + "preview": { + "type": "boolean", + "description": "Whether the block is unreleased and revealed only to this caller." + }, + "sunset": { + "description": "Post-release lifecycle state. Absent for a block in normal support.", "type": "object", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." - }, - "providerId": { + "status": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact OAuth provider ID returned by credential-provider discovery." + "enum": ["legacy", "deprecated"], + "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." }, - "displayName": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name shown for the new credential in Sim." + "replacedBy": { + "description": "Block type to migrate to, when one exists.", + "type": "string" } }, - "required": ["workspaceId", "providerId", "displayName"], + "required": ["status"], "additionalProperties": false }, - { + "docsLink": { + "description": "Sim documentation page for the integration.", + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Catalog tags, e.g. `messaging`, `version-control`." + }, + "bestPractices": { + "description": "Authored guidance on using the block correctly.", + "type": "string" + }, + "inputSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + }, + "description": "Configuration fields that apply regardless of the selected operation." + }, + "operationInputSchema": { "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace expected to own the credential." - }, - "credentialId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Existing OAuth credential to reconnect in place." + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" } }, - "required": ["workspaceId", "credentialId"], - "additionalProperties": false - } - ], - "title": "Create credential connection body", - "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." - }, - "V2CredentialDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Disconnected credential identifier." + "description": "Configuration fields keyed by the operation that reveals them." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the credential was disconnected." + "inputDefinitions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type: `string`, `number`, `boolean`, `json`, `array`, or `file`." + }, + "description": { + "description": "What the input means.", + "type": "string" + }, + "schema": { + "description": "JSON-Schema-shaped structure for object and array inputs." + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Block-level input definitions, keyed by parameter name." + }, + "operations": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "toolId": { + "description": "Built-in tool that performs this operation.", + "type": "string" + }, + "toolName": { + "description": "Display name of that tool.", + "type": "string" + }, + "description": { + "description": "What the operation does.", + "type": "string" + }, + "inputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2OperationInput" + }, + "description": "Values this operation needs, excluding the ones the block supplies from its own block-level inputs." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolOutput" + }, + "description": "Fields the operation produces." + }, + "inputSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + }, + "description": "Configuration fields that appear when this operation is selected." + } + }, + "required": ["inputs", "outputs", "inputSchema"], + "additionalProperties": false + }, + "description": "Operations the block exposes, keyed by operation id." + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ToolDetail" + }, + "description": "Every built-in tool the block can run, with parameters and outputs." + }, + "triggers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Trigger identifier." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type of the output." + }, + "description": { + "description": "What the output holds.", + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Top-level fields the trigger event delivers." + }, + "configFields": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Editor control the field renders as." + }, + "required": { + "type": "boolean", + "description": "Whether a value must be supplied." + }, + "title": { + "description": "Human-readable label.", + "type": "string" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "default": { + "description": "Value used when the field is left unset." + }, + "options": { + "description": "Selectable options.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + }, + "condition": { + "description": "Condition under which the field applies.", + "$ref": "#/components/schemas/V2CatalogCondition" + } + }, + "required": ["type", "required"], + "additionalProperties": false + }, + "description": "Fields that configure the trigger, keyed by field id." + } + }, + "required": ["id", "outputs", "configFields"], + "additionalProperties": false + }, + "description": "Triggers the block can run on." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type of the output." + }, + "description": { + "description": "What the output holds.", + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Fields the block produces." } }, - "required": ["id", "deleted"], + "required": [ + "id", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags", + "inputSchema", + "operationInputSchema", + "inputDefinitions", + "operations", + "tools", + "triggers", + "outputs" + ], "additionalProperties": false, - "title": "Delete credential data", - "description": "Credential disconnection acknowledgement." + "title": "Block", + "description": "A block with its configuration fields, operations, tools, and triggers." }, - "DeleteCredentialResponse": { + "GetBlockResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialDeleteData" + "$ref": "#/components/schemas/V2BlockDetail" } }, "required": ["data"], "additionalProperties": false, - "title": "Disconnect credential response", - "description": "Acknowledgement that the credential was disconnected.", + "title": "Get block response", + "description": "One block with its fields, operations, tools, and triggers.", "examples": [ { "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "deleted": true - } - } - ] - }, - "V2Secret": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." - }, - "scope": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." - }, - "description": { - "anyOf": [ - { - "type": "string" + "id": "slack", + "name": "Slack", + "description": "Send messages and read channels in Slack.", + "category": "tools", + "integrationType": "communication", + "source": "builtin", + "authMode": "oauth", + "triggerAllowed": true, + "triggerCapable": true, + "triggerIds": ["slack_webhook"], + "toolIds": ["slack_message", "slack_canvas_read"], + "operationIds": ["send", "read"], + "preview": false, + "docsLink": "https://docs.sim.ai/tools/slack", + "tags": ["messaging"], + "inputSchema": [ + { + "id": "operation", + "type": "dropdown", + "title": "Operation", + "required": true, + "options": [ + { + "id": "send", + "label": "Send message" + }, + { + "id": "read", + "label": "Read messages" + } + ] + } + ], + "operationInputSchema": { + "send": [ + { + "id": "text", + "type": "long-input", + "title": "Message", + "required": true + } + ] }, - { - "type": "null" - } - ], - "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." + "inputDefinitions": { + "channel": { + "type": "string", + "description": "Channel to post into." + } + }, + "operations": { + "send": { + "toolId": "slack_message", + "toolName": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "inputs": { + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + }, + "inputSchema": [ + { + "id": "text", + "type": "long-input", + "title": "Message", + "required": true + } + ] + } + }, + "tools": [ + { + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + }, + "params": { + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } + } + ], + "triggers": [ + { + "id": "slack_webhook", + "outputs": { + "text": { + "type": "string", + "description": "Message text." + } + }, + "configFields": { + "channels": { + "type": "short-input", + "required": false, + "title": "Channels" + } + } + } + ], + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } + } + } + ] + }, + "V2ToolSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Registered tool identifier, including its version suffix." }, - "role": { + "name": { "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the secret." + "description": "Display name." }, - "createdAt": { + "description": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was created." + "description": "What the tool does." }, - "updatedAt": { + "version": { + "description": "Tool version.", + "type": "string" + }, + "hostedApiKey": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was last updated." + "enum": ["always", "conditional", "none"], + "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." + }, + "oauth": { + "description": "OAuth requirement, when the tool has one.", + "type": "object", + "properties": { + "required": { + "type": "boolean", + "description": "Whether the tool cannot run without an OAuth credential." + }, + "provider": { + "type": "string", + "description": "OAuth service the credential must authenticate." + }, + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["required", "provider"], + "additionalProperties": false } }, - "required": ["name", "scope", "description", "role", "createdAt", "updatedAt"], + "required": ["id", "name", "description", "hostedApiKey"], "additionalProperties": false, - "title": "Secret metadata", - "description": "Public secret metadata without the stored secret value." + "title": "Tool summary", + "description": "List view of a built-in tool: identity, auth, and key hosting." }, - "ListSecretsResponse": { + "ListToolsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Secret" + "$ref": "#/components/schemas/V2ToolSummary" }, "description": "Items in the current page." }, @@ -5654,140 +9293,371 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List secrets response", - "description": "Secret metadata visible to the caller without stored values.", + "title": "List tools response", + "description": "Built-in tools available in the workspace.", "examples": [ { "data": [ { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + } } ], "nextCursor": null } ] }, - "SetSecretResponse": { + "GetToolResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Secret" + "$ref": "#/components/schemas/V2ToolDetail" } }, "required": ["data"], "additionalProperties": false, - "title": "Set secret response", - "description": "Metadata for the created or replaced secret without its value.", + "title": "Get tool response", + "description": "One built-in tool with its parameters and outputs.", "examples": [ { "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + }, + "params": { + "channel": { + "type": "string", + "required": true, + "description": "Channel ID to post into." + }, + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } } } ] }, - "SetSecretRequest": { + "V2ConnectorType": { "type": "object", "properties": { - "workspaceId": { + "connectorType": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." + "description": "Exact identifier to send when creating a connector of this type." }, - "scope": { + "name": { "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "description": "Display name." }, - "value": { + "description": { "type": "string", - "minLength": 1, - "maxLength": 65536, - "description": "Write-only secret value. It is never returned.", - "writeOnly": true + "description": "What the connector syncs." }, - "description": { - "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", - "anyOf": [ + "version": { + "type": "string", + "description": "Connector version." + }, + "auth": { + "oneOf": [ { - "type": "string", - "maxLength": 500 + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "oauth", + "description": "Authenticates with an OAuth credential." + }, + "provider": { + "type": "string", + "description": "OAuth service the credential must authenticate." + }, + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "provider"], + "additionalProperties": false }, { - "type": "null" + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "apiKey", + "description": "Authenticates with a stored API key." + }, + "label": { + "description": "Label shown above the key field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the key field.", + "type": "string" + }, + "optional": { + "type": "boolean", + "description": "Whether the key may be left blank, for a source reachable without authentication." + } + }, + "required": ["mode", "optional"], + "additionalProperties": false } - ] + ], + "description": "How the connector authenticates against its source." + }, + "configFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ConnectorConfigField" + }, + "description": "Fields that make up the connector’s `sourceConfig`." + }, + "supportsIncrementalSync": { + "type": "boolean", + "description": "Whether syncs after the first fetch only what changed." + }, + "tagDefinitions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Semantic tag identifier the connector populates." + }, + "displayName": { + "type": "string", + "description": "Human-readable tag name." + }, + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "description": "Value type, which decides the tag slot pool it draws from." + } + }, + "required": ["id", "displayName", "fieldType"], + "additionalProperties": false + }, + "description": "Tags this connector writes onto the documents it syncs." } }, - "required": ["workspaceId", "scope", "value"], + "required": [ + "connectorType", + "name", + "description", + "version", + "auth", + "configFields", + "supportsIncrementalSync", + "tagDefinitions" + ], "additionalProperties": false, - "title": "Set secret request", - "description": "Ownership scope and write-only value for the secret.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "workspace", - "value": "YOUR_SECRET_VALUE" - } - ] + "title": "Connector type", + "description": "A knowledge-base connector type and the configuration it accepts." }, - "V2SecretDeleteData": { + "V2ConnectorConfigField": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." + "description": "Field identifier." }, - "scope": { + "title": { "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "description": "Human-readable label." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the secret was deleted." + "type": { + "type": "string", + "enum": ["short-input", "dropdown", "selector"], + "description": "Control the field renders as. A `selector` fetches its options from the connected account." + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "required": { + "description": "Whether a value must be supplied.", + "type": "boolean" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "options": { + "description": "Static options, for a `dropdown` field.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + }, + "selectorKey": { + "description": "Names the picker a `selector` field renders. Its options are fetched per workspace.", + "type": "string" + }, + "mimeType": { + "description": "MIME type filter applied to the picker.", + "type": "string" + }, + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "all": { + "description": "Every listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + }, + "any": { + "description": "At least one listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "mode": { + "description": "Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.", + "type": "string", + "enum": ["basic", "advanced"] + }, + "canonicalParamId": { + "description": "Shared `sourceConfig` key for a picker/manual-entry pair. Send exactly one of the pair, keyed by this value rather than by the field’s own `id`.", + "type": "string" + }, + "multi": { + "description": "When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.", + "type": "boolean" } }, - "required": ["name", "scope", "deleted"], + "required": ["id", "title", "type"], "additionalProperties": false, - "title": "Delete secret data", - "description": "Secret deletion acknowledgement without the stored value." + "title": "Connector config field", + "description": "One field of a knowledge-base connector’s source configuration." }, - "DeleteSecretResponse": { + "ListConnectorTypesResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SecretDeleteData" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ConnectorType" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Delete secret response", - "description": "Acknowledgement that the secret was deleted.", + "title": "List connector types response", + "description": "Knowledge-base connector types and their configuration fields.", "examples": [ { - "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "deleted": true - } + "data": [ + { + "connectorType": "google_drive", + "name": "Google Drive", + "description": "Sync documents from a Google Drive folder.", + "version": "1.0.0", + "auth": { + "mode": "oauth", + "provider": "google-drive", + "requiredScopes": ["https://www.googleapis.com/auth/drive.readonly"] + }, + "configFields": [ + { + "id": "folderSelector", + "title": "Folder", + "type": "selector", + "selectorKey": "google-drive-folder", + "mimeType": "application/vnd.google-apps.folder", + "mode": "basic", + "canonicalParamId": "folderId", + "required": true + }, + { + "id": "manualFolderId", + "title": "Folder ID", + "type": "short-input", + "placeholder": "Enter the folder ID", + "mode": "advanced", + "canonicalParamId": "folderId" + } + ], + "supportsIncrementalSync": true, + "tagDefinitions": [ + { + "id": "owner", + "displayName": "Owner", + "fieldType": "text" + } + ] + } + ], + "nextCursor": null } ] } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 561ced08d0f..e1ed0fbb4e5 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -36,7 +36,7 @@ "get": { "operationId": "listTables", "summary": "List Tables", - "description": "List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. `scope=archived` lists tables a `DELETE` archived, which `POST /api/v2/tables/{tableId}/restore` can bring back. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -51,6 +51,18 @@ "description": "Workspace whose tables should be listed." } }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "type": "string", + "enum": ["active", "archived"] + } + }, { "name": "folderPath", "in": "query", @@ -224,6 +236,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -317,7 +332,7 @@ "delete": { "operationId": "deleteTable", "summary": "Delete Table", - "description": "Delete a table and return an explicit deletion acknowledgement.", + "description": "Archive a table and return an explicit deletion acknowledgement. The table is soft-deleted, not erased: its rows are retained and `POST /api/v2/tables/{tableId}/restore` brings it back.", "tags": ["Tables"], "parameters": [ { @@ -394,7 +409,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. The error body carries `details.applied` naming the fields that landed — retry with only the ones missing from it.\n\nA workspace folder tree over 10,000 folders is a `413`.", + "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. When at least one field landed before the failure the error body carries `details.applied` naming those fields — retry with only the ones missing from it. Its absence means nothing was applied.\n\nA workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -460,6 +475,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -539,6 +557,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -619,6 +640,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -699,6 +723,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -718,7 +745,7 @@ "get": { "operationId": "listTableRows", "summary": "List Rows", - "description": "List a plain cursor page in default row order. Pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. Use the query endpoint for predicate filtering and sorting.", + "description": "List a plain cursor page in default row order. Pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. Use the query endpoint for predicate filtering and sorting. Set `includeRunState=true` to attach each row's per-workflow-group run outcomes; the row limit is capped when it is set.", "tags": ["Tables"], "parameters": [ { @@ -767,6 +794,16 @@ "type": "string", "minLength": 1 } + }, + { + "name": "includeRunState", + "in": "query", + "required": false, + "description": "Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Caps `limit` at 200.", + "schema": { + "description": "Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Caps `limit` at 200.", + "type": "boolean" + } } ], "responses": { @@ -880,6 +917,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -960,6 +1000,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1040,6 +1083,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1059,7 +1105,7 @@ "get": { "operationId": "getTableRow", "summary": "Get Row", - "description": "Retrieve one row by identifier.", + "description": "Retrieve one row by identifier. Set `includeRunState=true` to attach the row's per-workflow-group run outcomes.", "tags": ["Tables"], "parameters": [ { @@ -1094,6 +1140,16 @@ "minLength": 1, "description": "Workspace that owns the table." } + }, + { + "name": "includeRunState", + "in": "query", + "required": false, + "description": "Include per-workflow-group run state on the returned row. Off by default.", + "schema": { + "description": "Include per-workflow-group run state on the returned row. Off by default.", + "type": "boolean" + } } ], "responses": { @@ -1218,6 +1274,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1388,6 +1447,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1407,7 +1469,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.", + "description": "Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`. Set `includeRunState: true` to attach each row's per-workflow-group run outcomes; the row limit is capped when it is set. Row totals live on the companion `POST /api/v2/tables/{tableId}/query/count`, which is a separate snapshot — a caller needing a consistent pair should take the count first and treat it as a floor.", "tags": ["Tables"], "parameters": [ { @@ -1470,6 +1532,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1549,6 +1614,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1702,6 +1770,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1877,6 +1948,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2115,6 +2189,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2198,6 +2275,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2278,6 +2358,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2293,11 +2376,11 @@ } } }, - "/api/v2/tables/{tableId}/columns/run": { + "/api/v2/tables/{tableId}/dispatches": { "post": { - "operationId": "runTableColumns", - "summary": "Run Column Groups", - "description": "Asynchronously run workflow or enrichment groups across all rows or a selected row subset.", + "operationId": "createTableDispatch", + "summary": "Create Run Dispatch", + "description": "Asynchronously run workflow or enrichment groups across all rows or a selected row subset. Poll the returned `dispatchId` with `GET /api/v2/tables/{tableId}/dispatches/{dispatchId}` until its status is `complete` or `canceled`, and cancel it with `DELETE` on the same path. A `null` `dispatchId` means the run settled inline and there is nothing to poll.", "tags": ["Tables"], "parameters": [ { @@ -2318,14 +2401,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RunTableColumnsRequest" + "$ref": "#/components/schemas/CreateTableDispatchRequest" } } } }, "responses": { "200": { - "description": "The accepted table-column dispatch.", + "description": "The accepted run dispatch.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2340,7 +2423,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2RunTableColumnsResponse" + "$ref": "#/components/schemas/V2CreateTableDispatchResponse" } } } @@ -2360,6 +2443,83 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "get": { + "operationId": "listTableDispatches", + "summary": "List Active Run Dispatches", + "description": "List the run dispatches still in flight on one table. Bounded by the dispatcher rather than by a page size, so this list is unpaginated and `nextCursor` is always null. A settled dispatch is read by identifier.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the table.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the table." + } + } + ], + "responses": { + "200": { + "description": "The table's active run dispatches.", + "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/V2TableRunDispatchListResponse" + } + } + } + }, + "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" }, @@ -2376,7 +2536,7 @@ "post": { "operationId": "runRowEnrichment", "summary": "Run Enrichment For One Row", - "description": "Asynchronously run one workflow or enrichment group for one table row.", + "description": "Asynchronously run one workflow or enrichment group for one table row. Poll the returned `dispatchId` with `GET /api/v2/tables/{tableId}/dispatches/{dispatchId}`; a `null` `dispatchId` means the cell already settled inline.", "tags": ["Tables"], "parameters": [ { @@ -2461,6 +2621,105 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "get": { + "operationId": "getRowEnrichment", + "summary": "Get Enrichment Run Detail", + "description": "Retrieve the provider cascade behind one enrichment cell: every configured provider in cascade order, each one's status, hosted-key cost, and duration, plus which provider produced the match. `null` means the cell has never run, or ran before cascade detail was recorded — distinct from a `404`, which means the table, row, or group does not exist.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + }, + { + "name": "rowId", + "in": "path", + "required": true, + "description": "Unique table row identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table row identifier." + } + }, + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Workflow or enrichment group to run.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workflow or enrichment group to run." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the table.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the table." + } + } + ], + "responses": { + "200": { + "description": "The enrichment run detail, or null when none was recorded.", + "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/V2RowEnrichmentResponse" + } + } + } + }, + "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" }, @@ -2473,11 +2732,11 @@ } } }, - "/api/v2/tables/{tableId}/rows/find": { + "/api/v2/tables/{tableId}/rows/search": { "post": { - "operationId": "findTableRows", - "summary": "Find Rows", - "description": "Search every cell case-insensitively, optionally within a predicate-filtered and sorted view.", + "operationId": "searchTableRows", + "summary": "Search Rows", + "description": "Text-search every cell case-insensitively for the substring `q`, optionally within a predicate-filtered and sorted view. This is TEXT search, not the structured predicate read: `POST /api/v2/tables/{tableId}/query` is that one, and on this surface `query` always means a structured predicate while `search` always means text.\n\nIt returns cell COORDINATES — `{ ordinal, rowId, column }` — and never row data. `ordinal` is the row's zero-based index in the same filtered, sorted view `POST /query` pages, so read the rows themselves through that. The result is uncursored and capped: at most 1000 matches come back and `truncated` is `true` when more matched than were returned. There is no cursor to page with — narrow `q` or the predicate instead.", "tags": ["Tables"], "parameters": [ { @@ -2498,7 +2757,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FindTableRowsRequest" + "$ref": "#/components/schemas/SearchTableRowsRequest" } } } @@ -2520,7 +2779,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FindTableRowsResponse" + "$ref": "#/components/schemas/V2SearchTableRowsResponse" } } } @@ -2540,6 +2799,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2609,6 +2871,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2894,6 +3159,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3070,6 +3338,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3082,13 +3353,24 @@ } } }, - "/api/v2/tables/exports/{exportId}": { + "/api/v2/tables/{tableId}/exports/{exportId}": { "get": { "operationId": "getTableExport", "summary": "Get Table Export", "description": "Read progress and terminal state for a durable table export.", "tags": ["Tables"], "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + }, { "name": "exportId", "in": "path", @@ -3164,6 +3446,17 @@ "description": "Cancel an export that has not reached a terminal state.", "tags": ["Tables"], "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + }, { "name": "exportId", "in": "path", @@ -3237,13 +3530,24 @@ } } }, - "/api/v2/tables/exports/{exportId}/download": { + "/api/v2/tables/{tableId}/exports/{exportId}/download": { "get": { "operationId": "downloadTableExport", "summary": "Download Table Export", "description": "Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached `completed`; one still processing, failed, or canceled is a `409` naming the current status. An export whose file is no longer available is a `404`, not a `410`.", "tags": ["Tables"], "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + }, { "name": "exportId", "in": "path", @@ -3384,6 +3688,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3566,6 +3873,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3633,6 +3943,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3752,25 +4065,582 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", + "/api/v2/tables/folders/restore": { + "post": { + "operationId": "restoreTablesFolder", + "summary": "Restore Folder", + "description": "Un-archive a table folder a recursive `DELETE` archived, along with every subfolder and table archived with it. Address it by the path it held when it was deleted. The restore may legally land it elsewhere: a folder whose parent is still archived is re-rooted to `/`, and a name an active sibling has taken meanwhile is deduplicated — so read the returned folder's `path` rather than assuming the requested one. A path that is not archived answers `404`. `DELETE /api/v2/tables/folders` returns the path it archived, which is the value to keep and send here; unlike the files surface, `GET /api/v2/tables/folders` does not yet list archived folders, so a caller that discards that path cannot recover it over the API.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "description": "Workspace scope and the canonical path the archived folder held.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreTableFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The restored table folder and what it brought back.", + "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/V2RestoreTableFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/tables/{tableId}/restore": { + "post": { + "operationId": "restoreTable", + "summary": "Restore Table", + "description": "Un-archive a table a `DELETE` archived, along with the rows, views, and workflow groups archived with it. Find archived tables with `scope=archived` on the table list. Idempotent: a table that is already active is returned unchanged with no audit entry recorded, so a retry after a dropped response cannot look like a failure. A name collision is resolved by renaming, so the restored table may come back under a different `name`.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope for the archived table.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreTableRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The restored table.", + "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/V2RestoreTableResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/bulk-update": { + "post": { + "operationId": "bulkUpdateTableRows", + "summary": "Bulk Update Rows", + "description": "Apply a distinct partial data patch to each of up to 1000 rows in one request. Each patch merges into its row, so a column absent from `data` is left alone. Membership is atomic: a `rowId` naming no row in this table fails the whole request with a `400` listing the missing identifiers. Use `PATCH /api/v2/tables/{tableId}/rows` when one patch applies to every matching row.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and one merge patch per row.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUpdateTableRowsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The bulk update result.", + "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/V2BulkUpdateTableRowsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/tables/{tableId}/dispatches/{dispatchId}": { + "get": { + "operationId": "getTableDispatch", + "summary": "Get Run Dispatch", + "description": "Poll one workflow-column run dispatch by the `dispatchId` the run endpoints returned. Answers in every lifecycle state — `pending`, `dispatching`, `complete`, and `canceled` — so a poller can wait for a run to settle. Per-cell outcomes are read with `includeRunState` on the row endpoints.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + }, + { + "name": "dispatchId", + "in": "path", + "required": true, + "description": "Unique table run-dispatch identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table run-dispatch identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the transfer resource.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the transfer resource." + } + } + ], + "responses": { + "200": { + "description": "The requested run dispatch.", + "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/V2TableRunDispatchResponse" + } + } + } + }, + "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" + } + } + }, + "delete": { + "operationId": "cancelTableDispatch", + "summary": "Cancel Run Dispatch", + "description": "Cancel one run dispatch by the `dispatchId` the run endpoint returned. This stops the scheduler: the dispatcher observes the cancellation at its next iteration and enqueues no further cells. Cells already handed to the queue are NOT canceled here — nothing links a queued cell back to the dispatch that enqueued it — so use `POST /api/v2/tables/{tableId}/cancel-runs` to stop work already in flight. Idempotent: a dispatch already `complete` or `canceled` is returned unchanged.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + }, + { + "name": "dispatchId", + "in": "path", + "required": true, + "description": "Unique table run-dispatch identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table run-dispatch identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the transfer resource.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the transfer resource." + } + } + ], + "responses": { + "200": { + "description": "The dispatch in its post-cancellation state.", + "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/V2CancelTableDispatchResponse" + } + } + } + }, + "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/tables/move": { + "post": { + "operationId": "moveTables", + "summary": "Move Tables and Folders", + "description": "Move up to 100 tables and table folders into one destination folder in a single authorized request. Folders are named by canonical path, and `null` or `/` moves to the workspace root. Best-effort per item: a table filed inside a selected folder is reported in `skipped` because the folder already carries it, an entry that resolves to nothing lands in `notFound`, and an item refused by a lock or a folder cycle lands in `failed` with a reason. An invalid destination fails the whole request before anything moves.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "description": "Workspace scope, the tables and folder paths to move, and the destination folder path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkMoveTablesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Per-item outcome of the bulk move.", + "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/V2MoveTablesResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/tables/bulk-delete": { + "post": { + "operationId": "bulkDeleteTables", + "summary": "Bulk Delete Tables and Folders", + "description": "Archive up to 100 tables and delete table folders in a single authorized request. Folders are named by canonical path and each cascades to everything inside it; `deletedItems` reports the totals across every cascade. Archived tables stay recoverable through `POST /api/v2/tables/{tableId}/restore`. Best-effort per item, with the same `skipped` / `notFound` / `failed` dispositions as the bulk move.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "description": "Workspace scope, and the tables and folder paths to delete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteTablesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Per-item outcome of the bulk delete.", + "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/V2BulkDeleteTablesResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", "description": "Maximum requests allowed in the current window." } }, @@ -3914,6 +4784,22 @@ } } }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, "Locked": { "description": "The resource is locked and cannot be modified.", "content": { @@ -4011,7 +4897,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." } }, "required": ["code", "message"], @@ -4840,41 +5726,128 @@ "description": "Row cells keyed by column name.", "$ref": "#/components/schemas/V2TableRowData" }, + "runState": { + "description": "Per-workflow-group run state keyed by group identifier. Present only when the read requested `includeRunState`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2TableRowRunState" + } + }, "createdAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "description": "ISO 8601 timestamp when the row was created." }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the row was last modified." + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the row was last modified." + } + }, + "required": ["id", "data", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Table row", + "description": "A table row with user-defined cell values." + }, + "V2TableRowData": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "User-defined cell value interpreted by its column definition." + }, + "description": "User-defined row cells keyed by column name.", + "title": "Table row data", + "examples": [ + { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + ] + }, + "V2TableRowRunState": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Lifecycle state of the most recent run for this cell: `pending`, `queued`, `running`, `completed`, `error`, or `canceled`." + }, + "executionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow execution identifier, or null before a worker claimed the cell." + }, + "workflowId": { + "type": "string", + "description": "Workflow the group runs for this cell." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Failure reason, or null when the run did not fail." + }, + "runningBlockIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block identifiers currently mid-execution." + }, + "blockErrors": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Per-block failure messages keyed by block identifier." + }, + "canceledAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the cell was canceled, or null." } }, - "required": ["id", "data", "createdAt", "updatedAt"], + "required": [ + "status", + "executionId", + "workflowId", + "error", + "runningBlockIds", + "blockErrors", + "canceledAt" + ], "additionalProperties": false, - "title": "Table row", - "description": "A table row with user-defined cell values." - }, - "V2TableRowData": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "User-defined cell value interpreted by its column definition." - }, - "description": "User-defined row cells keyed by column name.", - "title": "Table row data", - "examples": [ - { - "email": "jane@example.com", - "name": "Jane Doe", - "age": 30 - } - ] + "title": "Table row run state", + "description": "Run outcome for one workflow group on one row." }, "V2TableRowListResponse": { "type": "object", @@ -5699,6 +6672,11 @@ "description": "Opaque cursor returned by the previous query page.", "type": "string", "minLength": 1 + }, + "includeRunState": { + "default": false, + "description": "Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Incompatible with `limit: 0`, and caps `limit` at 200.", + "type": "boolean" } }, "required": ["workspaceId"], @@ -5914,42 +6892,235 @@ { "type": "null" } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Table view list response", - "description": "A cursor envelope containing the saved views." - }, - "V2CreateTableViewResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2ApiTableView" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create table view response", - "description": "The created saved view." - }, - "CreateTableViewRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "Workspace that owns the table." - }, - "name": { - "type": "string", - "minLength": 1, - "description": "Saved-view display name." + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Table view list response", + "description": "A cursor envelope containing the saved views." + }, + "V2CreateTableViewResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ApiTableView" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create table view response", + "description": "The created saved view." + }, + "CreateTableViewRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the table." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Saved-view display name." + }, + "config": { + "type": "object", + "properties": { + "columnWidths": { + "description": "Column widths keyed by column name or stable column identifier.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "columnOrder": { + "description": "Columns in display order, by name or stable identifier.", + "type": "array", + "items": { + "type": "string" + } + }, + "pinnedColumns": { + "description": "Pinned columns, by name or stable identifier.", + "type": "array", + "items": { + "type": "string" + } + }, + "hiddenColumns": { + "description": "Hidden columns, by name or stable identifier.", + "type": "array", + "items": { + "type": "string" + } + }, + "filter": { + "description": "Saved row predicate, or null when the view is unfiltered.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicateInput" + }, + { + "type": "null" + } + ] + }, + "sort": { + "description": "Saved ordered sort specification, or null for default ordering.", + "anyOf": [ + { + "maxItems": 16, + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to sort by." + }, + "direction": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Sort direction for this column." + } + }, + "required": ["field", "direction"], + "additionalProperties": false + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "description": "Saved filter, sort, and column-layout configuration." + } + }, + "required": ["workspaceId", "name", "config"], + "additionalProperties": false, + "title": "Create table view request", + "description": "Workspace scope, name, and saved filter, sort, and layout configuration." + }, + "V2TableViewResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ApiTableView" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Table view response", + "description": "A single saved table view." + }, + "UpdateTableViewRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the table." + }, + "name": { + "description": "Replacement saved-view display name.", + "type": "string", + "minLength": 1 + }, + "config": { + "description": "Complete replacement saved-view configuration.", + "type": "object", + "properties": { + "columnWidths": { + "description": "Column widths keyed by column name or stable column identifier.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "columnOrder": { + "description": "Columns in display order, by name or stable identifier.", + "type": "array", + "items": { + "type": "string" + } + }, + "pinnedColumns": { + "description": "Pinned columns, by name or stable identifier.", + "type": "array", + "items": { + "type": "string" + } + }, + "hiddenColumns": { + "description": "Hidden columns, by name or stable identifier.", + "type": "array", + "items": { + "type": "string" + } + }, + "filter": { + "description": "Saved row predicate, or null when the view is unfiltered.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicateInput" + }, + { + "type": "null" + } + ] + }, + "sort": { + "description": "Saved ordered sort specification, or null for default ordering.", + "anyOf": [ + { + "maxItems": 16, + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to sort by." + }, + "direction": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Sort direction for this column." + } + }, + "required": ["field", "direction"], + "additionalProperties": false + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false }, - "config": { + "configPatch": { + "description": "Saved-view configuration fields to shallow-merge.", "type": "object", "properties": { "columnWidths": { @@ -6016,278 +7187,498 @@ "description": "Sort direction for this column." } }, - "required": ["field", "direction"], - "additionalProperties": false - } - }, - { - "type": "null" + "required": ["field", "direction"], + "additionalProperties": false + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "isDefault": { + "description": "Whether to promote this view to the table default.", + "type": "boolean" + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update table view request", + "description": "Workspace scope and one or more saved-view changes." + }, + "V2DeleteTableViewData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted view." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the view was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete table view data", + "description": "Saved-view deletion acknowledgement." + }, + "V2DeleteTableViewResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2DeleteTableViewData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete table view response", + "description": "Saved-view deletion acknowledgement." + }, + "V2TableWorkflowGroup": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow-group identifier." + }, + "workflowId": { + "type": "string", + "description": "Backing workflow identifier for a manual group." + }, + "enrichmentId": { + "description": "Registry enrichment identifier.", + "type": "string" + }, + "name": { + "description": "Workflow-group display name.", + "type": "string" + }, + "type": { + "description": "Producer type.", + "type": "string", + "enum": ["manual", "enrichment"] + }, + "dependencies": { + "description": "Input column dependencies.", + "type": "object", + "properties": { + "columns": { + "description": "Columns required as producer inputs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Workflow block producing this output." + }, + "path": { + "type": "string", + "description": "Path to the value in the workflow block output." + }, + "outputId": { + "description": "Registry enrichment output identifier.", + "type": "string" + }, + "columnName": { + "type": "string", + "description": "Name of the table column receiving the output." + } + }, + "required": ["blockId", "path", "columnName"], + "additionalProperties": false + }, + "description": "Workflow outputs mapped to table columns." + }, + "inputMappings": { + "description": "Workflow inputs mapped from table columns.", + "type": "array", + "items": { + "type": "object", + "properties": { + "inputName": { + "type": "string", + "description": "Workflow input name." + }, + "columnName": { + "type": "string", + "description": "Name of the source table column." + } + }, + "required": ["inputName", "columnName"], + "additionalProperties": false + } + }, + "deploymentMode": { + "description": "Workflow execution mode.", + "type": "string", + "enum": ["live", "deployed"] + }, + "autoRun": { + "description": "Whether the group automatically runs for new rows.", + "type": "boolean" + } + }, + "required": ["id", "workflowId", "outputs"], + "additionalProperties": false, + "title": "Table workflow group", + "description": "A workflow or enrichment producer and the columns it populates." + }, + "V2TableWorkflowGroupListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2TableWorkflowGroup" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Table workflow group list response", + "description": "A cursor envelope containing the table workflow groups." + }, + "V2WorkflowGroupData": { + "type": "object", + "properties": { + "group": { + "description": "The created or updated workflow group.", + "$ref": "#/components/schemas/V2TableWorkflowGroup" + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "Stable server-assigned column identifier.", + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Column name used as the public row-data key." + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of values stored in the column." + }, + "required": { + "default": false, + "description": "Whether inserts require a value for this column.", + "type": "boolean" + }, + "unique": { + "default": false, + "description": "Whether values must be unique across table rows.", + "type": "boolean" + }, + "workflowGroupId": { + "description": "Workflow group whose output populates this column.", + "type": "string" + }, + "options": { + "description": "Options declared for a select column.", + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable select-option identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Display name of the select option." + } + }, + "required": ["id", "name"], + "additionalProperties": false } - ] - } + }, + "multiple": { + "description": "Whether a select column accepts multiple options.", + "type": "boolean" + }, + "currencyCode": { + "description": "ISO 4217 code for a currency column, normalized to uppercase.", + "type": "string", + "pattern": "^[A-Za-z]{3}$" + } + }, + "required": ["name", "type", "required", "unique"], + "additionalProperties": false, + "description": "A typed column in a table schema." }, - "additionalProperties": false, - "description": "Saved filter, sort, and column-layout configuration." + "description": "Current table columns after the mutation." } }, - "required": ["workspaceId", "name", "config"], + "required": ["group", "columns"], "additionalProperties": false, - "title": "Create table view request", - "description": "Workspace scope, name, and saved filter, sort, and layout configuration." + "title": "Workflow group data", + "description": "A workflow group and the resulting table columns." }, - "V2TableViewResponse": { + "V2AddTableWorkflowGroupResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2ApiTableView" + "$ref": "#/components/schemas/V2WorkflowGroupData" } }, "required": ["data"], "additionalProperties": false, - "title": "Table view response", - "description": "A single saved table view." + "title": "Add table workflow group response", + "description": "The workflow group and complete resulting table columns." }, - "UpdateTableViewRequest": { + "AddTableWorkflowGroupRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, - "description": "Workspace that owns the table." - }, - "name": { - "description": "Replacement saved-view display name.", - "type": "string", - "minLength": 1 + "maxLength": 128, + "description": "Unique workspace identifier." }, - "config": { - "description": "Complete replacement saved-view configuration.", + "group": { "type": "object", "properties": { - "columnWidths": { - "description": "Column widths keyed by column name or stable column identifier.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "number", - "exclusiveMinimum": 0 - } + "id": { + "description": "Optional client-provided workflow-group identifier.", + "type": "string", + "minLength": 1 }, - "columnOrder": { - "description": "Columns in display order, by name or stable identifier.", - "type": "array", - "items": { - "type": "string" - } + "workflowId": { + "description": "Backing workflow identifier. Required when `type` is `manual` (which is also the default when `type` is omitted); omit it for an `enrichment` group.", + "type": "string", + "minLength": 1 }, - "pinnedColumns": { - "description": "Pinned columns, by name or stable identifier.", - "type": "array", - "items": { - "type": "string" - } + "enrichmentId": { + "description": "Registry enrichment identifier.", + "type": "string", + "minLength": 1 }, - "hiddenColumns": { - "description": "Hidden columns, by name or stable identifier.", - "type": "array", - "items": { - "type": "string" - } + "name": { + "description": "Workflow-group display name.", + "type": "string" }, - "filter": { - "description": "Saved row predicate, or null when the view is unfiltered.", - "anyOf": [ - { - "$ref": "#/components/schemas/TablePredicateInput" - }, - { - "type": "null" - } - ] + "type": { + "description": "Workflow-group producer type.", + "type": "string", + "enum": ["manual", "enrichment"] }, - "sort": { - "description": "Saved ordered sort specification, or null for default ordering.", - "anyOf": [ - { - "maxItems": 16, + "dependencies": { + "description": "Producer input dependencies.", + "type": "object", + "properties": { + "columns": { + "description": "Columns required as producer inputs.", "type": "array", "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Column name to sort by." - }, - "direction": { - "type": "string", - "enum": ["asc", "desc"], - "description": "Sort direction for this column." - } - }, - "required": ["field", "direction"], - "additionalProperties": false + "type": "string" } - }, - { - "type": "null" } - ] - } - }, - "additionalProperties": false - }, - "configPatch": { - "description": "Saved-view configuration fields to shallow-merge.", - "type": "object", - "properties": { - "columnWidths": { - "description": "Column widths keyed by column name or stable column identifier.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "number", - "exclusiveMinimum": 0 - } - }, - "columnOrder": { - "description": "Columns in display order, by name or stable identifier.", - "type": "array", - "items": { - "type": "string" - } - }, - "pinnedColumns": { - "description": "Pinned columns, by name or stable identifier.", - "type": "array", - "items": { - "type": "string" } }, - "hiddenColumns": { - "description": "Hidden columns, by name or stable identifier.", + "outputs": { + "minItems": 1, "type": "array", "items": { - "type": "string" - } - }, - "filter": { - "description": "Saved row predicate, or null when the view is unfiltered.", - "anyOf": [ - { - "$ref": "#/components/schemas/TablePredicateInput" + "type": "object", + "properties": { + "blockId": { + "default": "", + "description": "Workflow block producing this output.", + "type": "string" + }, + "path": { + "default": "", + "description": "Path to the value in the workflow block output.", + "type": "string" + }, + "outputId": { + "description": "Registry enrichment output identifier.", + "type": "string" + }, + "columnName": { + "type": "string", + "minLength": 1, + "description": "Table column receiving the output." + } }, - { - "type": "null" - } - ] + "required": ["columnName"] + }, + "description": "Producer outputs mapped to columns." }, - "sort": { - "description": "Saved ordered sort specification, or null for default ordering.", - "anyOf": [ - { - "maxItems": 16, - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Column name to sort by." - }, - "direction": { - "type": "string", - "enum": ["asc", "desc"], - "description": "Sort direction for this column." - } - }, - "required": ["field", "direction"], - "additionalProperties": false + "inputMappings": { + "description": "Workflow inputs mapped from table columns.", + "type": "array", + "items": { + "type": "object", + "properties": { + "inputName": { + "type": "string", + "minLength": 1, + "description": "Workflow input name." + }, + "columnName": { + "type": "string", + "minLength": 1, + "description": "Source table column name." } }, - { - "type": "null" - } - ] + "required": ["inputName", "columnName"] + } + }, + "deploymentMode": { + "description": "Workflow state used for cell runs.", + "type": "string", + "enum": ["live", "deployed"] + }, + "autoRun": { + "description": "Whether this group automatically runs for new rows.", + "type": "boolean" } }, - "additionalProperties": false + "required": ["outputs"], + "description": "Workflow or enrichment producer definition." }, - "isDefault": { - "description": "Whether to promote this view to the table default.", + "outputColumns": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Output column name." + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Output column data type." + }, + "required": { + "description": "Whether the output column is required.", + "type": "boolean" + }, + "unique": { + "description": "Whether the output column must be unique.", + "type": "boolean" + } + }, + "required": ["name", "type"], + "additionalProperties": false + }, + "description": "Columns created for producer outputs." + }, + "autoRun": { + "default": false, + "description": "Whether to schedule existing rows after group creation.", "type": "boolean" } }, - "required": ["workspaceId"], + "required": ["workspaceId", "group", "outputColumns"], "additionalProperties": false, - "title": "Update table view request", - "description": "Workspace scope and one or more saved-view changes." - }, - "V2DeleteTableViewData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted view." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the view was deleted." + "title": "Add table workflow group request", + "description": "Workspace scope, producer definition, and output columns.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "group": { + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "outputs": [ + { + "blockId": "block_lookup", + "path": "output.revenue", + "columnName": "revenue" + } + ] + }, + "outputColumns": [ + { + "name": "revenue", + "type": "number" + } + ] } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete table view data", - "description": "Saved-view deletion acknowledgement." + ] }, - "V2DeleteTableViewResponse": { + "V2UpdateTableWorkflowGroupResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2DeleteTableViewData" + "$ref": "#/components/schemas/V2WorkflowGroupData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete table view response", - "description": "Saved-view deletion acknowledgement." + "title": "Update table workflow group response", + "description": "The workflow group and complete resulting table columns." }, - "V2TableWorkflowGroup": { + "UpdateTableWorkflowGroupRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", - "description": "Unique workflow-group identifier." + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." }, - "workflowId": { + "groupId": { "type": "string", - "description": "Backing workflow identifier for a manual group." + "minLength": 1, + "description": "Workflow group to update." }, - "enrichmentId": { - "description": "Registry enrichment identifier.", - "type": "string" + "workflowId": { + "description": "Replacement backing workflow identifier.", + "type": "string", + "minLength": 1 }, "name": { - "description": "Workflow-group display name.", + "description": "Replacement workflow-group display name.", "type": "string" }, - "type": { - "description": "Producer type.", - "type": "string", - "enum": ["manual", "enrichment"] - }, "dependencies": { - "description": "Input column dependencies.", + "description": "Replacement input dependencies.", "type": "object", "properties": { "columns": { @@ -6297,21 +7688,23 @@ "type": "string" } } - }, - "additionalProperties": false + } }, "outputs": { + "description": "Replacement producer outputs.", "type": "array", "items": { "type": "object", "properties": { "blockId": { - "type": "string", - "description": "Workflow block producing this output." + "default": "", + "description": "Workflow block producing this output.", + "type": "string" }, "path": { - "type": "string", - "description": "Path to the value in the workflow block output." + "default": "", + "description": "Path to the value in the workflow block output.", + "type": "string" }, "outputId": { "description": "Registry enrichment output identifier.", @@ -6319,81 +7712,125 @@ }, "columnName": { "type": "string", - "description": "Name of the table column receiving the output." + "minLength": 1, + "description": "Table column receiving the output." } }, - "required": ["blockId", "path", "columnName"], + "required": ["columnName"] + } + }, + "newOutputColumns": { + "description": "Columns to add for new outputs.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Output column name." + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Output column data type." + }, + "required": { + "description": "Whether the output column is required.", + "type": "boolean" + }, + "unique": { + "description": "Whether the output column must be unique.", + "type": "boolean" + } + }, + "required": ["name", "type"], "additionalProperties": false - }, - "description": "Workflow outputs mapped to table columns." + } + }, + "mappingUpdates": { + "description": "Existing output-column mapping changes.", + "type": "array", + "items": { + "type": "object", + "properties": { + "columnName": { + "type": "string", + "minLength": 1, + "description": "Existing output column to remap." + }, + "blockId": { + "type": "string", + "minLength": 1, + "description": "New workflow block producing the value." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "New path to the workflow output value." + } + }, + "required": ["columnName", "blockId", "path"] + } }, "inputMappings": { - "description": "Workflow inputs mapped from table columns.", + "description": "Replacement workflow input mappings.", "type": "array", "items": { "type": "object", "properties": { "inputName": { "type": "string", + "minLength": 1, "description": "Workflow input name." }, "columnName": { "type": "string", - "description": "Name of the source table column." + "minLength": 1, + "description": "Source table column name." } - }, - "required": ["inputName", "columnName"], - "additionalProperties": false + }, + "required": ["inputName", "columnName"] } }, "deploymentMode": { - "description": "Workflow execution mode.", + "description": "Replacement workflow execution mode.", "type": "string", "enum": ["live", "deployed"] }, + "type": { + "description": "Workflow-group producer type. Must match the group's stored type — a group's producer cannot be changed after creation.", + "type": "string", + "enum": ["manual", "enrichment"] + }, "autoRun": { - "description": "Whether the group automatically runs for new rows.", + "description": "Replacement automatic-run setting.", "type": "boolean" } }, - "required": ["id", "workflowId", "outputs"], + "required": ["workspaceId", "groupId"], "additionalProperties": false, - "title": "Table workflow group", - "description": "A workflow or enrichment producer and the columns it populates." - }, - "V2TableWorkflowGroupListResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2TableWorkflowGroup" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + "title": "Update table workflow group request", + "description": "Workspace scope, workflow group identifier, and producer changes.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "name": "Company profile enrichment" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Table workflow group list response", - "description": "A cursor envelope containing the table workflow groups." + ] }, - "V2WorkflowGroupData": { + "V2DeleteWorkflowGroupData": { "type": "object", "properties": { - "group": { - "description": "The created or updated workflow group.", - "$ref": "#/components/schemas/V2TableWorkflowGroup" + "id": { + "type": "string", + "description": "Identifier of the deleted workflow group." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the workflow group was deleted." }, "columns": { "type": "array", @@ -6467,28 +7904,28 @@ "additionalProperties": false, "description": "A typed column in a table schema." }, - "description": "Current table columns after the mutation." + "description": "Surviving table columns." } }, - "required": ["group", "columns"], + "required": ["id", "deleted", "columns"], "additionalProperties": false, - "title": "Workflow group data", - "description": "A workflow group and the resulting table columns." + "title": "Delete workflow group data", + "description": "Workflow-group deletion acknowledgement and surviving columns." }, - "V2AddTableWorkflowGroupResponse": { + "V2DeleteTableWorkflowGroupResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2WorkflowGroupData" + "$ref": "#/components/schemas/V2DeleteWorkflowGroupData" } }, "required": ["data"], "additionalProperties": false, - "title": "Add table workflow group response", - "description": "The workflow group and complete resulting table columns." + "title": "Delete table workflow group response", + "description": "Deletion acknowledgement and surviving table columns." }, - "AddTableWorkflowGroupRequest": { + "DeleteTableWorkflowGroupRequest": { "type": "object", "properties": { "workspaceId": { @@ -6497,186 +7934,146 @@ "maxLength": 128, "description": "Unique workspace identifier." }, - "group": { - "type": "object", - "properties": { - "id": { - "description": "Optional client-provided workflow-group identifier.", - "type": "string", - "minLength": 1 - }, - "workflowId": { - "description": "Backing workflow identifier. Required when `type` is `manual` (which is also the default when `type` is omitted); omit it for an `enrichment` group.", - "type": "string", - "minLength": 1 - }, - "enrichmentId": { - "description": "Registry enrichment identifier.", - "type": "string", - "minLength": 1 - }, - "name": { - "description": "Workflow-group display name.", + "groupId": { + "type": "string", + "minLength": 1, + "description": "Workflow group to delete." + } + }, + "required": ["workspaceId", "groupId"], + "additionalProperties": false, + "title": "Delete table workflow group request", + "description": "Workspace scope and workflow group identifier.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" + } + ] + }, + "V2RunColumnData": { + "type": "object", + "properties": { + "dispatchId": { + "anyOf": [ + { "type": "string" }, - "type": { - "description": "Workflow-group producer type.", - "type": "string", - "enum": ["manual", "enrichment"] - }, - "dependencies": { - "description": "Producer input dependencies.", - "type": "object", - "properties": { - "columns": { - "description": "Columns required as producer inputs.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "outputs": { - "minItems": 1, - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "default": "", - "description": "Workflow block producing this output.", - "type": "string" - }, - "path": { - "default": "", - "description": "Path to the value in the workflow block output.", - "type": "string" - }, - "outputId": { - "description": "Registry enrichment output identifier.", - "type": "string" - }, - "columnName": { - "type": "string", - "minLength": 1, - "description": "Table column receiving the output." - } - }, - "required": ["columnName"] - }, - "description": "Producer outputs mapped to columns." - }, - "inputMappings": { - "description": "Workflow inputs mapped from table columns.", - "type": "array", - "items": { - "type": "object", - "properties": { - "inputName": { - "type": "string", - "minLength": 1, - "description": "Workflow input name." - }, - "columnName": { - "type": "string", - "minLength": 1, - "description": "Source table column name." - } - }, - "required": ["inputName", "columnName"] - } - }, - "deploymentMode": { - "description": "Workflow state used for cell runs.", - "type": "string", - "enum": ["live", "deployed"] - }, - "autoRun": { - "description": "Whether this group automatically runs for new rows.", - "type": "boolean" + { + "type": "null" } + ], + "description": "Background dispatch identifier, or null when execution is inline." + } + }, + "required": ["dispatchId"], + "additionalProperties": false, + "title": "Run column data", + "description": "Acknowledgement for a table column run." + }, + "V2CreateTableDispatchResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2RunColumnData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create table dispatch response", + "description": "Accepted background dispatch identifier." + }, + "CreateTableDispatchRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + "groupIds": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 }, - "required": ["outputs"], - "description": "Workflow or enrichment producer definition." + "description": "Workflow or enrichment groups to run." }, - "outputColumns": { + "runMode": { + "default": "all", + "description": "Whether to run all or only incomplete cells.", + "type": "string", + "enum": ["all", "incomplete"] + }, + "rowIds": { + "description": "Explicit row subset to run.", "minItems": 1, + "maxItems": 1000000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "filter": { + "$ref": "#/components/schemas/TablePredicate" + }, + "excludeRowIds": { + "description": "Rows excluded from a select-all run scope.", + "maxItems": 10000, "type": "array", "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Output column name." - }, - "type": { - "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Output column data type." - }, - "required": { - "description": "Whether the output column is required.", - "type": "boolean" - }, - "unique": { - "description": "Whether the output column must be unique.", - "type": "boolean" - } + "type": "string", + "minLength": 1 + } + }, + "limit": { + "description": "Optional cap on eligible rows to run.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "rows", + "description": "Unit constrained by the run cap." }, - "required": ["name", "type"], - "additionalProperties": false + "max": { + "type": "integer", + "minimum": 1, + "maximum": 1000000, + "description": "Maximum eligible rows to run." + } }, - "description": "Columns created for producer outputs." - }, - "autoRun": { - "default": false, - "description": "Whether to schedule existing rows after group creation.", - "type": "boolean" + "required": ["type", "max"] } }, - "required": ["workspaceId", "group", "outputColumns"], + "required": ["workspaceId", "groupIds"], "additionalProperties": false, - "title": "Add table workflow group request", - "description": "Workspace scope, producer definition, and output columns.", + "title": "Create table dispatch request", + "description": "Workspace scope, producer groups, execution mode, and optional row scope.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "group": { - "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", - "name": "Enrich company", - "outputs": [ - { - "blockId": "block_lookup", - "path": "output.revenue", - "columnName": "revenue" - } - ] - }, - "outputColumns": [ - { - "name": "revenue", - "type": "number" - } - ] + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"] } ] }, - "V2UpdateTableWorkflowGroupResponse": { + "V2RunRowEnrichmentResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2WorkflowGroupData" + "$ref": "#/components/schemas/V2RunColumnData" } }, "required": ["data"], "additionalProperties": false, - "title": "Update table workflow group response", - "description": "The workflow group and complete resulting table columns." + "title": "Run row enrichment response", + "description": "Accepted background dispatch identifier." }, - "UpdateTableWorkflowGroupRequest": { + "RunRowEnrichmentRequest": { "type": "object", "properties": { "workspaceId": { @@ -6684,493 +8081,593 @@ "minLength": 1, "maxLength": 128, "description": "Unique workspace identifier." + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Run row enrichment request", + "description": "Workspace scope for the row enrichment.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + ] + }, + "V2TableRowMatch": { + "type": "object", + "properties": { + "ordinal": { + "type": "number", + "description": "Zero-based row index in the filtered and sorted view." }, - "groupId": { + "rowId": { "type": "string", - "minLength": 1, - "description": "Workflow group to update." + "description": "Identifier of the matching row." }, - "workflowId": { - "description": "Replacement backing workflow identifier.", + "column": { "type": "string", - "minLength": 1 - }, - "name": { - "description": "Replacement workflow-group display name.", - "type": "string" - }, - "dependencies": { - "description": "Replacement input dependencies.", - "type": "object", - "properties": { - "columns": { - "description": "Columns required as producer inputs.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "outputs": { - "description": "Replacement producer outputs.", + "description": "Column name containing the match." + } + }, + "required": ["ordinal", "rowId", "column"], + "additionalProperties": false, + "title": "Table row match", + "description": "One matching cell returned by a table row search." + }, + "V2SearchRowsData": { + "type": "object", + "properties": { + "matches": { + "maxItems": 1000, "type": "array", "items": { - "type": "object", - "properties": { - "blockId": { - "default": "", - "description": "Workflow block producing this output.", - "type": "string" - }, - "path": { - "default": "", - "description": "Path to the value in the workflow block output.", - "type": "string" - }, - "outputId": { - "description": "Registry enrichment output identifier.", - "type": "string" - }, - "columnName": { - "type": "string", - "minLength": 1, - "description": "Table column receiving the output." - } - }, - "required": ["columnName"] - } + "$ref": "#/components/schemas/V2TableRowMatch" + }, + "description": "Matching table cells, at most 1000." }, - "newOutputColumns": { - "description": "Columns to add for new outputs.", - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Output column name." - }, - "type": { - "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Output column data type." - }, - "required": { - "description": "Whether the output column is required.", - "type": "boolean" - }, - "unique": { - "description": "Whether the output column must be unique.", - "type": "boolean" - } - }, - "required": ["name", "type"], - "additionalProperties": false - } + "truncated": { + "type": "boolean", + "description": "Whether more than 1000 cells matched, so the list was cut." + } + }, + "required": ["matches", "truncated"], + "additionalProperties": false, + "title": "Search rows data", + "description": "Matching table cells and truncation state." + }, + "V2SearchTableRowsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SearchRowsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Search table rows response", + "description": "Matching table cells and truncation state." + }, + "SearchTableRowsRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." }, - "mappingUpdates": { - "description": "Existing output-column mapping changes.", - "type": "array", - "items": { - "type": "object", - "properties": { - "columnName": { - "type": "string", - "minLength": 1, - "description": "Existing output column to remap." - }, - "blockId": { - "type": "string", - "minLength": 1, - "description": "New workflow block producing the value." - }, - "path": { - "type": "string", - "minLength": 1, - "description": "New path to the workflow output value." - } - }, - "required": ["columnName", "blockId", "path"] - } + "q": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Case-insensitive cell substring to find." + }, + "predicate": { + "$ref": "#/components/schemas/TablePredicate" }, - "inputMappings": { - "description": "Replacement workflow input mappings.", + "sort": { + "description": "Ordered table-row sort specification.", + "maxItems": 16, "type": "array", "items": { "type": "object", "properties": { - "inputName": { + "field": { "type": "string", "minLength": 1, - "description": "Workflow input name." + "maxLength": 128, + "description": "Column name to sort by." }, - "columnName": { + "direction": { "type": "string", - "minLength": 1, - "description": "Source table column name." + "enum": ["asc", "desc"], + "description": "Sort direction for this column." } }, - "required": ["inputName", "columnName"] + "required": ["field", "direction"], + "additionalProperties": false } - }, - "deploymentMode": { - "description": "Replacement workflow execution mode.", - "type": "string", - "enum": ["live", "deployed"] - }, - "type": { - "description": "Workflow-group producer type. Must match the group's stored type — a group's producer cannot be changed after creation.", - "type": "string", - "enum": ["manual", "enrichment"] - }, - "autoRun": { - "description": "Replacement automatic-run setting.", - "type": "boolean" } }, - "required": ["workspaceId", "groupId"], + "required": ["workspaceId", "q"], "additionalProperties": false, - "title": "Update table workflow group request", - "description": "Workspace scope, workflow group identifier, and producer changes.", + "title": "Search table rows request", + "description": "Workspace scope, substring query, and optional predicate and sort.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", - "name": "Company profile enrichment" + "q": "acme", + "predicate": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + } } ] }, - "V2DeleteWorkflowGroupData": { + "V2TableUploadImportSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "upload", + "description": "Upload-backed import discriminator." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "CSV filename." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "CSV MIME type." + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 5368709120, + "description": "Exact CSV file size in bytes." + } + }, + "required": ["type", "name", "contentType", "size"], + "additionalProperties": false, + "title": "Upload table import source", + "description": "CSV file uploaded through signed transfer instructions." + }, + "V2UploadBackedTableImport": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the deleted workflow group." + "description": "Unique table-import identifier." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the workflow group was deleted." + "workspaceId": { + "type": "string", + "description": "Workspace that owns the import." }, - "columns": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "description": "Stable server-assigned column identifier.", - "type": "string" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 50, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "description": "Column name used as the public row-data key." - }, - "type": { - "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Data type of values stored in the column." - }, - "required": { - "default": false, - "description": "Whether inserts require a value for this column.", - "type": "boolean" - }, - "unique": { - "default": false, - "description": "Whether values must be unique across table rows.", - "type": "boolean" - }, - "workflowGroupId": { - "description": "Workflow group whose output populates this column.", - "type": "string" - }, - "options": { - "description": "Options declared for a select column.", - "maxItems": 100, - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Stable select-option identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Display name of the select option." - } - }, - "required": ["id", "name"], - "additionalProperties": false + "status": { + "type": "string", + "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], + "description": "Current import lifecycle state." + }, + "source": { + "description": "Uploaded CSV source for this import.", + "$ref": "#/components/schemas/V2TableUploadImportSource" + }, + "target": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "new", + "description": "Create-new-table target discriminator." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Name of the table to create." + }, + "folderPath": { + "$ref": "#/components/schemas/FolderPathInput" } }, - "multiple": { - "description": "Whether a select column accepts multiple options.", - "type": "boolean" + "required": ["type", "name"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "existing", + "description": "Existing-table target discriminator." + }, + "tableId": { + "type": "string", + "minLength": 1, + "description": "Existing target table identifier." + }, + "mode": { + "type": "string", + "enum": ["append", "replace"], + "description": "Whether to append rows or replace existing rows." + } }, - "currencyCode": { - "description": "ISO 4217 code for a currency column, normalized to uppercase.", - "type": "string", - "pattern": "^[A-Za-z]{3}$" - } + "required": ["type", "tableId", "mode"], + "additionalProperties": false + } + ], + "description": "New or existing table import target." + }, + "tableId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Resulting or target table identifier." + }, + "rowsProcessed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Rows processed so far." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Terminal failure reason, or null." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 creation timestamp." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 last-update timestamp." + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "required": ["name", "type", "required", "unique"], - "additionalProperties": false, - "description": "A typed column in a table schema." - }, - "description": "Surviving table columns." - } - }, - "required": ["id", "deleted", "columns"], - "additionalProperties": false, - "title": "Delete workflow group data", - "description": "Workflow-group deletion acknowledgement and surviving columns." - }, - "V2DeleteTableWorkflowGroupResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2DeleteWorkflowGroupData" + { + "type": "null" + } + ], + "description": "ISO 8601 completion timestamp, or null." } }, - "required": ["data"], + "required": [ + "id", + "workspaceId", + "status", + "source", + "target", + "tableId", + "rowsProcessed", + "error", + "createdAt", + "updatedAt", + "completedAt" + ], "additionalProperties": false, - "title": "Delete table workflow group response", - "description": "Deletion acknowledgement and surviving table columns." + "title": "Upload-backed table import", + "description": "Table import whose CSV source is uploaded through signed transfer instructions." }, - "DeleteTableWorkflowGroupRequest": { + "V2PutUploadTransfer": { "type": "object", "properties": { - "workspaceId": { + "method": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." + "const": "put", + "description": "Upload strategy discriminator." }, - "groupId": { + "url": { "type": "string", - "minLength": 1, - "description": "Workflow group to delete." + "format": "uri", + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the upload request." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." } }, - "required": ["workspaceId", "groupId"], + "required": ["method", "url", "headers", "expiresAt"], "additionalProperties": false, - "title": "Delete table workflow group request", - "description": "Workspace scope and workflow group identifier.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" - } - ] + "title": "Direct upload transfer", + "description": "Instructions for uploading bytes to one signed URL." }, - "V2RunColumnData": { + "V2MultipartUploadTransfer": { "type": "object", "properties": { - "dispatchId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Background dispatch identifier, or null when execution is inline." + "method": { + "type": "string", + "const": "multipart", + "description": "Upload strategy discriminator." + }, + "partSize": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Required size of each non-final part in bytes." + }, + "partCount": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 640, + "description": "Total number of upload parts." } }, - "required": ["dispatchId"], + "required": ["method", "partSize", "partCount"], "additionalProperties": false, - "title": "Run column data", - "description": "Acknowledgement for a table column run." + "title": "Multipart upload transfer", + "description": "Instructions for splitting bytes into a multipart upload." }, - "V2RunTableColumnsResponse": { + "V2TableWorkspaceFileImportSource": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2RunColumnData" + "type": { + "type": "string", + "const": "workspace_file", + "description": "Workspace-file source discriminator." + }, + "fileId": { + "type": "string", + "minLength": 1, + "description": "Existing workspace file identifier." } }, - "required": ["data"], + "required": ["type", "fileId"], "additionalProperties": false, - "title": "Run table columns response", - "description": "Accepted background dispatch identifier." + "title": "Workspace file table import source", + "description": "Existing workspace file used as a CSV import source." }, - "RunTableColumnsRequest": { + "V2WorkspaceFileTableImport": { "type": "object", "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." + "description": "Unique table-import identifier." }, - "groupIds": { - "minItems": 1, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Workflow or enrichment groups to run." + "workspaceId": { + "type": "string", + "description": "Workspace that owns the import." }, - "runMode": { - "default": "all", - "description": "Whether to run all or only incomplete cells.", + "status": { "type": "string", - "enum": ["all", "incomplete"] + "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], + "description": "Current import lifecycle state." }, - "rowIds": { - "description": "Explicit row subset to run.", - "minItems": 1, - "maxItems": 1000000, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } + "source": { + "description": "Workspace-file CSV source for this import.", + "$ref": "#/components/schemas/V2TableWorkspaceFileImportSource" }, - "filter": { - "$ref": "#/components/schemas/TablePredicate" + "target": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "new", + "description": "Create-new-table target discriminator." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Name of the table to create." + }, + "folderPath": { + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["type", "name"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "existing", + "description": "Existing-table target discriminator." + }, + "tableId": { + "type": "string", + "minLength": 1, + "description": "Existing target table identifier." + }, + "mode": { + "type": "string", + "enum": ["append", "replace"], + "description": "Whether to append rows or replace existing rows." + } + }, + "required": ["type", "tableId", "mode"], + "additionalProperties": false + } + ], + "description": "New or existing table import target." }, - "excludeRowIds": { - "description": "Rows excluded from a select-all run scope.", - "maxItems": 10000, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } + "tableId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Resulting or target table identifier." }, - "limit": { - "description": "Optional cap on eligible rows to run.", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "rows", - "description": "Unit constrained by the run cap." + "rowsProcessed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Rows processed so far." + }, + "error": { + "anyOf": [ + { + "type": "string" }, - "max": { - "type": "integer", - "minimum": 1, - "maximum": 1000000, - "description": "Maximum eligible rows to run." + { + "type": "null" } - }, - "required": ["type", "max"] - } - }, - "required": ["workspaceId", "groupIds"], - "additionalProperties": false, - "title": "Run table columns request", - "description": "Workspace scope, producer groups, execution mode, and optional row scope.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"] - } - ] - }, - "V2RunRowEnrichmentResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2RunColumnData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Run row enrichment response", - "description": "Accepted background dispatch identifier." - }, - "RunRowEnrichmentRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Run row enrichment request", - "description": "Workspace scope for the row enrichment.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - } - ] - }, - "V2TableRowMatch": { - "type": "object", - "properties": { - "ordinal": { - "type": "number", - "description": "Zero-based row index in the filtered and sorted view." + ], + "description": "Terminal failure reason, or null." }, - "rowId": { + "createdAt": { "type": "string", - "description": "Identifier of the matching row." + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 creation timestamp." }, - "column": { + "updatedAt": { "type": "string", - "description": "Column name containing the match." + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 last-update timestamp." + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 completion timestamp, or null." } }, - "required": ["ordinal", "rowId", "column"], + "required": [ + "id", + "workspaceId", + "status", + "source", + "target", + "tableId", + "rowsProcessed", + "error", + "createdAt", + "updatedAt", + "completedAt" + ], "additionalProperties": false, - "title": "Table row match", - "description": "One matching cell returned by a table row search." + "title": "Workspace-file table import", + "description": "Table import whose CSV source is an existing workspace file." }, - "V2FindRowsData": { - "type": "object", - "properties": { - "matches": { - "maxItems": 1000, - "type": "array", - "items": { - "$ref": "#/components/schemas/V2TableRowMatch" + "V2CreateTableImportData": { + "anyOf": [ + { + "type": "object", + "properties": { + "session": { + "description": "Created upload-backed import session.", + "$ref": "#/components/schemas/V2UploadBackedTableImport" + }, + "uploadToken": { + "type": "string", + "minLength": 1, + "description": "Signed token for upload control requests." + }, + "transfer": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2PutUploadTransfer" + }, + { + "$ref": "#/components/schemas/V2MultipartUploadTransfer" + } + ], + "description": "Signed CSV upload instructions." + } }, - "description": "Matching table cells, at most 1000." + "required": ["session", "uploadToken", "transfer"], + "additionalProperties": false }, - "truncated": { - "type": "boolean", - "description": "Whether more than 1000 cells matched, so the list was cut." + { + "type": "object", + "properties": { + "session": { + "description": "Created workspace-file import session.", + "$ref": "#/components/schemas/V2WorkspaceFileTableImport" + }, + "uploadToken": { + "type": "null", + "description": "Always null; a workspace-file import has no upload to authorize." + }, + "transfer": { + "type": "null", + "description": "Always null; a workspace-file import has no bytes to transfer." + } + }, + "required": ["session", "uploadToken", "transfer"], + "additionalProperties": false } - }, - "required": ["matches", "truncated"], - "additionalProperties": false, - "title": "Find rows data", - "description": "Matching table cells and truncation state." + ], + "title": "Create table import data", + "description": "Created import session and upload instructions when the source needs transfer." }, - "V2FindTableRowsResponse": { + "V2CreateTableImportResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2FindRowsData" + "$ref": "#/components/schemas/V2CreateTableImportData" } }, "required": ["data"], "additionalProperties": false, - "title": "Find table rows response", - "description": "Matching table cells and truncation state." + "title": "Create table import response", + "description": "The import session and upload instructions when transfer is required." }, - "FindTableRowsRequest": { + "CreateTableImportRequest": { "type": "object", "properties": { "workspaceId": { @@ -7179,92 +8676,108 @@ "maxLength": 128, "description": "Unique workspace identifier." }, - "q": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Case-insensitive cell substring to find." + "source": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2TableUploadImportSource" + }, + { + "$ref": "#/components/schemas/V2TableWorkspaceFileImportSource" + } + ], + "description": "CSV source for the import." }, - "predicate": { - "$ref": "#/components/schemas/TablePredicate" + "target": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "new", + "description": "Create-new-table target discriminator." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Name of the table to create." + }, + "folderPath": { + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["type", "name"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "existing", + "description": "Existing-table target discriminator." + }, + "tableId": { + "type": "string", + "minLength": 1, + "description": "Existing target table identifier." + }, + "mode": { + "type": "string", + "enum": ["append", "replace"], + "description": "Whether to append rows or replace existing rows." + } + }, + "required": ["type", "tableId", "mode"], + "additionalProperties": false + } + ], + "description": "New or existing table import target." }, - "sort": { - "description": "Ordered table-row sort specification.", - "maxItems": 16, - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { + "mapping": { + "description": "CSV headers mapped to existing table columns.", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "additionalProperties": { + "anyOf": [ + { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Column name to sort by." + "maxLength": 50 }, - "direction": { - "type": "string", - "enum": ["asc", "desc"], - "description": "Sort direction for this column." - } - }, - "required": ["field", "direction"], - "additionalProperties": false - } - } - }, - "required": ["workspaceId", "q"], - "additionalProperties": false, - "title": "Find table rows request", - "description": "Workspace scope, substring query, and optional predicate and sort.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "q": "acme", - "predicate": { - "all": [ { - "field": "status", - "op": "eq", - "value": "active" + "type": "null" } ] } - } - ] - }, - "V2TableUploadImportSource": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "upload", - "description": "Upload-backed import discriminator." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "CSV filename." }, - "contentType": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "CSV MIME type." + "createColumns": { + "description": "CSV headers for which new columns should be created.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 50 + } }, - "size": { - "type": "integer", - "minimum": 1, - "maximum": 5368709120, - "description": "Exact CSV file size in bytes." + "timezone": { + "description": "IANA timezone used to interpret local date values.", + "type": "string" } }, - "required": ["type", "name", "contentType", "size"], + "required": ["workspaceId", "source", "target"], "additionalProperties": false, - "title": "Upload table import source", - "description": "CSV file uploaded through signed transfer instructions." + "title": "Create table import request", + "description": "Workspace, CSV source, target table, optional mapping, and timezone." }, - "V2UploadBackedTableImport": { + "V2TableImport": { "type": "object", "properties": { "id": { @@ -7281,8 +8794,15 @@ "description": "Current import lifecycle state." }, "source": { - "description": "Uploaded CSV source for this import.", - "$ref": "#/components/schemas/V2TableUploadImportSource" + "oneOf": [ + { + "$ref": "#/components/schemas/V2TableUploadImportSource" + }, + { + "$ref": "#/components/schemas/V2TableWorkspaceFileImportSource" + } + ], + "description": "CSV source for the import." }, "target": { "oneOf": [ @@ -7334,21 +8854,236 @@ "description": "New or existing table import target." }, "tableId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Resulting or target table identifier." + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Resulting or target table identifier." + }, + "rowsProcessed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Rows processed so far." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Terminal failure reason, or null." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 creation timestamp." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 last-update timestamp." + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 completion timestamp, or null." + } + }, + "required": [ + "id", + "workspaceId", + "status", + "source", + "target", + "tableId", + "rowsProcessed", + "error", + "createdAt", + "updatedAt", + "completedAt" + ], + "additionalProperties": false, + "title": "Table import", + "description": "Durable CSV table-import lifecycle resource." + }, + "V2TableImportResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableImport" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Table import response", + "description": "A durable table-import lifecycle resource." + }, + "V2CancelTableImportResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableImport" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Cancel table import response", + "description": "The canceled table-import lifecycle resource." + }, + "V2UploadPartUrl": { + "type": "object", + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Multipart part number." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the part upload." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 expiration time for the signed URL." + } + }, + "required": ["partNumber", "url", "headers", "expiresAt"], + "additionalProperties": false, + "title": "Upload part URL", + "description": "A signed URL and required headers for one multipart upload part." + }, + "V2PartUrlsData": { + "type": "object", + "properties": { + "parts": { + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/V2UploadPartUrl" + }, + "description": "Signed URLs for requested parts." + } + }, + "required": ["parts"], + "additionalProperties": false, + "title": "Upload part URLs", + "description": "Signed transfer URLs for the requested multipart upload parts." + }, + "V2CreateTableImportPartUrlsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PartUrlsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create table import part URLs response", + "description": "Signed URLs and required headers for each requested upload part." + }, + "CreateTableImportPartUrlsRequest": { + "type": "object", + "properties": { + "partNumbers": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "description": "Multipart part numbers for which signed URLs should be created." + } + }, + "required": ["partNumbers"], + "additionalProperties": false, + "title": "Create table import part URLs request", + "description": "Multipart part numbers for which signed URLs should be created.", + "examples": [ + { + "partNumbers": [1, 2, 3] + } + ] + }, + "V2CompleteTableImportUploadResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableImport" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Complete table import upload response", + "description": "The import lifecycle resource after processing is queued." + }, + "V2TableExport": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique table-export identifier." + }, + "tableId": { + "type": "string", + "description": "Exported table identifier." + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the export." + }, + "format": { + "type": "string", + "enum": ["csv", "json"], + "description": "Export file format." + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed", "canceled"], + "description": "Current export lifecycle state." }, "rowsProcessed": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Rows processed so far." + "description": "Rows exported so far." }, "error": { "anyOf": [ @@ -7389,11 +9124,10 @@ }, "required": [ "id", + "tableId", "workspaceId", + "format", "status", - "source", - "target", - "tableId", "rowsProcessed", "error", "createdAt", @@ -7401,295 +9135,139 @@ "completedAt" ], "additionalProperties": false, - "title": "Upload-backed table import", - "description": "Table import whose CSV source is uploaded through signed transfer instructions." - }, - "V2PutUploadTransfer": { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "put", - "description": "Upload strategy discriminator." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - }, - "description": "Headers that must be included with the upload request." - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." - } - }, - "required": ["method", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Direct upload transfer", - "description": "Instructions for uploading bytes to one signed URL." + "title": "Table export", + "description": "Durable asynchronous table-export lifecycle resource." }, - "V2MultipartUploadTransfer": { + "V2CreateTableExportResponse": { "type": "object", "properties": { - "method": { - "type": "string", - "const": "multipart", - "description": "Upload strategy discriminator." - }, - "partSize": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Required size of each non-final part in bytes." - }, - "partCount": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 640, - "description": "Total number of upload parts." + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableExport" } }, - "required": ["method", "partSize", "partCount"], + "required": ["data"], "additionalProperties": false, - "title": "Multipart upload transfer", - "description": "Instructions for splitting bytes into a multipart upload." + "title": "Create table export response", + "description": "The created durable table-export lifecycle resource." }, - "V2TableWorkspaceFileImportSource": { + "CreateTableExportRequest": { "type": "object", "properties": { - "type": { + "workspaceId": { "type": "string", - "const": "workspace_file", - "description": "Workspace-file source discriminator." + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." }, - "fileId": { + "format": { + "default": "csv", + "description": "Export file format.", "type": "string", - "minLength": 1, - "description": "Existing workspace file identifier." + "enum": ["csv", "json"] } }, - "required": ["type", "fileId"], - "additionalProperties": false, - "title": "Workspace file table import source", - "description": "Existing workspace file used as a CSV import source." - }, - "V2WorkspaceFileTableImport": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique table-import identifier." - }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the import." - }, - "status": { - "type": "string", - "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], - "description": "Current import lifecycle state." - }, - "source": { - "description": "Workspace-file CSV source for this import.", - "$ref": "#/components/schemas/V2TableWorkspaceFileImportSource" - }, - "target": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "new", - "description": "Create-new-table target discriminator." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "description": "Name of the table to create." - }, - "folderPath": { - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["type", "name"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "existing", - "description": "Existing-table target discriminator." - }, - "tableId": { - "type": "string", - "minLength": 1, - "description": "Existing target table identifier." - }, - "mode": { - "type": "string", - "enum": ["append", "replace"], - "description": "Whether to append rows or replace existing rows." - } - }, - "required": ["type", "tableId", "mode"], - "additionalProperties": false - } - ], - "description": "New or existing table import target." - }, - "tableId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Resulting or target table identifier." - }, - "rowsProcessed": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Rows processed so far." - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Terminal failure reason, or null." + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Create table export request", + "description": "Workspace scope and export format.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "format": "csv" + } + ] + }, + "V2TableExportResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableExport" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Table export response", + "description": "A durable table-export lifecycle resource." + }, + "V2CancelTableExportResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableExport" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Cancel table export response", + "description": "The canceled durable table-export lifecycle resource." + }, + "V2TableExportDownloadData": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Short-lived signed download URL." }, - "createdAt": { + "fileName": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 creation timestamp." + "description": "Suggested export filename." }, - "updatedAt": { + "expiresAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 last-update timestamp." - }, - "completedAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 completion timestamp, or null." + "description": "ISO 8601 URL expiration timestamp." } }, - "required": [ - "id", - "workspaceId", - "status", - "source", - "target", - "tableId", - "rowsProcessed", - "error", - "createdAt", - "updatedAt", - "completedAt" - ], + "required": ["url", "fileName", "expiresAt"], "additionalProperties": false, - "title": "Workspace-file table import", - "description": "Table import whose CSV source is an existing workspace file." + "title": "Table export download data", + "description": "Signed URL and filename for a completed table export." }, - "V2CreateTableImportData": { - "anyOf": [ - { - "type": "object", - "properties": { - "session": { - "description": "Created upload-backed import session.", - "$ref": "#/components/schemas/V2UploadBackedTableImport" - }, - "uploadToken": { - "type": "string", - "minLength": 1, - "description": "Signed token for upload control requests." - }, - "transfer": { - "oneOf": [ - { - "$ref": "#/components/schemas/V2PutUploadTransfer" - }, - { - "$ref": "#/components/schemas/V2MultipartUploadTransfer" - } - ], - "description": "Signed CSV upload instructions." - } - }, - "required": ["session", "uploadToken", "transfer"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "session": { - "description": "Created workspace-file import session.", - "$ref": "#/components/schemas/V2WorkspaceFileTableImport" - }, - "uploadToken": { - "type": "null", - "description": "Always null; a workspace-file import has no upload to authorize." - }, - "transfer": { - "type": "null", - "description": "Always null; a workspace-file import has no bytes to transfer." - } - }, - "required": ["session", "uploadToken", "transfer"], - "additionalProperties": false + "V2DownloadTableExportResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableExportDownloadData" } - ], - "title": "Create table import data", - "description": "Created import session and upload instructions when the source needs transfer." + }, + "required": ["data"], + "additionalProperties": false, + "title": "Download table export response", + "description": "Short-lived signed URL, filename, and expiration timestamp." }, - "V2CreateTableImportResponse": { + "V2CancelTableRunsData": { + "type": "object", + "properties": { + "cancelled": { + "type": "number", + "description": "Number of cell runs canceled." + } + }, + "required": ["cancelled"], + "additionalProperties": false, + "title": "Cancel table runs data", + "description": "Result of canceling in-flight table cell runs." + }, + "V2CancelTableRunsResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CreateTableImportData" + "$ref": "#/components/schemas/V2CancelTableRunsData" } }, "required": ["data"], "additionalProperties": false, - "title": "Create table import response", - "description": "The import session and upload instructions when transfer is required." + "title": "Cancel table runs response", + "description": "Count of canceled table cell runs." }, - "CreateTableImportRequest": { + "CancelTableRunsRequest": { "type": "object", "properties": { "workspaceId": { @@ -7698,683 +9276,723 @@ "maxLength": 128, "description": "Unique workspace identifier." }, - "source": { - "oneOf": [ - { - "$ref": "#/components/schemas/V2TableUploadImportSource" - }, - { - "$ref": "#/components/schemas/V2TableWorkspaceFileImportSource" - } - ], - "description": "CSV source for the import." + "scope": { + "type": "string", + "enum": ["all", "row"], + "description": "Whether to cancel across the table or one row." }, - "target": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "new", - "description": "Create-new-table target discriminator." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "description": "Name of the table to create." - }, - "folderPath": { - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["type", "name"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "existing", - "description": "Existing-table target discriminator." - }, - "tableId": { - "type": "string", - "minLength": 1, - "description": "Existing target table identifier." - }, - "mode": { - "type": "string", - "enum": ["append", "replace"], - "description": "Whether to append rows or replace existing rows." - } - }, - "required": ["type", "tableId", "mode"], - "additionalProperties": false - } - ], - "description": "New or existing table import target." + "rowId": { + "description": "Row whose runs should be canceled for row scope.", + "type": "string", + "minLength": 1 }, - "mapping": { - "description": "CSV headers mapped to existing table columns.", - "type": "object", - "propertyNames": { - "type": "string", - "minLength": 1, - "maxLength": 50 - }, - "additionalProperties": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "maxLength": 50 - }, - { - "type": "null" - } - ] - } + "filter": { + "$ref": "#/components/schemas/TablePredicate" }, - "createColumns": { - "description": "CSV headers for which new columns should be created.", - "maxItems": 1000, + "excludeRowIds": { + "description": "Rows excluded from an all-scope cancellation.", + "maxItems": 10000, "type": "array", "items": { "type": "string", - "minLength": 1, - "maxLength": 50 + "minLength": 1 } - }, - "timezone": { - "description": "IANA timezone used to interpret local date values.", - "type": "string" } }, - "required": ["workspaceId", "source", "target"], + "required": ["workspaceId", "scope"], "additionalProperties": false, - "title": "Create table import request", - "description": "Workspace, CSV source, target table, optional mapping, and timezone." + "title": "Cancel table runs request", + "description": "Workspace scope, cancellation scope, and optional predicate or producer groups.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "row", + "rowId": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93" + } + ] }, - "V2TableImport": { + "V2Folder": { "type": "object", "properties": { - "id": { + "name": { "type": "string", - "description": "Unique table-import identifier." + "description": "Folder name." }, - "workspaceId": { + "path": { "type": "string", - "description": "Workspace that owns the import." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, - "status": { + "parentPath": { "type": "string", - "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], - "description": "Current import lifecycle state." - }, - "source": { - "oneOf": [ - { - "$ref": "#/components/schemas/V2TableUploadImportSource" - }, - { - "$ref": "#/components/schemas/V2TableWorkspaceFileImportSource" - } - ], - "description": "CSV source for the import." - }, - "target": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "new", - "description": "Create-new-table target discriminator." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "description": "Name of the table to create." - }, - "folderPath": { - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["type", "name"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "existing", - "description": "Existing-table target discriminator." - }, - "tableId": { - "type": "string", - "minLength": 1, - "description": "Existing target table identifier." - }, - "mode": { - "type": "string", - "enum": ["append", "replace"], - "description": "Whether to append rows or replace existing rows." - } - }, - "required": ["type", "tableId", "mode"], - "additionalProperties": false - } - ], - "description": "New or existing table import target." - }, - "tableId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Resulting or target table identifier." - }, - "rowsProcessed": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Rows processed so far." - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Terminal failure reason, or null." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 creation timestamp." + "description": "ISO 8601 timestamp when the folder was created.", + "format": "date-time" }, "updatedAt": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 last-update timestamp." + "description": "ISO 8601 timestamp when the folder was last updated.", + "format": "date-time" + } + }, + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Folder", + "description": "A canonical workspace folder." + }, + "V2TableFolderListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Folder" + }, + "description": "Items in the current page." }, - "completedAt": { + "nextCursor": { "anyOf": [ { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "type": "string" }, { "type": "null" } ], - "description": "ISO 8601 completion timestamp, or null." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, - "required": [ - "id", - "workspaceId", - "status", - "source", - "target", - "tableId", - "rowsProcessed", - "error", - "createdAt", - "updatedAt", - "completedAt" - ], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Table import", - "description": "Durable CSV table-import lifecycle resource." + "title": "Table folder list response", + "description": "A cursor envelope containing table folders." }, - "V2TableImportResponse": { + "V2CreateTableFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2TableImport" + "$ref": "#/components/schemas/V2Folder" } }, "required": ["data"], "additionalProperties": false, - "title": "Table import response", - "description": "A durable table-import lifecycle resource." + "title": "Create table folder response", + "description": "The created table folder." }, - "V2CancelTableImportResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2TableImport" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Cancel table import response", - "description": "The canceled table-import lifecycle resource." + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" }, - "V2UploadPartUrl": { + "CreateTableFolderRequest": { "type": "object", "properties": { - "partNumber": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "description": "Multipart part number." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - }, - "description": "Headers that must be included with the part upload." - }, - "expiresAt": { + "workspaceId": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 expiration time for the signed URL." - } - }, - "required": ["partNumber", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Upload part URL", - "description": "A signed URL and required headers for one multipart upload part." - }, - "V2PartUrlsData": { - "type": "object", - "properties": { - "parts": { - "maxItems": 100, - "type": "array", - "items": { - "$ref": "#/components/schemas/V2UploadPartUrl" - }, - "description": "Signed URLs for requested parts." + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the folder." + }, + "path": { + "description": "Path of the folder to create.", + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, - "required": ["parts"], + "required": ["workspaceId", "path"], "additionalProperties": false, - "title": "Upload part URLs", - "description": "Signed transfer URLs for the requested multipart upload parts." + "title": "Create table folder request", + "description": "Workspace scope and canonical folder path to create." }, - "V2CreateTableImportPartUrlsResponse": { + "V2RelocateTableFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PartUrlsData" + "$ref": "#/components/schemas/V2Folder" } }, "required": ["data"], "additionalProperties": false, - "title": "Create table import part URLs response", - "description": "Signed URLs and required headers for each requested upload part." + "title": "Relocate table folder response", + "description": "The relocated table folder." }, - "CreateTableImportPartUrlsRequest": { + "RelocateTableFolderRequest": { "type": "object", "properties": { - "partNumbers": { - "minItems": 1, - "maxItems": 100, - "type": "array", - "items": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "description": "Multipart part numbers for which signed URLs should be created." + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the folder." + }, + "path": { + "description": "Current folder path.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + }, + "destinationPath": { + "description": "New full path for the folder and its descendants.", + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, - "required": ["partNumbers"], + "required": ["workspaceId", "path", "destinationPath"], "additionalProperties": false, - "title": "Create table import part URLs request", - "description": "Multipart part numbers for which signed URLs should be created.", - "examples": [ - { - "partNumbers": [1, 2, 3] + "title": "Relocate table folder request", + "description": "Workspace scope, current canonical path, and destination path." + }, + "V2DeleteTableFolderData": { + "type": "object", + "properties": { + "path": { + "type": "string", + "title": "Folder path", + "description": "Canonical path of the deleted folder.", + "maxLength": 4096 + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the folder was deleted." + }, + "deletedItems": { + "type": "object", + "properties": { + "folders": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Number of deleted folders." + }, + "tables": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Number of deleted tables." + } + }, + "required": ["folders", "tables"], + "additionalProperties": false, + "description": "Deleted resource counts." } - ] + }, + "required": ["path", "deleted", "deletedItems"], + "additionalProperties": false, + "title": "Delete table folder data", + "description": "Folder deletion acknowledgement and deleted-resource counts." }, - "V2CompleteTableImportUploadResponse": { + "V2DeleteTableFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2TableImport" + "$ref": "#/components/schemas/V2DeleteTableFolderData" } }, "required": ["data"], "additionalProperties": false, - "title": "Complete table import upload response", - "description": "The import lifecycle resource after processing is queued." + "title": "Delete table folder response", + "description": "Folder deletion acknowledgement and deleted resource counts." }, - "V2TableExport": { + "V2TableFolderRestore": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique table-export identifier." - }, - "tableId": { - "type": "string", - "description": "Exported table identifier." - }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the export." - }, - "format": { - "type": "string", - "enum": ["csv", "json"], - "description": "Export file format." - }, - "status": { - "type": "string", - "enum": ["queued", "processing", "completed", "failed", "canceled"], - "description": "Current export lifecycle state." - }, - "rowsProcessed": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Rows exported so far." - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Terminal failure reason, or null." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 creation timestamp." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 last-update timestamp." + "folder": { + "description": "The restored folder, at the path it actually landed on — which is not always the path requested.", + "$ref": "#/components/schemas/V2Folder" }, - "completedAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "restoredItems": { + "type": "object", + "properties": { + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Folders restored, including the one addressed." }, - { - "type": "null" + "tables": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Tables restored inside the folder tree." } - ], - "description": "ISO 8601 completion timestamp, or null." + }, + "required": ["folders", "tables"], + "additionalProperties": false, + "description": "What the restore brought back." } }, - "required": [ - "id", - "tableId", - "workspaceId", - "format", - "status", - "rowsProcessed", - "error", - "createdAt", - "updatedAt", - "completedAt" - ], + "required": ["folder", "restoredItems"], "additionalProperties": false, - "title": "Table export", - "description": "Durable asynchronous table-export lifecycle resource." + "title": "Table folder restore result", + "description": "The restored folder and the counts of items it brought back." }, - "V2CreateTableExportResponse": { + "V2RestoreTableFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2TableExport" + "$ref": "#/components/schemas/V2TableFolderRestore" } }, "required": ["data"], "additionalProperties": false, - "title": "Create table export response", - "description": "The created durable table-export lifecycle resource." + "title": "Restore table folder response", + "description": "The restored table folder and the counts of items it brought back." }, - "CreateTableExportRequest": { + "RestoreTableFolderRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Unique workspace identifier." + "description": "Workspace that owns the archived folder." }, - "format": { - "default": "csv", - "description": "Export file format.", - "type": "string", - "enum": ["csv", "json"] + "path": { + "description": "Path the folder held when `DELETE /api/v2/tables/folders` archived it.", + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, - "required": ["workspaceId"], + "required": ["workspaceId", "path"], "additionalProperties": false, - "title": "Create table export request", - "description": "Workspace scope and export format.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "format": "csv" - } - ] + "title": "Restore table folder request", + "description": "Workspace scope and the canonical path the archived folder held." }, - "V2TableExportResponse": { + "V2RestoreTableResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2TableExport" + "$ref": "#/components/schemas/V2ApiTable" } }, "required": ["data"], "additionalProperties": false, - "title": "Table export response", - "description": "A durable table-export lifecycle resource." + "title": "Restore table response", + "description": "The restored table." }, - "V2CancelTableExportResponse": { + "RestoreTableRequest": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2TableExport" + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." } }, - "required": ["data"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Cancel table export response", - "description": "The canceled durable table-export lifecycle resource." + "title": "Restore table request", + "description": "Workspace scope for the archived table.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + ] }, - "V2TableExportDownloadData": { + "V2BulkUpdateRowsData": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "Short-lived signed download URL." - }, - "fileName": { - "type": "string", - "description": "Suggested export filename." + "updatedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of rows the batch updated." }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 URL expiration timestamp." + "updatedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of the rows the batch updated." } }, - "required": ["url", "fileName", "expiresAt"], + "required": ["updatedCount", "updatedRowIds"], "additionalProperties": false, - "title": "Table export download data", - "description": "Signed URL and filename for a completed table export." + "title": "Bulk update rows data", + "description": "Rows affected by a heterogeneous bulk update." }, - "V2DownloadTableExportResponse": { + "V2BulkUpdateTableRowsResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2TableExportDownloadData" + "$ref": "#/components/schemas/V2BulkUpdateRowsData" } }, "required": ["data"], "additionalProperties": false, - "title": "Download table export response", - "description": "Short-lived signed URL, filename, and expiration timestamp." + "title": "Bulk update table rows response", + "description": "Updated row count and identifiers." }, - "V2CancelTableRunsData": { + "BulkUpdateTableRowsRequest": { "type": "object", "properties": { - "cancelled": { - "type": "number", - "description": "Number of cell runs canceled." + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the table." + }, + "updates": { + "minItems": 1, + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "rowId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the row this patch applies to." + }, + "data": { + "description": "Cells to merge into this row, keyed by column name.", + "$ref": "#/components/schemas/V2TableRowData" + } + }, + "required": ["rowId", "data"], + "additionalProperties": false + }, + "description": "One merge patch per row. Each row identifier may appear at most once." } }, - "required": ["cancelled"], + "required": ["workspaceId", "updates"], "additionalProperties": false, - "title": "Cancel table runs data", - "description": "Result of canceling in-flight table cell runs." + "title": "Bulk update table rows request", + "description": "Workspace scope and one merge patch per row.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "updates": [ + { + "rowId": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "data": { + "status": "active" + } + }, + { + "rowId": "row_2b4d6f8a0c1e3759b8d0f2a4c6e80193", + "data": { + "status": "churned" + } + } + ] + } + ] }, - "V2CancelTableRunsResponse": { + "V2EnrichmentRunDetail": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CancelTableRunsData" + "startedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the cascade started, or null when not recorded." + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the cascade finished, or null when not recorded." + }, + "durationMs": { + "type": "number", + "description": "Wall-clock milliseconds across the whole cascade; zero when not recorded." + }, + "totalCost": { + "type": "number", + "description": "Sum of per-provider hosted-key cost in USD; zero when not recorded." + }, + "matchedProvider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Provider that produced the match, or null when none did." + }, + "aborted": { + "type": "boolean", + "description": "True when the run was canceled before it settled." + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2EnrichmentProviderOutcome" + }, + "description": "Every configured provider, in cascade order, including those that never ran." } }, - "required": ["data"], + "required": [ + "startedAt", + "completedAt", + "durationMs", + "totalCost", + "matchedProvider", + "aborted", + "providers" + ], "additionalProperties": false, - "title": "Cancel table runs response", - "description": "Count of canceled table cell runs." + "title": "Enrichment run detail", + "description": "Provider cascade, cost, and timing for one enrichment cell." }, - "CancelTableRunsRequest": { + "V2EnrichmentProviderOutcome": { "type": "object", "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." + "description": "Provider identifier, e.g. `hunter`." }, - "scope": { + "label": { "type": "string", - "enum": ["all", "row"], - "description": "Whether to cancel across the table or one row." + "description": "Human-readable provider name." }, - "rowId": { - "description": "Row whose runs should be canceled for row scope.", + "toolId": { "type": "string", - "minLength": 1 + "description": "Sim tool identifier the provider ran." }, - "filter": { - "$ref": "#/components/schemas/TablePredicate" + "status": { + "type": "string", + "description": "How this provider ended: `matched`, `no_match`, `skipped`, `error`, or `not_run`. Declared as a string rather than a closed enum because the value is read back out of a schemaless JSONB blob — a member added by a newer runner must widen a client's switch, not fail its read." }, - "excludeRowIds": { - "description": "Rows excluded from an all-scope cancellation.", - "maxItems": 10000, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } + "cost": { + "type": "number", + "description": "Hosted-key cost in USD this provider incurred; zero when Sim did not bill it." + }, + "durationMs": { + "type": "number", + "description": "Wall-clock milliseconds this provider took; zero if skipped." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Failure reason when `status` is `error`, else null." } }, - "required": ["workspaceId", "scope"], + "required": ["id", "label", "toolId", "status", "cost", "durationMs", "error"], "additionalProperties": false, - "title": "Cancel table runs request", - "description": "Workspace scope, cancellation scope, and optional predicate or producer groups.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "row", - "rowId": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93" + "title": "Enrichment provider outcome", + "description": "One provider's result within an enrichment cascade." + }, + "V2RowEnrichmentResponse": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2EnrichmentRunDetail" + }, + { + "type": "null" + } + ], + "description": "Response data." } - ] + }, + "required": ["data"], + "additionalProperties": false, + "title": "Row enrichment response", + "description": "Provider cascade, cost, and timing for one enrichment cell." }, - "V2Folder": { + "V2TableRunDispatch": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "description": "Folder name." + "description": "Unique dispatch identifier." }, - "path": { + "tableId": { "type": "string", - "title": "Non-root folder path", - "description": "Canonical folder path used as the public folder identifier.", - "maxLength": 4096 + "description": "Table the dispatch runs against." }, - "parentPath": { + "workspaceId": { "type": "string", - "title": "Folder path", - "description": "Canonical parent path; `/` is the root.", - "maxLength": 4096 + "description": "Workspace that owns the dispatch." }, - "createdAt": { + "status": { "type": "string", - "description": "ISO 8601 timestamp when the folder was created.", - "format": "date-time" + "enum": ["pending", "dispatching", "complete", "canceled"], + "description": "Current dispatch lifecycle state." }, - "updatedAt": { + "mode": { "type": "string", - "description": "ISO 8601 timestamp when the folder was last updated.", - "format": "date-time" + "enum": ["all", "incomplete", "new"], + "description": "Which cells the dispatch targets: `all` re-runs settled cells, `incomplete` skips them, `new` covers only cells that have never run." + }, + "scope": { + "type": "object", + "properties": { + "groupIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflow groups the dispatch runs." + }, + "rowIds": { + "description": "Explicit rows the dispatch targets; absent means every eligible row.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["groupIds"], + "additionalProperties": false, + "description": "What the dispatch was asked to run." + }, + "limit": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "rows", + "description": "Unit the cap counts." + }, + "max": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Hard ceiling in units of `type`." + } + }, + "required": ["type", "max"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Cap on how much work the dispatch does, or null when unbounded." + }, + "processedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Units of `limit.type` consumed so far." + }, + "isManualRun": { + "type": "boolean", + "description": "True when a caller started the run, false for an automatic re-fire." + }, + "requestedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the dispatch was created." + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the dispatch completed, or null." + }, + "canceledAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the dispatch was canceled, or null." + } + }, + "required": [ + "id", + "tableId", + "workspaceId", + "status", + "mode", + "scope", + "limit", + "processedCount", + "isManualRun", + "requestedAt", + "completedAt", + "canceledAt" + ], + "additionalProperties": false, + "title": "Table run dispatch", + "description": "Lifecycle state of one table workflow-column run dispatch." + }, + "V2TableRunDispatchResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableRunDispatch" } }, - "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "required": ["data"], "additionalProperties": false, - "title": "Folder", - "description": "A canonical workspace folder." + "title": "Table run dispatch response", + "description": "A single table run dispatch." }, - "V2TableFolderListResponse": { + "V2CancelTableDispatchResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2TableRunDispatch" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Cancel table dispatch response", + "description": "The dispatch in its post-cancellation state." + }, + "V2TableRunDispatchListResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Folder" + "$ref": "#/components/schemas/V2TableRunDispatch" }, "description": "Items in the current page." }, @@ -8392,135 +10010,334 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Table folder list response", - "description": "A cursor envelope containing table folders." - }, - "V2CreateTableFolderResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Folder" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create table folder response", - "description": "The created table folder." - }, - "NonRootFolderPathInput": { - "title": "Non-root folder path input", - "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" + "title": "Table run dispatch list response", + "description": "The table's active run dispatches." }, - "CreateTableFolderRequest": { + "V2MoveTablesData": { "type": "object", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the folder." + "moved": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["table", "folder"], + "description": "Which kind of item this entry names." + }, + "id": { + "type": "string", + "description": "Table identifier, or the folder path for a folder." + }, + "name": { + "type": "string", + "description": "Table name, or the folder path for a folder." + } + }, + "required": ["kind", "id", "name"], + "additionalProperties": false + }, + "description": "Items the batch moved." }, - "path": { - "description": "Path of the folder to create.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "skipped": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["table", "folder"], + "description": "Which kind of item this entry names." + }, + "id": { + "type": "string", + "description": "Table identifier, or the folder path for a folder." + }, + "name": { + "type": "string", + "description": "Table name, or the folder path for a folder." + } + }, + "required": ["kind", "id", "name"], + "additionalProperties": false + }, + "description": "Items dropped because a selected folder already carries them." + }, + "notFound": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["table", "folder"], + "description": "Which kind of item this entry names." + }, + "id": { + "type": "string", + "description": "Table identifier, or the folder path for a folder." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + "description": "Entries nothing active resolved to." + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["table", "folder"], + "description": "Which kind of item this entry names." + }, + "id": { + "type": "string", + "description": "Table identifier, or the folder path for a folder." + }, + "name": { + "type": "string", + "description": "Table name, or the folder path for a folder." + }, + "reason": { + "type": "string", + "description": "Why this item could not be acted on." + } + }, + "required": ["kind", "id", "name", "reason"], + "additionalProperties": false + }, + "description": "Items the batch could not move." } }, - "required": ["workspaceId", "path"], + "required": ["moved", "skipped", "notFound", "failed"], "additionalProperties": false, - "title": "Create table folder request", - "description": "Workspace scope and canonical folder path to create." + "title": "Bulk move tables data", + "description": "Per-item outcome of a bulk table and folder move." }, - "V2RelocateTableFolderResponse": { + "V2MoveTablesResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Folder" + "$ref": "#/components/schemas/V2MoveTablesData" } }, "required": ["data"], "additionalProperties": false, - "title": "Relocate table folder response", - "description": "The relocated table folder." + "title": "Bulk move tables response", + "description": "Per-item outcome of a bulk table and folder move." }, - "RelocateTableFolderRequest": { + "BulkMoveTablesRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace containing the folder." + "description": "Workspace that owns every selected item." }, - "path": { - "description": "Current folder path.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "tableIds": { + "default": [], + "description": "Tables to move, by identifier.", + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } }, - "destinationPath": { - "description": "New full path for the folder and its descendants.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "folderPaths": { + "description": "Table folders to re-parent, by canonical path.", + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "targetFolderPath": { + "description": "Destination folder path. Omit to move the selection to the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" } }, - "required": ["workspaceId", "path", "destinationPath"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Relocate table folder request", - "description": "Workspace scope, current canonical path, and destination path." + "title": "Bulk move tables request", + "description": "Workspace scope, the tables and folder paths to move, and the destination folder path." }, - "V2DeleteTableFolderData": { + "V2BulkDeleteTablesData": { "type": "object", "properties": { - "path": { - "type": "string", - "title": "Folder path", - "description": "Canonical path of the deleted folder.", - "maxLength": 4096 - }, "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the folder was deleted." + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["table", "folder"], + "description": "Which kind of item this entry names." + }, + "id": { + "type": "string", + "description": "Table identifier, or the folder path for a folder." + }, + "name": { + "type": "string", + "description": "Table name, or the folder path for a folder." + } + }, + "required": ["kind", "id", "name"], + "additionalProperties": false + }, + "description": "Items the batch archived or deleted." + }, + "skipped": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["table", "folder"], + "description": "Which kind of item this entry names." + }, + "id": { + "type": "string", + "description": "Table identifier, or the folder path for a folder." + }, + "name": { + "type": "string", + "description": "Table name, or the folder path for a folder." + } + }, + "required": ["kind", "id", "name"], + "additionalProperties": false + }, + "description": "Items dropped because a selected folder already carries them." + }, + "notFound": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["table", "folder"], + "description": "Which kind of item this entry names." + }, + "id": { + "type": "string", + "description": "Table identifier, or the folder path for a folder." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + "description": "Entries nothing active resolved to." + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["table", "folder"], + "description": "Which kind of item this entry names." + }, + "id": { + "type": "string", + "description": "Table identifier, or the folder path for a folder." + }, + "name": { + "type": "string", + "description": "Table name, or the folder path for a folder." + }, + "reason": { + "type": "string", + "description": "Why this item could not be acted on." + } + }, + "required": ["kind", "id", "name", "reason"], + "additionalProperties": false + }, + "description": "Items the batch could not delete." }, "deletedItems": { "type": "object", "properties": { - "folders": { + "tables": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991, - "description": "Number of deleted folders." + "description": "Tables archived, including folder cascades." }, - "tables": { + "folders": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991, - "description": "Number of deleted tables." + "description": "Folders deleted, including nested folders." } }, - "required": ["folders", "tables"], + "required": ["tables", "folders"], "additionalProperties": false, - "description": "Deleted resource counts." + "description": "Totals across the explicit archives and every folder cascade they triggered." } }, - "required": ["path", "deleted", "deletedItems"], + "required": ["deleted", "skipped", "notFound", "failed", "deletedItems"], "additionalProperties": false, - "title": "Delete table folder data", - "description": "Folder deletion acknowledgement and deleted-resource counts." + "title": "Bulk delete tables data", + "description": "Per-item outcome of a bulk table and folder delete." }, - "V2DeleteTableFolderResponse": { + "V2BulkDeleteTablesResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2DeleteTableFolderData" + "$ref": "#/components/schemas/V2BulkDeleteTablesData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete table folder response", - "description": "Folder deletion acknowledgement and deleted resource counts." + "title": "Bulk delete tables response", + "description": "Per-item outcome of a bulk table and folder delete." + }, + "BulkDeleteTablesRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns every selected item." + }, + "tableIds": { + "default": [], + "description": "Tables to archive, by identifier.", + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "folderPaths": { + "description": "Table folders to delete, by canonical path. Each cascades to everything inside it.", + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/FolderPathInput" + } + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Bulk delete tables request", + "description": "Workspace scope, and the tables and folder paths to delete." } } }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index f4a4a96bec4..5877d957f55 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -40,7 +40,7 @@ "get": { "operationId": "listWorkflows", "summary": "List Workflows", - "description": "List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List workflows in a workspace with lifecycle scope, folder and deployment filters, search, sorting, and opaque cursor pagination. `scope` defaults to `active`; pass `archived` to list workflows a `DELETE` archived. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -55,6 +55,18 @@ "description": "Workspace whose workflows should be listed." } }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "type": "string", + "enum": ["active", "archived"] + } + }, { "name": "folderPath", "in": "query", @@ -187,7 +199,7 @@ "post": { "operationId": "createWorkflowV2", "summary": "Create Workflow", - "description": "Create a workflow in a workspace root or canonical workflow folder. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Create a workflow in a workspace root or canonical workflow folder. The response carries the blocks the platform seeded the workflow with, so the start block's id is available without a second request — attach edges to it directly. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -240,6 +252,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -255,15 +270,15 @@ } } }, - "/api/v2/workflows/{id}": { + "/api/v2/workflows/{workflowId}/state": { "get": { - "operationId": "getWorkflow", - "summary": "Get Workflow", - "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace folder tree over 10,000 folders is a `413`.", + "operationId": "getWorkflowState", + "summary": "Get Workflow State", + "description": "Get the editable draft graph of a workflow: blocks, edges, the loop and parallel containers derived from them, and variables. This is the pollable read — it records no audit event, and `HEAD` mirrors `GET`. The payload is **unsanitized**: it carries workspace-scoped `credentialId`, `knowledgeBaseId`, and `tableId` values verbatim, so it is not portable to another workspace. Use `GET /workflows/{workflowId}/export` for a portable, sanitized copy — and note that export is not a read-modify-write source, because sanitizing it drops every credential binding. Unknown members are stripped, so what this returns is exactly the set of keys `PUT /workflows/{workflowId}/state` accepts.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", @@ -277,7 +292,7 @@ ], "responses": { "200": { - "description": "The requested workflow.", + "description": "The workflow draft graph.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -292,7 +307,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowDetailResponse" + "$ref": "#/components/schemas/WorkflowStateResponse" } } } @@ -309,9 +324,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -323,14 +335,14 @@ } } }, - "patch": { - "operationId": "updateWorkflowV2", - "summary": "Update Workflow", - "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace folder tree over 10,000 folders is a `413`.", + "put": { + "operationId": "replaceWorkflowState", + "summary": "Replace Workflow State", + "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", @@ -340,22 +352,32 @@ "description": "Unique workflow identifier.", "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } + }, + { + "name": "dryRun", + "in": "query", + "required": false, + "description": "Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified.", + "schema": { + "description": "Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified.", + "type": "boolean" + } } ], "requestBody": { "required": true, - "description": "Fields to update on an existing workflow.", + "description": "A complete replacement draft graph for a workflow.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateWorkflowRequest" + "$ref": "#/components/schemas/ReplaceWorkflowStateRequest" } } } }, "responses": { "200": { - "description": "The updated workflow.", + "description": "The draft graph was replaced.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -370,7 +392,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateWorkflowResponse" + "$ref": "#/components/schemas/ReplaceWorkflowStateResponse" } } } @@ -393,6 +415,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -406,15 +431,17 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteWorkflowV2", - "summary": "Delete Workflow", - "description": "Permanently delete a workflow and its associated mutable state.", + } + }, + "/api/v2/workflows/{workflowId}/operations": { + "post": { + "operationId": "applyWorkflowOperations", + "summary": "Apply Workflow Operations", + "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", @@ -424,11 +451,32 @@ "description": "Unique workflow identifier.", "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } + }, + { + "name": "dryRun", + "in": "query", + "required": false, + "description": "Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified.", + "schema": { + "description": "Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified.", + "type": "boolean" + } } ], + "requestBody": { + "required": true, + "description": "A batch of semantic edits against a workflow graph.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyWorkflowOperationsRequest" + } + } + } + }, "responses": { "200": { - "description": "The workflow was deleted.", + "description": "The batch was applied.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -443,7 +491,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteWorkflowResponse" + "$ref": "#/components/schemas/ApplyWorkflowOperationsResponse" } } } @@ -460,6 +508,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -475,15 +532,15 @@ } } }, - "/api/v2/workflows/{id}/versions": { - "get": { - "operationId": "listWorkflowVersionsV2", - "summary": "List Workflow Versions", - "description": "List immutable deployment versions of a workflow, newest first.", + "/api/v2/workflows/{workflowId}/variables": { + "patch": { + "operationId": "applyWorkflowVariables", + "summary": "Update Workflow Variables", + "description": "Add, edit, and delete a workflow’s variables. Operations are matched by variable `name` and applied in order; a batch that changes nothing answers `200` with `changed: false`. Values are coerced to the declared `type`, and a value that cannot be coerced is stored as supplied. Read the current set from `variables` on `GET /workflows/{workflowId}`.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", @@ -493,35 +550,22 @@ "description": "Unique workflow identifier.", "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum deployment versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum deployment versions 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 - } } ], + "requestBody": { + "required": true, + "description": "Additions, edits, and deletions against a workflow’s variables.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyWorkflowVariablesRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of deployment versions.", + "description": "The variable set after the batch.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -536,7 +580,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowVersionListResponse" + "$ref": "#/components/schemas/ApplyWorkflowVariablesResponse" } } } @@ -553,6 +597,18 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -565,41 +621,40 @@ } } }, - "/api/v2/workflows/{id}/versions/{version}": { - "get": { - "operationId": "getWorkflowVersionV2", - "summary": "Get Workflow Version", - "description": "Get an immutable deployment version and its pinned workflow graph snapshot.", + "/api/v2/workflows/{workflowId}/duplicate": { + "post": { + "operationId": "duplicateWorkflow", + "summary": "Duplicate Workflow", + "description": "Copy a workflow, including its blocks, edges, subflows, and variables, into the same workspace. Omitting `name` reuses the source name; a collision inside the destination folder is deduplicated rather than refused. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow identifier." - } - }, - { - "name": "version", - "in": "path", - "required": true, - "description": "Numeric deployment version.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Numeric deployment version.", - "examples": [3] + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], + "requestBody": { + "required": true, + "description": "Optional name and destination folder for the copy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DuplicateWorkflowRequest" + } + } + } + }, "responses": { - "200": { - "description": "The requested deployment version.", + "201": { + "description": "The created copy.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -614,7 +669,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowVersionDetailResponse" + "$ref": "#/components/schemas/DuplicateWorkflowResponse" } } } @@ -631,6 +686,18 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -643,15 +710,15 @@ } } }, - "/api/v2/workflows/{id}/deployment": { - "get": { - "operationId": "getWorkflowDeployment", - "summary": "Get Workflow Deployment", - "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.", + "/api/v2/workflows/{workflowId}/restore": { + "post": { + "operationId": "restoreWorkflow", + "summary": "Restore Workflow", + "description": "Bring an archived workflow back, along with the schedules, webhooks, MCP tools, and chats that were archived with it. A workflow that is not archived answers `409`. A workflow whose folder was archived is restored to the workspace root. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", @@ -665,7 +732,7 @@ ], "responses": { "200": { - "description": "The current deployment state.", + "description": "The restored workflow.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -680,7 +747,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowDeploymentResponse" + "$ref": "#/components/schemas/RestoreWorkflowResponse" } } } @@ -697,6 +764,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -709,40 +785,26 @@ } } }, - "/api/v2/workflows/{id}/deploy": { + "/api/v2/workflows/move": { "post": { - "operationId": "deployWorkflow", - "summary": "Deploy Workflow", - "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.", + "operationId": "moveWorkflows", + "summary": "Move Workflows", + "description": "Relocate up to 100 workflows into one folder. Explicitly best-effort: each workflow moves in its own transaction, and one that is absent from the workspace, archived, or locked lands in `failed` while the rest still move. Duplicate ids are collapsed. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "Unique workflow identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - } - } - ], "requestBody": { - "required": false, - "description": "Optional metadata for the new deployment version.", + "required": true, + "description": "Workflows to relocate and the folder to relocate them into.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeployWorkflowRequest" + "$ref": "#/components/schemas/MoveWorkflowsRequest" } } } }, "responses": { "200": { - "description": "The accepted deployment attempt.", + "description": "Which workflows moved and which did not.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -757,7 +819,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeployWorkflowResponse" + "$ref": "#/components/schemas/MoveWorkflowsResponse" } } } @@ -774,14 +836,11 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "423": { - "$ref": "#/components/responses/Locked" + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -793,15 +852,17 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "undeployWorkflow", - "summary": "Undeploy Workflow", - "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.", + } + }, + "/api/v2/workflows/{workflowId}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", @@ -815,7 +876,7 @@ ], "responses": { "200": { - "description": "The workflow was undeployed.", + "description": "The requested workflow.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -830,7 +891,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UndeployWorkflowResponse" + "$ref": "#/components/schemas/WorkflowDetailResponse" } } } @@ -847,8 +908,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "423": { - "$ref": "#/components/responses/Locked" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -860,17 +921,15 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workflows/{id}/rollback": { - "post": { - "operationId": "rollbackWorkflow", - "summary": "Rollback Workflow", - "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key is rejected with `403`; use a personal API key.", + }, + "patch": { + "operationId": "updateWorkflowV2", + "summary": "Update Workflow", + "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", @@ -883,19 +942,19 @@ } ], "requestBody": { - "required": false, - "description": "Optional deployment version to reactivate.", + "required": true, + "description": "Fields to update on an existing workflow.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RollbackWorkflowRequest" + "$ref": "#/components/schemas/UpdateWorkflowRequest" } } } }, "responses": { "200": { - "description": "The accepted rollback attempt.", + "description": "The updated workflow.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -910,7 +969,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RollbackWorkflowResponse" + "$ref": "#/components/schemas/UpdateWorkflowResponse" } } } @@ -933,6 +992,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -946,17 +1008,15 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workflows/{id}/export": { - "get": { - "operationId": "exportWorkflow", - "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace folder tree over 10,000 folders is a `413`.", + }, + "delete": { + "operationId": "deleteWorkflowV2", + "summary": "Delete Workflow", + "description": "Archive a workflow. Despite the verb, this is not an erasure: the workflow, and the schedules, webhooks, MCP tools, and chats attached to it, are stamped archived and stop running, and `POST /workflows/{workflowId}/restore` brings all of them back. An archived workflow disappears from the default list and is reachable with `scope=archived`. The `deleted` field is retained for shipped clients; `archived` states what actually happened.", "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", @@ -970,7 +1030,7 @@ ], "responses": { "200": { - "description": "The workflow export payload.", + "description": "The workflow was archived.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -985,7 +1045,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExportWorkflowResponse" + "$ref": "#/components/schemas/DeleteWorkflowResponse" } } } @@ -1002,8 +1062,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" + "423": { + "$ref": "#/components/responses/Locked" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1017,26 +1077,53 @@ } } }, - "/api/v2/workflows/import": { - "post": { - "operationId": "importWorkflow", - "summary": "Import Workflow", - "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace folder tree over 10,000 folders is a `413`.", + "/api/v2/workflows/{workflowId}/versions": { + "get": { + "operationId": "listWorkflowVersionsV2", + "summary": "List Workflow Versions", + "description": "List immutable deployment versions of a workflow, newest first.", "tags": ["Workflows"], - "requestBody": { - "required": true, - "description": "Portable workflow data and destination metadata for an import.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImportWorkflowRequest" - } + "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"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum deployment versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum deployment versions 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 } } - }, + ], "responses": { - "201": { - "description": "The imported workflow.", + "200": { + "description": "A page of deployment versions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1051,7 +1138,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportWorkflowResponse" + "$ref": "#/components/schemas/WorkflowVersionListResponse" } } } @@ -1068,15 +1155,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "423": { - "$ref": "#/components/responses/Locked" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1089,74 +1167,42 @@ } } }, - "/api/v2/workflows/{id}/execute": { - "post": { - "operationId": "executeWorkflowV2", - "summary": "Execute Workflow", - "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "/api/v2/workflows/{workflowId}/versions/{version}": { + "get": { + "operationId": "getWorkflowVersionV2", + "summary": "Get Workflow Version", + "description": "Get an immutable deployment version and its pinned workflow graph snapshot.", "tags": ["Workflows"], - "security": [ - { - "apiKey": [] - }, - {} - ], "parameters": [ { - "name": "id", + "name": "version", "in": "path", "required": true, - "description": "Unique workflow identifier.", + "description": "Numeric deployment version.", "schema": { - "type": "string", - "minLength": 1, - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Numeric deployment version.", + "examples": [3] } }, { - "name": "x-run-id", - "in": "header", - "required": false, - "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", + "name": "workflowId", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", "schema": { - "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "examples": ["run_8f14e45f-ceea-467f-a"] - } - }, - { - "name": "x-sim-via", - "in": "header", - "required": false, - "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", - "schema": { - "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", - "type": "string" + "description": "Unique workflow identifier." } } ], - "requestBody": { - "required": true, - "description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExecuteWorkflowRequest" - } - } - } - }, "responses": { "200": { - "description": "A synchronous run result or Server-Sent Event stream.", + "description": "The requested deployment version.", "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1170,22 +1216,80 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExecuteWorkflowSyncResponse" - } - }, - "text/event-stream": { - "schema": { - "type": "string" + "$ref": "#/components/schemas/WorkflowVersionDetailResponse" } } } }, - "202": { - "description": "The asynchronous run was queued.", + "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" + } + } + }, + "patch": { + "operationId": "updateWorkflowVersionV2", + "summary": "Update Workflow Version", + "description": "Relabel a deployment version. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the release note. Metadata only — the pinned graph is immutable, and this never changes which version is live. Promote a version with `POST /workflows/{workflowId}/versions/{version}/activate`.", + "tags": ["Workflows"], + "parameters": [ + { + "name": "version", + "in": "path", + "required": true, + "description": "Numeric deployment version.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Numeric deployment version.", + "examples": [3] + } + }, + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Merge-patch body for the mutable metadata of a deployment version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowVersionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated version metadata.", "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1199,7 +1303,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExecuteWorkflowQueuedResponse" + "$ref": "#/components/schemas/UpdateWorkflowVersionResponse" } } } @@ -1210,27 +1314,21 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/RunIdConflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, - "499": { - "$ref": "#/components/responses/ClientClosedRequest" - }, "500": { "$ref": "#/components/responses/InternalError" }, @@ -1240,111 +1338,52 @@ } } }, - "/api/v2/workflows/{id}/runs": { - "get": { - "operationId": "listWorkflowRunsV2", - "summary": "List Workflow Runs", - "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", - "tags": ["Workflow Runs"], + "/api/v2/workflows/{workflowId}/versions/{version}/activate": { + "post": { + "operationId": "activateWorkflowVersion", + "summary": "Activate Workflow Version", + "description": "Promote an existing deployment version to live. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state. Unlike `rollback`, the target is named by the path and the workflow need not already be deployed. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "version", "in": "path", "required": true, - "description": "Unique workflow identifier.", + "description": "Numeric deployment version.", "schema": { - "type": "string", - "minLength": 1, - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Numeric deployment version.", + "examples": [3] } }, { - "name": "status", - "in": "query", - "required": false, - "description": "Filter by run status.", + "name": "workflowId", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", "schema": { - "description": "Filter by run status.", "type": "string", - "enum": ["pending", "running", "completed", "failed", "cancelled", "paused"] - } - }, - { - "name": "trigger", - "in": "query", - "required": false, - "description": "Filter by trigger type.", - "schema": { - "description": "Filter by trigger type.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "startDate", - "in": "query", - "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." - } - }, - { - "name": "endDate", - "in": "query", - "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum workflow runs to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum workflow runs 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": "order", - "in": "query", - "required": false, - "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", - "schema": { - "default": "desc", - "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", - "type": "string", - "enum": ["asc", "desc"] + "minLength": 1, + "description": "Unique workflow identifier." } } ], + "requestBody": { + "required": false, + "description": "No body. The version to promote is named by the request path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateWorkflowVersionRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of workflow runs.", + "description": "The accepted activation attempt.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1359,7 +1398,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowRunListResponse" + "$ref": "#/components/schemas/ActivateWorkflowVersionResponse" } } } @@ -1376,6 +1415,18 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1388,62 +1439,60 @@ } } }, - "/api/v2/workflows/{id}/runs/{runId}": { - "get": { - "operationId": "getWorkflowRunV2", - "summary": "Get Workflow Run", - "description": "Get current workflow run state, optionally including final and block outputs.", - "tags": ["Workflow Runs"], + "/api/v2/workflows/{workflowId}/versions/{version}/revert": { + "post": { + "operationId": "revertWorkflowVersion", + "summary": "Revert Workflow To Version", + "description": "Overwrite the editable draft with the graph pinned by a deployment version, discarding every unsaved edit. This is the most destructive operation in the deployment family and it does **not** change what is live — to move production, use `activate` or `rollback`, both of which leave the draft alone. Pass `active` as the version to discard draft edits and return to the live graph. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "version", "in": "path", "required": true, - "description": "Unique workflow identifier.", + "description": "Numeric deployment version, or `active` for the currently live version.", "schema": { - "type": "string", - "minLength": 1, - "description": "Unique workflow identifier." + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647 + }, + { + "type": "string", + "const": "active" + } + ], + "description": "Numeric deployment version, or `active` for the currently live version.", + "examples": [3, "active"] } }, { - "name": "runId", + "name": "workflowId", "in": "path", "required": true, - "description": "Unique workflow run identifier.", + "description": "Unique workflow identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] - } - }, - { - "name": "includeOutput", - "in": "query", - "required": false, - "description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.", - "schema": { - "description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.", - "type": "boolean" - } - }, - { - "name": "selectedOutputs", - "in": "query", - "required": false, - "description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.", - "schema": { - "description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.", - "type": "string" + "description": "Unique workflow identifier." } } ], + "requestBody": { + "required": false, + "description": "No body. The version to load into the draft is named by the request path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevertWorkflowVersionRequest" + } + } + } + }, "responses": { "200": { - "description": "The workflow run status.", + "description": "The draft after it was overwritten.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1458,7 +1507,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowRunStatusResponse" + "$ref": "#/components/schemas/RevertWorkflowVersionResponse" } } } @@ -1478,6 +1527,15 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1490,81 +1548,30 @@ } } }, - "/api/v2/workflows/{id}/runs/{runId}/resume": { - "post": { - "operationId": "resumeWorkflowRunV2", - "summary": "Resume Workflow Run", - "description": "Resume one human-in-the-loop pause context. The resumed attempt receives a new run identifier and may complete synchronously or return a queue receipt.", - "tags": ["Workflow Runs"], + "/api/v2/workflows/{workflowId}/deployment": { + "get": { + "operationId": "getWorkflowDeployment", + "summary": "Get Workflow Deployment", + "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment` and `isPublicApi`.\n\n`isPublicApi` is the security-relevant one: while it is `true` the deployed workflow executes without an API key, so anyone holding the execution URL can run it — and consume the workspace’s billed usage — anonymously. It is set through `PATCH /workflows/{workflowId}/deployment`, and this read is the only way to audit whether it is on.\n\nNot to be confused with `/workflows/{workflowId}/deployments/chat`, which is the hosted chat the workflow is published as. This path governs whether the workflow is executable at all; that one governs one surface it is served on. A workflow can be deployed with no chat, and removing its chat leaves it deployed and executable.", + "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow identifier." - } - }, - { - "name": "runId", - "in": "path", - "required": true, - "description": "Unique workflow run identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], - "requestBody": { - "required": true, - "description": "Pause context and optional input used to resume a workflow run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResumeWorkflowRequest" - } - } - } - }, "responses": { "200": { - "description": "The resumed workflow attempt completed synchronously.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - }, - "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/ResumeWorkflowSyncResponse" - } - } - } - }, - "202": { - "description": "The resumed workflow attempt was queued.", + "description": "The current deployment state.", "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1578,7 +1585,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResumeWorkflowQueuedResponse" + "$ref": "#/components/schemas/WorkflowDeploymentResponse" } } } @@ -1589,21 +1596,12 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1614,44 +1612,40 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workflows/{id}/runs/{runId}/cancel": { - "post": { - "operationId": "cancelRunV2", - "summary": "Cancel Workflow Run", - "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.", - "tags": ["Workflow Runs"], + }, + "patch": { + "operationId": "updateWorkflowPublicApi", + "summary": "Update Workflow Public API Access", + "description": "Enable or disable unauthenticated public execution of the deployed workflow. While enabled, anyone holding the execution URL can run the workflow without an API key. An organization that forbids public sharing refuses this with `403` and `PUBLIC_SHARING_NOT_ALLOWED`. Not to be confused with `/workflows/{workflowId}/deployments/chat`, which is the hosted chat the workflow is published as. This path governs whether the workflow is executable at all; that one governs one surface it is served on. A workflow can be deployed with no chat, and removing its chat leaves it deployed and executable. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], "parameters": [ { - "name": "id", + "name": "workflowId", "in": "path", "required": true, "description": "Unique workflow identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow identifier." - } - }, - { - "name": "runId", - "in": "path", - "required": true, - "description": "Unique workflow run identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], + "requestBody": { + "required": true, + "description": "Enable or disable unauthenticated public execution of the deployed workflow.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowPublicApiRequest" + } + } + } + }, "responses": { "200": { - "description": "The cancellation outcome.", + "description": "The updated public API setting.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1666,7 +1660,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CancelWorkflowRunResponse" + "$ref": "#/components/schemas/UpdateWorkflowPublicApiResponse" } } } @@ -1683,8 +1677,14 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1698,75 +1698,40 @@ } } }, - "/api/v2/workflows/folders": { - "get": { - "operationId": "listWorkflowsFolders", - "summary": "List Workflow Folders", - "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "/api/v2/workflows/{workflowId}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "workflowId", + "in": "path", "required": true, - "description": "Workspace whose folders should be listed.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace whose folders should be listed." - } - }, - { - "name": "parentPath", - "in": "query", - "required": false, - "description": "Restrict results to direct children of this parent path.", - "schema": { - "description": "Restrict results to direct children of this parent path.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the folder name.", + "description": "Unique workflow identifier.", "schema": { - "description": "Case-insensitive substring match against the folder name.", "type": "string", "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployWorkflowRequest" + } + } + } + }, "responses": { "200": { - "description": "A list of workflow folders.", + "description": "The accepted deployment attempt.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1781,7 +1746,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowFolderListResponse" + "$ref": "#/components/schemas/DeployWorkflowResponse" } } } @@ -1798,9 +1763,18 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1812,25 +1786,28 @@ } } }, - "post": { - "operationId": "createWorkflowsFolder", - "summary": "Create Workflow Folder", - "description": "Create a canonical workflow folder in a workspace. A workspace folder tree over 10,000 folders is a `413`.", + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], - "requestBody": { - "required": true, - "description": "Workspace and canonical path for a new workflow folder.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateWorkflowFolderRequest" - } + "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": { - "201": { - "description": "The created workflow folder.", + "200": { + "description": "The workflow was undeployed.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1845,7 +1822,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkflowFolderResponse" + "$ref": "#/components/schemas/UndeployWorkflowResponse" } } } @@ -1862,12 +1839,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1881,26 +1852,42 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "relocateWorkflowsFolder", - "summary": "Rename or Move Workflow Folder", - "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace folder tree over 10,000 folders is a `413`.", + } + }, + "/api/v2/workflows/{workflowId}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. Use this to step back from the currently live version; to make a specific version live by naming it in the path — including when the workflow is not currently deployed — use `POST /workflows/{workflowId}/versions/{version}/activate`. Neither touches the draft. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], + "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"] + } + } + ], "requestBody": { - "required": true, - "description": "Current and destination paths for a workflow folder.", + "required": false, + "description": "Optional deployment version to reactivate.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RelocateWorkflowFolderRequest" + "$ref": "#/components/schemas/RollbackWorkflowRequest" } } } }, "responses": { "200": { - "description": "The relocated workflow folder.", + "description": "The accepted rollback attempt.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1915,7 +1902,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RelocateWorkflowFolderResponse" + "$ref": "#/components/schemas/RollbackWorkflowResponse" } } } @@ -1938,6 +1925,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1951,64 +1941,31 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteWorkflowsFolder", - "summary": "Delete Workflow Folder", - "description": "Delete a workflow folder, optionally including its descendants and workflows.", + } + }, + "/api/v2/workflows/{workflowId}/export": { + "get": { + "operationId": "exportWorkflow", + "summary": "Export Workflow", + "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "workflowId", + "in": "path", "required": true, - "description": "Workspace containing the folder.", + "description": "Unique workflow identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace containing the folder." - } - }, - { - "name": "path", - "in": "query", - "required": true, - "description": "Path of the folder to delete.", - "schema": { - "description": "Path of the folder to delete.", - "$ref": "#/components/schemas/NonRootFolderPathInput" - } - }, - { - "name": "recursive", - "in": "query", - "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "default": "false", - "type": "string" + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], "responses": { "200": { - "description": "The workflow folder was deleted.", + "description": "The workflow export payload.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2023,7 +1980,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteWorkflowFolderResponse" + "$ref": "#/components/schemas/ExportWorkflowResponse" } } } @@ -2040,15 +1997,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "423": { - "$ref": "#/components/responses/Locked" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2060,350 +2011,4465 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." + "/api/v2/workflows/import": { + "post": { + "operationId": "importWorkflow", + "summary": "Import Workflow", + "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "description": "Portable workflow data and destination metadata for an import.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportWorkflowRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The imported workflow.", + "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/ImportWorkflowResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } } } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { + "/api/v2/chat-deployments": { + "get": { + "operationId": "listChatDeployments", + "summary": "List Chat Deployments", + "description": "List the workflows a workspace has published as hosted chats. Each entry carries the public `url` a visitor uses — there is no chat subdomain, the identifier is a path segment.\n\nThis is the only chat path not addressed under a workflow, and deliberately so: every chat is a singleton of the workflow it publishes, but \"what does this workspace serve\" is a question no per-workflow path can answer. Filter by `workflowId` to resolve one workflow's chat without holding its id.\n\nEntries are deliberately narrower than the singleton read: `allowedEmails`, `hasPassword`, and `customizations` are available only from `GET /api/v2/workflows/{workflowId}/deployments/chat`, which requires workspace `admin`. That is what keeps this list callable at workspace `read` and by a workspace API key. A stored password is never returned by either.", + "tags": ["Workflows"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose chat deployments to list.", "schema": { - "$ref": "#/components/schemas/V2Error" + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose chat deployments to list." + } + }, + { + "name": "workflowId", + "in": "query", + "required": false, + "description": "Restrict to deployments of one workflow.", + "schema": { + "description": "Restrict to deployments of one workflow.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "isActive", + "in": "query", + "required": false, + "description": "Restrict to active or inactive deployments.", + "schema": { + "description": "Restrict to active or inactive deployments.", + "type": "boolean" + } + }, + { + "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": ["identifier", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum chat deployments to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum chat deployments 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 + } + } + ], + "responses": { + "200": { + "description": "A page of chat deployments.", + "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" + } }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatDeploymentListResponse" + } } } + }, + "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}/deployments/chat": { + "get": { + "operationId": "getWorkflowChatDeployment", + "summary": "Get Workflow Chat Deployment", + "description": "Read the hosted chat a workflow is published as. Answers `404` when the workflow publishes no chat. Not to be confused with `/workflows/{workflowId}/deployment` (singular), which is the workflow's own API deployment — its live version and whether the draft has drifted. That path governs whether the workflow is executable at all; this one governs the hosted chat it is served on. The chat is a singleton of its workflow, so it has no id of its own in any path and no separate create verb: `PUT` is create-or-replace and is the only write. The stored password is never returned — `hasPassword` reports only whether one is set. This carries the visitor gate — `authType`, `hasPassword`, and the `allowedEmails` allow-list — so it requires workspace `admin`, unlike the workspace-wide list. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], + "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's chat deployment.", + "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/GetWorkflowChatDeploymentResponse" + } + } + } + }, + "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" + } + } + }, + "put": { + "operationId": "replaceWorkflowChatDeployment", + "summary": "Create or Replace Workflow Chat Deployment", + "description": "Publish a workflow as a hosted chat, or replace the chat it already publishes. Not to be confused with `/workflows/{workflowId}/deployment` (singular), which is the workflow's own API deployment — its live version and whether the draft has drifted. That path governs whether the workflow is executable at all; this one governs the hosted chat it is served on. The chat is a singleton of its workflow, so it has no id of its own in any path and no separate create verb: `PUT` is create-or-replace and is the only write.\n\n**Replace, not merge.** The chat ends up as exactly what the body describes: an omitted optional field takes its platform default rather than whatever the previous chat carried, so sending the same body twice leaves the same result. `password` is therefore required whenever `authType` is `\"password\"` and rejected otherwise — it is write-only and never readable back, so carrying one over implicitly is the one place a replace would quietly stop meaning replace. `allowedEmails` follows the same rule: required and non-empty for `\"email\"` and `\"sso\"`, rejected for the modes that admit no allow-list. `customizations` is the one documented exception: it merges per field, so an omitted `imageUrl` keeps the stored one rather than clearing it, and customization keys this surface does not declare do not survive the write. That behaviour is shared with the in-app editor and the Copilot deploy tool, which both send partial objects.\n\nThis also deploys the workflow, because a chat serves the live version: a draft that has drifted is republished as part of the call. Two conditions answer `409` — an `identifier` another live chat already holds, and a workflow deployment attempt still preparing, which the caller can retry once it becomes active. `authType: \"public\"` leaves the chat open to anyone holding the URL. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], + "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"] + } + } + ], + "requestBody": { + "required": true, + "description": "The complete desired state of a workflow's chat.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceChatDeploymentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The published chat deployment.", + "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/ReplaceWorkflowChatDeploymentResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteWorkflowChatDeployment", + "summary": "Delete Workflow Chat Deployment", + "description": "Stop serving a workflow's hosted chat. Its URL stops answering and the identifier becomes free again. The workflow's own deployment is untouched and stays executable through the workflow API — to undeploy that, use `DELETE /workflows/{workflowId}/deploy`. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], + "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 chat deployment was removed.", + "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/DeleteWorkflowChatDeploymentResponse" + } + } + } + }, + "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}/execute": { + "post": { + "operationId": "executeWorkflowV2", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + }, + {} + ], + "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"] + } + }, + { + "name": "x-run-id", + "in": "header", + "required": false, + "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", + "schema": { + "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "examples": ["run_8f14e45f-ceea-467f-a"] + } + }, + { + "name": "x-sim-via", + "in": "header", + "required": false, + "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", + "schema": { + "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteWorkflowRequest" + } + } + } + }, + "responses": { + "200": { + "description": "A synchronous run result or Server-Sent Event stream.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + }, + "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/ExecuteWorkflowSyncResponse" + } + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "202": { + "description": "The asynchronous run was queued.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + }, + "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/ExecuteWorkflowQueuedResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/RunIdConflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "499": { + "$ref": "#/components/responses/ClientClosedRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/{workflowId}/runs": { + "get": { + "operationId": "listWorkflowRunsV2", + "summary": "List Workflow Runs", + "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", + "tags": ["Workflow Runs"], + "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"] + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by run status.", + "schema": { + "description": "Filter by run status.", + "type": "string", + "enum": ["pending", "running", "completed", "failed", "cancelled", "paused"] + } + }, + { + "name": "trigger", + "in": "query", + "required": false, + "description": "Filter by trigger type.", + "schema": { + "description": "Filter by trigger type.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum workflow runs to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum workflow runs 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": "order", + "in": "query", + "required": false, + "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", + "schema": { + "default": "desc", + "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", + "type": "string", + "enum": ["asc", "desc"] + } + } + ], + "responses": { + "200": { + "description": "A page of workflow runs.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowRunListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/{workflowId}/runs/{runId}": { + "get": { + "operationId": "getWorkflowRunV2", + "summary": "Get Workflow Run", + "description": "Get current workflow run state, optionally including final and block outputs. With `includeOutput`, `files` lists the files the run produced, each with a `downloadPath`; add `includeFileBase64` to inline their bytes, which answers `413` naming the download path when a single file, or the run's inlined total, exceeds the 16 MiB ceiling. Because inlining reads object storage, this `GET` is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return.", + "tags": ["Workflow Runs"], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." + } + }, + { + "name": "runId", + "in": "path", + "required": true, + "description": "Unique workflow run identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.", + "schema": { + "description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.", + "type": "boolean" + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.", + "schema": { + "description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.", + "type": "string" + } + }, + { + "name": "includeFileBase64", + "in": "query", + "required": false, + "description": "Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead.", + "schema": { + "description": "Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead.", + "type": "boolean" + } + }, + { + "name": "base64MaxBytes", + "in": "query", + "required": false, + "description": "Per-file inline ceiling, lowering but never raising the server limit of 16 MiB.", + "schema": { + "description": "Per-file inline ceiling, lowering but never raising the server limit of 16 MiB.", + "type": "integer", + "minimum": 1, + "maximum": 16777216 + } + } + ], + "responses": { + "200": { + "description": "The workflow run status.", + "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/WorkflowRunStatusResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/{workflowId}/runs/{runId}/files/{fileId}": { + "get": { + "operationId": "downloadWorkflowRunFileV2", + "summary": "Download Workflow Run File", + "description": "Download one file a run produced. The run resource reports the files a run emitted; address one of them by its `id` here. Run output carries `/api/files/serve/...` URLs that reject API keys, so this is the byte path out of a run for an API-key caller. Execution objects are not retained indefinitely, so a `404` for a file an older run produced is expected rather than a fault. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "tags": ["Workflow Runs"], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." + } + }, + { + "name": "runId", + "in": "path", + "required": true, + "description": "Unique workflow run identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + } + }, + { + "name": "fileId", + "in": "path", + "required": true, + "description": "Identifier of a file the run produced, as reported by the run resource.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Identifier of a file the run produced, as reported by the run resource." + } + } + ], + "responses": { + "200": { + "description": "The run file bytes.", + "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-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/{workflowId}/runs/{runId}/resume": { + "post": { + "operationId": "resumeWorkflowRunV2", + "summary": "Resume Workflow Run", + "description": "Resume one human-in-the-loop pause context. The resumed attempt receives a new run identifier and may complete synchronously or return a queue receipt.", + "tags": ["Workflow Runs"], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." + } + }, + { + "name": "runId", + "in": "path", + "required": true, + "description": "Unique workflow run identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + } + } + ], + "requestBody": { + "required": true, + "description": "Pause context and optional input used to resume a workflow run.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeWorkflowRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The resumed workflow attempt completed synchronously.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + }, + "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/ResumeWorkflowSyncResponse" + } + } + } + }, + "202": { + "description": "The resumed workflow attempt was queued.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + }, + "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/ResumeWorkflowQueuedResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/{workflowId}/runs/{runId}/cancel": { + "post": { + "operationId": "cancelRunV2", + "summary": "Cancel Workflow Run", + "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.", + "tags": ["Workflow Runs"], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." + } + }, + { + "name": "runId", + "in": "path", + "required": true, + "description": "Unique workflow run identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + } + } + ], + "responses": { + "200": { + "description": "The cancellation outcome.", + "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/CancelWorkflowRunResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/folders": { + "get": { + "operationId": "listWorkflowsFolders", + "summary": "List Workflow Folders", + "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Workflows"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose folders should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose folders should be listed." + } + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Restrict results to direct children of this parent path.", + "schema": { + "description": "Restrict results to direct children of this parent path.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the folder name.", + "schema": { + "description": "Case-insensitive substring match against the folder name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + } + ], + "responses": { + "200": { + "description": "A list of workflow folders.", + "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/WorkflowFolderListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "createWorkflowsFolder", + "summary": "Create Workflow Folder", + "description": "Create a canonical workflow folder in a workspace. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "description": "Workspace and canonical path for a new workflow folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowFolderRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created workflow folder.", + "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/CreateWorkflowFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "relocateWorkflowsFolder", + "summary": "Rename or Move Workflow Folder", + "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "description": "Current and destination paths for a workflow folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelocateWorkflowFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The relocated workflow folder.", + "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/RelocateWorkflowFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteWorkflowsFolder", + "summary": "Delete Workflow Folder", + "description": "Delete a workflow folder, optionally including its descendants and workflows.", + "tags": ["Workflows"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace containing the folder.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the folder." + } + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Path of the folder to delete.", + "schema": { + "description": "Path of the folder to delete.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "default": "false", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The workflow folder was deleted.", + "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/DeleteWorkflowFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "Content-Type": { + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", + "schema": { + "type": "string", + "title": "Content type", + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." + } + }, + "Content-Disposition": { + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", + "schema": { + "type": "string", + "title": "Content disposition", + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." + } + }, + "Content-Length": { + "description": "File size in bytes.", + "schema": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)$", + "title": "Content length", + "description": "File size in bytes." + } + }, + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Webhook path already in use" + } + } + } + } + }, + "RunIdConflict": { + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Run ID has already been used", + "details": { + "code": "RUN_ID_CONFLICT", + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, + "Locked": { + "description": "The resource is locked and cannot be modified.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "ClientClosedRequest": { + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CLIENT_CLOSED_REQUEST", + "message": "Client cancelled request", + "details": { + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "WorkflowListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow summary", + "description": "Summary of a workflow and its deployment and run state." + }, + "WorkflowListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Workflow list response", + "description": "A cursor-paginated page of workflow summaries.", + "examples": [ + { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "SeededWorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block identifier." + }, + "type": { + "type": "string", + "description": "Registered block type." + }, + "name": { + "type": "string", + "description": "Block display name." + } + }, + "required": ["id", "type", "name"], + "additionalProperties": false, + "title": "Seeded workflow block", + "description": "A block the platform placed in a newly created workflow." + }, + "CreateWorkflowResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeededWorkflowBlock" + }, + "description": "Blocks seeded into the new workflow. Contains the start block; attach edges to its `id`." + } + }, + "required": [ + "id", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt", + "blocks" + ], + "additionalProperties": false, + "title": "Create workflow result", + "description": "The created workflow and the blocks it was seeded with." + }, + "CreateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/CreateWorkflowResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create workflow response", + "description": "The created workflow and the blocks it was seeded with.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z", + "blocks": [ + { + "id": "start-1", + "type": "starter", + "name": "Start" + } + ] + } + } + ] + }, + "CreateWorkflowRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the workflow." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Workflow name." + }, + "description": { + "description": "Optional workflow description.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + }, + "folderPath": { + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create workflow request", + "description": "Name, description, workspace, and optional folder for a new workflow." + }, + "WorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Block identifier, unique within the workflow." + }, + "type": { + "type": "string", + "description": "Registered block type." + }, + "name": { + "type": "string", + "description": "Block display name; must be unique within the workflow." + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + }, + "required": ["x", "y"], + "additionalProperties": false, + "description": "Canvas coordinates of a block." + }, + "subBlocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Sub-block identifier." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Sub-block input type." + }, + "value": { + "description": "Configured value; shape depends on the sub-block type." + } + }, + "required": ["id", "type", "value"], + "additionalProperties": false, + "description": "One configurable input on a block." + }, + "description": "Configured inputs keyed by sub-block id." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Declared shape of one output; depends on the block type." + }, + "description": "Declared output shape keyed by output name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "horizontalHandles": { + "description": "Whether edge handles render horizontally.", + "type": "boolean" + }, + "height": { + "description": "Rendered block height.", + "type": "number" + }, + "advancedMode": { + "description": "Whether the block is edited in advanced mode.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block exposes an error branch.", + "type": "boolean" + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "additionalProperties": false, + "description": "Per-block retry configuration." + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "parentId": { + "description": "Identifier of the containing loop or parallel.", + "type": "string" + }, + "extent": { + "description": "Constrains the block to its parent bounds.", + "type": "string", + "const": "parent" + }, + "width": { + "description": "Rendered container width.", + "type": "number" + }, + "height": { + "description": "Rendered container height.", + "type": "number" + }, + "collection": { + "description": "Items a forEach loop or collection parallel iterates." + }, + "count": { + "description": "Iteration count for a `for` loop or count parallel.", + "type": "number" + }, + "loopType": { + "description": "Loop container kind.", + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "parallelType": { + "description": "Parallel container kind.", + "type": "string", + "enum": ["collection", "count"] + }, + "batchSize": { + "description": "Maximum concurrent branches of a parallel.", + "type": "number" + }, + "type": { + "description": "Container subtype.", + "type": "string" + }, + "canonicalModes": { + "description": "Per-field editing mode, keyed by canonical parameter id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + }, + "additionalProperties": false, + "description": "Container and layout metadata carried by a block." + }, + "locked": { + "description": "Whether the block is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], + "additionalProperties": false, + "title": "Workflow block", + "description": "One node of a workflow graph and its configuration." + }, + "WorkflowEdge": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Edge identifier, unique within the workflow." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source block id." + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Target block id." + }, + "sourceHandle": { + "description": "Source port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetHandle": { + "description": "Target port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "Edge renderer type.", + "type": "string" + } + }, + "required": ["id", "source", "target"], + "additionalProperties": false, + "title": "Workflow edge", + "description": "A directed connection between two blocks." + }, + "WorkflowLoop": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Loop container identifier; equal to the loop block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the loop." + }, + "iterations": { + "type": "number", + "description": "Resolved iteration count." + }, + "loopType": { + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"], + "description": "Loop kind." + }, + "forEachItems": { + "description": "Items a forEach loop iterates, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item the loop iterates." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item the loop iterates." + } + }, + { + "type": "string" + } + ] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "enabled": { + "description": "Whether the loop runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the loop is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes", "iterations", "loopType"], + "additionalProperties": false, + "title": "Workflow loop", + "description": "A loop container derived from the workflow blocks." + }, + "WorkflowParallel": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parallel container identifier; equal to the parallel block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the parallel." + }, + "distribution": { + "description": "Items distributed across branches, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item distributed to a branch." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item distributed to a branch." + } + }, + { + "type": "string" + } + ] + }, + "count": { + "description": "Fixed branch count.", + "type": "number" + }, + "parallelType": { + "description": "Parallel kind.", + "type": "string", + "enum": ["count", "collection"] + }, + "batchSize": { + "description": "Maximum concurrent branches.", + "type": "number" + }, + "enabled": { + "description": "Whether the parallel runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the parallel is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes"], + "additionalProperties": false, + "title": "Workflow parallel", + "description": "A parallel container derived from the workflow blocks." + }, + "WorkflowVariable": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Variable identifier." + }, + "name": { + "type": "string", + "description": "Variable name, referenced from block inputs." + }, + "type": { + "default": "string", + "description": "Declared variable type.", + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"] + }, + "value": { + "description": "Variable value; free-form and validated per `type` at use time." + } + }, + "required": ["id", "name", "type", "value"], + "additionalProperties": false, + "title": "Workflow variable", + "description": "A workflow-scoped variable." + }, + "WorkflowGraph": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowBlock" + }, + "description": "Blocks keyed by block id." + }, + "edges": { + "maxItems": 10000, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEdge" + }, + "description": "Directed connections between blocks." + }, + "loops": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowLoop" + }, + "description": "Loop containers keyed by container id; always present, `{}` when there are none." + }, + "parallels": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowParallel" + }, + "description": "Parallel containers keyed by container id; always present, `{}` when there are none." + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowVariable" + }, + "description": "Workflow variables keyed by variable id; always present, `{}` when there are none." + } + }, + "required": ["blocks", "edges", "loops", "parallels", "variables"], + "additionalProperties": false, + "title": "Workflow graph", + "description": "The editable draft graph of a workflow: blocks, edges, derived loop and parallel containers, and variables." + }, + "WorkflowStateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowGraph" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow state response", + "description": "The editable draft graph of a workflow.", + "examples": [ + { + "data": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {}, + "variables": {} + } + } + ] + }, + "WorkflowLintReport": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with no incoming edge. A trigger block is naturally a source; anything else here is unreachable." + }, + "sinks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with no outgoing edge." + }, + "orphanBlocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with neither an incoming nor an outgoing edge." + }, + "emptyOutgoingPorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "handle": { + "type": "string", + "description": "Source handle with nothing connected to it." + }, + "label": { + "type": "string", + "description": "Human-readable name of the port." + } + }, + "required": ["blockId", "blockName", "blockType", "handle", "label"], + "additionalProperties": false + }, + "description": "Branch and container ports that lead nowhere." + }, + "invalidBranchPorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "sourceHandle": { + "type": "string", + "description": "Source handle that does not match the block." + }, + "reason": { + "type": "string", + "description": "Why the handle is not valid for this block." + } + }, + "required": ["blockId", "blockName", "blockType", "sourceHandle", "reason"], + "additionalProperties": false + }, + "description": "Condition and router edges whose source handle names no real branch." + }, + "invalidConnectionTargets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceBlockId": { + "type": "string", + "description": "Block the edge leaves." + }, + "sourceBlockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the source block." + }, + "sourceHandle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Handle the edge leaves from." + }, + "targetBlockId": { + "type": "string", + "description": "Block the edge points at." + }, + "reason": { + "type": "string", + "description": "Why the target is not a legal destination." + } + }, + "required": [ + "sourceBlockId", + "sourceBlockName", + "sourceHandle", + "targetBlockId", + "reason" + ], + "additionalProperties": false + }, + "description": "Edges pointing at a block that cannot legally receive them." + }, + "fieldIssues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "missingRequiredFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Required sub-block fields that resolve empty in the active mode." + }, + "inactiveModeValues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "canonicalId": { + "type": "string", + "description": "Canonical parameter the two sub-block modes share." + }, + "activeMemberId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Sub-block the runtime reads, where the value should live." + }, + "inactiveMemberId": { + "type": "string", + "description": "Sub-block holding the stranded value, which the runtime ignores." + }, + "kind": { + "type": "string", + "enum": ["credential", "resource", "other"], + "description": "What kind of value is stranded." + } + }, + "required": ["canonicalId", "activeMemberId", "inactiveMemberId", "kind"], + "additionalProperties": false + }, + "description": "Values stranded on the inactive member of a canonical pair." + } + }, + "required": [ + "blockId", + "blockName", + "blockType", + "missingRequiredFields", + "inactiveModeValues" + ], + "additionalProperties": false + }, + "description": "Per-block configuration problems. The most actionable part of the report for a headless graph builder: a block missing a required field will fail at run time." + }, + "unresolvedReferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "field": { + "type": "string", + "description": "Sub-block field holding the reference." + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "The reference, or references, that did not resolve." + }, + "kind": { + "type": "string", + "enum": ["credential", "resource", "custom-tool", "mcp-tool", "skill"], + "description": "What kind of entity the reference was expected to name." + }, + "reason": { + "type": "string", + "description": "Why the reference does not resolve." + } + }, + "required": ["blockId", "blockName", "blockType", "field", "value", "kind", "reason"], + "additionalProperties": false + }, + "description": "Credential, resource, tool, and skill references that do not resolve. These values are still persisted; they are reported, not dropped." + }, + "notes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Advisory notes about the report itself." + } + }, + "required": [ + "sources", + "sinks", + "orphanBlocks", + "emptyOutgoingPorts", + "invalidBranchPorts", + "invalidConnectionTargets", + "fieldIssues", + "unresolvedReferences", + "notes" + ], + "additionalProperties": false, + "title": "Workflow lint report", + "description": "Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time." + }, + "ReplaceWorkflowStateResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + }, + "lint": { + "$ref": "#/components/schemas/WorkflowLintReport" + }, + "dryRun": { + "type": "boolean", + "description": "Whether this request only validated. `true` means nothing was persisted; the findings describe what a committed write of the same body would produce." + } + }, + "required": ["id", "warnings", "needsRedeployment", "lint", "dryRun"], + "additionalProperties": false, + "title": "Replace workflow state result", + "description": "Outcome of replacing a workflow draft graph, with its advisory findings." + }, + "ReplaceWorkflowStateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ReplaceWorkflowStateResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Replace workflow state response", + "description": "Outcome of replacing a workflow draft graph.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "warnings": [], + "needsRedeployment": true, + "dryRun": false, + "lint": { + "sources": [], + "sinks": [], + "orphanBlocks": [], + "emptyOutgoingPorts": [], + "invalidBranchPorts": [], + "invalidConnectionTargets": [], + "fieldIssues": [], + "unresolvedReferences": [], + "notes": [] + } + } + } + ] + }, + "WorkflowBlockInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Block identifier, unique within the workflow." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Block display name; must be unique within the workflow." + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + }, + "required": ["x", "y"], + "description": "Canvas coordinates of a block." + }, + "subBlocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Sub-block identifier." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Sub-block input type." + }, + "value": { + "description": "Configured value; shape depends on the sub-block type." + } + }, + "required": ["id", "type", "value"], + "description": "One configurable input on a block." + }, + "description": "Configured inputs keyed by sub-block id." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Declared shape of one output; depends on the block type." + }, + "description": "Declared output shape keyed by output name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "horizontalHandles": { + "description": "Whether edge handles render horizontally.", + "type": "boolean" + }, + "height": { + "description": "Rendered block height.", + "type": "number" + }, + "advancedMode": { + "description": "Whether the block is edited in advanced mode.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block exposes an error branch.", + "type": "boolean" + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "description": "Per-block retry configuration." + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "parentId": { + "description": "Identifier of the containing loop or parallel.", + "type": "string" + }, + "extent": { + "description": "Constrains the block to its parent bounds.", + "type": "string", + "const": "parent" + }, + "width": { + "description": "Rendered container width.", + "type": "number" + }, + "height": { + "description": "Rendered container height.", + "type": "number" + }, + "collection": { + "description": "Items a forEach loop or collection parallel iterates." + }, + "count": { + "description": "Iteration count for a `for` loop or count parallel.", + "type": "number" + }, + "loopType": { + "description": "Loop container kind.", + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "parallelType": { + "description": "Parallel container kind.", + "type": "string", + "enum": ["collection", "count"] + }, + "batchSize": { + "description": "Maximum concurrent branches of a parallel.", + "type": "number" + }, + "type": { + "description": "Container subtype.", + "type": "string" + }, + "canonicalModes": { + "description": "Per-field editing mode, keyed by canonical parameter id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + }, + "description": "Container and layout metadata carried by a block." + }, + "locked": { + "description": "Whether the block is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], + "title": "Workflow block", + "description": "One node of a workflow graph and its configuration." + }, + "WorkflowEdgeInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Edge identifier, unique within the workflow." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source block id." + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Target block id." + }, + "sourceHandle": { + "description": "Source port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetHandle": { + "description": "Target port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "Edge renderer type.", + "type": "string" + } + }, + "required": ["id", "source", "target"], + "title": "Workflow edge", + "description": "A directed connection between two blocks." + }, + "WorkflowLoopInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Loop container identifier; equal to the loop block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the loop." + }, + "iterations": { + "type": "number", + "description": "Resolved iteration count." + }, + "loopType": { + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"], + "description": "Loop kind." + }, + "forEachItems": { + "description": "Items a forEach loop iterates, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item the loop iterates." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item the loop iterates." + } + }, + { + "type": "string" + } + ] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "enabled": { + "description": "Whether the loop runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the loop is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes", "iterations", "loopType"], + "title": "Workflow loop", + "description": "A loop container derived from the workflow blocks." + }, + "WorkflowParallelInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parallel container identifier; equal to the parallel block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the parallel." + }, + "distribution": { + "description": "Items distributed across branches, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item distributed to a branch." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item distributed to a branch." + } + }, + { + "type": "string" + } + ] + }, + "count": { + "description": "Fixed branch count.", + "type": "number" + }, + "parallelType": { + "description": "Parallel kind.", + "type": "string", + "enum": ["count", "collection"] + }, + "batchSize": { + "description": "Maximum concurrent branches.", + "type": "number" + }, + "enabled": { + "description": "Whether the parallel runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the parallel is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes"], + "title": "Workflow parallel", + "description": "A parallel container derived from the workflow blocks." + }, + "WorkflowVariableInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Variable identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name, referenced from block inputs." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value; free-form and validated per `type` at use time." + } + }, + "required": ["id", "name", "type", "value"], + "title": "Workflow variable", + "description": "A workflow-scoped variable." + }, + "ReplaceWorkflowStateRequest": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowBlockInput" + }, + "description": "Blocks keyed by block id." + }, + "edges": { + "maxItems": 10000, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEdgeInput" + }, + "description": "Directed connections between blocks." + }, + "loops": { + "description": "Ignored on write: loop containers are recomputed from `blocks`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowLoopInput" + } + }, + "parallels": { + "description": "Ignored on write: parallel containers are recomputed from `blocks`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowParallelInput" + } + }, + "variables": { + "description": "Replacement variable set. Omit to leave the stored variables untouched.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowVariableInput" + } + } + }, + "required": ["blocks", "edges"], + "additionalProperties": false, + "title": "Replace workflow state request", + "description": "A complete replacement draft graph for a workflow.", + "examples": [ + { + "blocks": {}, + "edges": [] + } + ] + }, + "WorkflowSkippedItem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "block_not_found", + "invalid_block_type", + "block_not_allowed", + "block_locked", + "tool_not_allowed", + "invalid_edge_target", + "invalid_edge_source", + "invalid_edge_scope", + "invalid_source_handle", + "invalid_target_handle", + "invalid_subblock_field", + "missing_required_params", + "invalid_subflow_parent", + "nested_subflow_not_allowed", + "duplicate_block_name", + "reserved_block_name", + "retry_not_supported", + "duplicate_trigger", + "duplicate_single_instance_block", + "disabled_ancestor" + ], + "description": "Machine-readable reason the engine declined an operation." + }, + "operationType": { + "type": "string", + "description": "The `operation_type` that was declined." + }, + "blockId": { + "type": "string", + "description": "Block the declined operation targeted." + }, + "reason": { + "type": "string", + "description": "Human-readable explanation." + }, + "details": { + "description": "Additional context for the reason; keys depend on `type`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One piece of engine-supplied context for the reason." + } + } + }, + "required": ["type", "operationType", "blockId", "reason"], + "additionalProperties": false, + "title": "Workflow skipped item", + "description": "One operation the edit engine did not apply." + }, + "WorkflowInputValidationError": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block whose input was rejected." + }, + "blockType": { + "type": "string", + "description": "Type of the block whose input was rejected." + }, + "field": { + "type": "string", + "description": "Sub-block field that was rejected." + }, + "error": { + "type": "string", + "description": "Why the value was rejected." } - } + }, + "required": ["blockId", "blockType", "field", "error"], + "additionalProperties": false, + "title": "Workflow input validation error", + "description": "One block input that was dropped rather than persisted." }, - "Unauthorized": { - "description": "The API key is missing or invalid.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "ApplyWorkflowOperationsResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "API key required" - } + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + }, + "applied": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Operations the engine applied." + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSkippedItem" + }, + "description": "Operations the engine declined. Empty when everything applied." + }, + "deferred": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSkippedItem" + }, + "description": "Forward-referencing edges the engine recorded rather than applied. These are NOT failures: the engine wires each one as soon as its target block exists, in this batch or a later one. Do not re-issue them." + }, + "inputValidationErrors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowInputValidationError" + }, + "description": "Block inputs that were dropped rather than persisted, and only those. The rest of the operation still applied. References that merely fail to resolve stay persisted and are reported in `lint.unresolvedReferences` instead." + }, + "mintedBlockIds": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "The id the block was actually given." + }, + "description": "The id each newly created block was actually given, keyed by the `block_id` you asked for, and present only for the ones that differ. A `block_id` on an `add` or `insert_into_subflow` that is not already a UUID is replaced with a minted one, so this is how you learn what to reference afterwards. Within a single batch you can keep using your own ids — references between operations are remapped for you — but a later request must use the minted id, so send your own UUIDs when you want an id you chose to survive." + }, + "lint": { + "$ref": "#/components/schemas/WorkflowLintReport" + }, + "dryRun": { + "type": "boolean", + "description": "Whether this request only evaluated. `true` means nothing was persisted; the outcome describes what a committed apply of the same body would produce." + } + }, + "required": [ + "id", + "warnings", + "needsRedeployment", + "applied", + "skipped", + "deferred", + "inputValidationErrors", + "mintedBlockIds", + "lint", + "dryRun" + ], + "additionalProperties": false, + "title": "Apply workflow operations result", + "description": "Outcome of a batch of semantic edits against a workflow graph." + }, + "ApplyWorkflowOperationsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ApplyWorkflowOperationsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Apply workflow operations response", + "description": "Outcome of a batch of semantic edits.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "applied": 1, + "skipped": [], + "deferred": [], + "inputValidationErrors": [], + "mintedBlockIds": { + "triage": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" + }, + "lint": { + "sources": [], + "sinks": [], + "orphanBlocks": [], + "emptyOutgoingPorts": [], + "invalidBranchPorts": [], + "invalidConnectionTargets": [], + "fieldIssues": [ + { + "blockId": "agent-1", + "blockName": "Triage", + "blockType": "agent", + "missingRequiredFields": ["systemPrompt"], + "inactiveModeValues": [] + } + ], + "unresolvedReferences": [], + "notes": [] + }, + "warnings": [], + "needsRedeployment": true, + "dryRun": false } } - } + ] }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "WorkflowEditOperation": { + "oneOf": [ + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "add", + "description": "Create a new block." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Block display name." + } + }, + "required": ["type", "name"], + "additionalProperties": { + "description": "One block-specific input or connection descriptor." + }, + "description": "Block type and name, plus any block-specific configuration. Beyond `type` and `name` the accepted keys are `inputs`, `connections`, `retry`, `triggerMode`, and `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle." + } }, - "example": { - "error": { - "code": "USAGE_LIMIT_EXCEEDED", - "message": "Usage limit exceeded. Please upgrade your plan to continue." + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "edit", + "description": "Change an existing block: its inputs, name, or connections." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One operation parameter; see the description for the accepted keys." + }, + "description": "Fields to change on the target block. Send only what changes. Accepted keys: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle. Re-sending `connections` replaces that block's outgoing edges, so use `removeEdges` — `[{ targetBlockId, sourceHandle? }]`, `sourceHandle` defaulting to `source` — to drop one edge without restating the rest." } - } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "delete", + "description": "Remove a block and every edge touching it." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + } + }, + "required": ["operation_type", "block_id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "insert_into_subflow", + "description": "Create a block inside a loop or parallel container." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "subflowId": { + "type": "string", + "minLength": 1, + "description": "Loop or parallel container to insert the block into." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Block display name." + } + }, + "required": ["subflowId", "type", "name"], + "additionalProperties": { + "description": "One block-specific input or connection descriptor." + }, + "description": "Container, block type and name, plus any block-specific configuration. Takes the same keys as an `add`: `inputs`, `connections`, `retry`, `triggerMode`, `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "extract_from_subflow", + "description": "Move a block out of its loop or parallel container." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "subflowId": { + "type": "string", + "minLength": 1, + "description": "Loop or parallel container the block moves into or out of." + } + }, + "required": ["subflowId"], + "additionalProperties": { + "description": "One block-specific input." + }, + "description": "Container identifier, plus any block-specific inputs." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false } - } + ], + "title": "Workflow edit operation", + "description": "One semantic edit against a workflow graph." }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "ApplyWorkflowOperationsRequest": { + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "maxItems": 200, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEditOperation" }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" + "description": "Edits to apply, in a single batch." + }, + "atomic": { + "default": false, + "description": "Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead.", + "type": "boolean" + }, + "layout": { + "default": "targeted", + "description": "Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied.", + "type": "string", + "enum": ["targeted", "none"] + }, + "setBlockEnabled": { + "description": "Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined.", + "maxItems": 200, + "type": "array", + "items": { + "type": "object", + "properties": { + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block should run." } - } + }, + "required": ["block_id", "enabled"], + "additionalProperties": false } } - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" + }, + "required": ["operations"], + "additionalProperties": false, + "title": "Apply workflow operations request", + "description": "A batch of semantic edits against a workflow graph.", + "examples": [ + { + "operations": [ + { + "operation_type": "add", + "block_id": "agent-1", + "params": { + "type": "agent", + "name": "Triage" + } } - } + ] } - } + ] }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Webhook path already in use" - } - } + "ApplyWorkflowVariablesResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose variables were updated." + }, + "variableCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Variables the workflow now holds." + }, + "changed": { + "type": "boolean", + "description": "Whether anything actually changed. A no-op batch answers `200` with `false`." } - } + }, + "required": ["id", "variableCount", "changed"], + "additionalProperties": false, + "title": "Apply workflow variables result", + "description": "Outcome of a workflow variable update." }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" + "ApplyWorkflowVariablesResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ApplyWorkflowVariablesResult" } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Run ID has already been used", - "details": { - "code": "RUN_ID_CONFLICT", - "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" - } - } + "required": ["data"], + "additionalProperties": false, + "title": "Apply workflow variables response", + "description": "Outcome of a workflow variable update.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "variableCount": 3, + "changed": true } } - } + ] }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "ApplyWorkflowVariablesRequest": { + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "add", + "description": "Create a variable with this name." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value, coerced to `type`." + } + }, + "required": ["operation", "name", "type", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "edit", + "description": "Replace the value, and optionally the type, of an existing variable." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name of the variable to update." + }, + "type": { + "description": "Replacement type; the stored type is kept when omitted.", + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"] + }, + "value": { + "description": "Replacement value, coerced to the effective type." + } + }, + "required": ["operation", "name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "delete", + "description": "Remove the variable with this name." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name of the variable to remove." + } + }, + "required": ["operation", "name"], + "additionalProperties": false + } + ], + "description": "One variable change." }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } - } + "description": "Variable changes to apply, in order." } - } + }, + "required": ["operations"], + "additionalProperties": false, + "title": "Apply workflow variables request", + "description": "Additions, edits, and deletions against a workflow’s variables." }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "LOCKED", - "message": "Workflow is locked" - } + "DuplicateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Duplicate workflow response", + "description": "The created copy.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage (copy)", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" } } - } + ] }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + "DuplicateWorkflowRequest": { + "type": "object", + "properties": { + "name": { + "description": "Name for the copy. Defaults to the source name, deduplicated within the folder.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "folderPath": { + "description": "Destination folder path. Defaults to the source workflow's folder.", + "$ref": "#/components/schemas/FolderPathInput" } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" - } - } - } - } - } + "additionalProperties": false, + "title": "Duplicate workflow request", + "description": "Optional name and destination folder for the copy." }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CLIENT_CLOSED_REQUEST", - "message": "Client cancelled request", - "details": { - "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" - } - } + "RestoreWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Restore workflow response", + "description": "The restored workflow.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" } } - } + ] }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "MoveWorkflowsResult": { + "type": "object", + "properties": { + "moved": { + "type": "array", + "items": { + "type": "string" }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } - } + "description": "Workflows that were relocated." + }, + "failed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflows that were not relocated — absent from the workspace, archived, or locked. Best-effort by design: the rest of the batch still moved." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical destination folder path.", + "maxLength": 4096 } - } + }, + "required": ["moved", "failed", "folderPath"], + "additionalProperties": false, + "title": "Move workflows result", + "description": "Which workflows moved and which did not." }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + "MoveWorkflowsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/MoveWorkflowsResult" } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } + "required": ["data"], + "additionalProperties": false, + "title": "Move workflows response", + "description": "Which workflows moved and which did not.", + "examples": [ + { + "data": { + "moved": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"], + "failed": [], + "folderPath": "/Operations" } } - } - } - }, - "schemas": { - "V2Error": { + ] + }, + "MoveWorkflowsRequest": { "type": "object", "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." - }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." - }, - "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." - } + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace holding every workflow in the batch." + }, + "workflowIds": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." + "description": "Workflows to move. Duplicates are collapsed." + }, + "folderPath": { + "description": "Destination folder path; `/` moves the workflows to the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" } }, - "required": ["error"], + "required": ["workspaceId", "workflowIds", "folderPath"], "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." - } - } - ] + "title": "Move workflows request", + "description": "Workflows to relocate and the folder to relocate them into." }, - "FolderPathInput": { - "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" + "WorkflowInputField": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Input field name." + }, + "type": { + "type": "string", + "description": "Input field type." + }, + "description": { + "description": "Optional input field description.", + "type": "string" + } + }, + "required": ["name", "type"], + "additionalProperties": false, + "title": "Workflow input field", + "description": "A deployed API trigger input exposed by a workflow." }, - "WorkflowListItem": { + "WorkflowDetail": { "type": "object", "properties": { "id": { @@ -2450,17 +6516,238 @@ { "type": "null" } - ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" - }, - "runCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Structured workflow variable value." + }, + "description": "Workflow-scoped variables keyed by variable identifier." + }, + "inputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowInputField" + }, + "description": "Input fields exposed by the workflow API trigger." + } + }, + "required": [ + "id", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt", + "variables", + "inputs" + ], + "additionalProperties": false, + "title": "Workflow detail", + "description": "Full workflow summary with variables and API-trigger input fields." + }, + "WorkflowDetailResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow detail response", + "description": "Detailed workflow metadata, variables, and trigger inputs.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z", + "variables": {}, + "inputs": [] + } + } + ] + }, + "UpdateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow response", + "description": "The updated workflow summary.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + } + ] + }, + "UpdateWorkflowRequest": { + "type": "object", + "properties": { + "name": { + "description": "Replacement workflow name.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Replacement workflow description; null clears it.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + }, + "folderPath": { + "description": "Destination folder path; `/` moves the workflow to the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "additionalProperties": false, + "title": "Update workflow request", + "description": "Fields to update on an existing workflow." + }, + "DeleteWorkflowResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the archived workflow." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the workflow is no longer live." + }, + "archived": { + "type": "boolean", + "const": true, + "description": "The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{workflowId}/restore` brings all of them back." + } + }, + "required": ["id", "deleted", "archived"], + "additionalProperties": false, + "title": "Delete workflow result", + "description": "Confirmation that a workflow was archived." + }, + "DeleteWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/DeleteWorkflowResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete workflow response", + "description": "Confirmation that the workflow was archived.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true, + "archived": true + } + } + ] + }, + "WorkflowVersion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique deployment-version identifier." + }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." + }, + "name": { + "description": "Optional deployment-version label.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - "lastRunAt": { + "description": { + "description": "Optional deployment-version release note.", "anyOf": [ { "type": "string" @@ -2468,45 +6755,53 @@ { "type": "null" } - ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" + ] + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is currently serving executions." }, "createdAt": { "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", + "description": "ISO 8601 timestamp when this version was created.", "format": "date-time" }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" + "deployedBy": { + "description": "Display name of the user who created the deployment, when available.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestOperationStatus": { + "description": "Latest lifecycle-operation status for this version.", + "anyOf": [ + { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"] + }, + { + "type": "null" + } + ] } }, - "required": [ - "id", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt" - ], + "required": ["id", "version", "isActive", "createdAt"], "additionalProperties": false, - "title": "Workflow summary", - "description": "Summary of a workflow and its deployment and run state." + "title": "Workflow version", + "description": "A saved deployment version of a workflow." }, - "WorkflowListResponse": { + "WorkflowVersionListResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowListItem" + "$ref": "#/components/schemas/WorkflowVersion" }, "description": "Items in the current page." }, @@ -2524,156 +6819,125 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Workflow list response", - "description": "A cursor-paginated page of workflow summaries.", + "title": "Workflow version list response", + "description": "A cursor-paginated page of deployment versions.", "examples": [ { "data": [ { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "id": "version_3", + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch.", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "deployedBy": "Jane Smith", + "latestOperationStatus": "active" } ], "nextCursor": null } ] }, - "CreateWorkflowResponse": { + "DeployedWorkflowState": { + "title": "Deployed workflow state", + "description": "Workflow graph snapshot pinned by a deployment version.", "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowListItem" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create workflow response", - "description": "The created workflow summary.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" - } - } - ] + "additionalProperties": true }, - "CreateWorkflowRequest": { + "WorkflowVersionDetail": { "type": "object", "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the workflow." + "description": "Unique deployment-version identifier." + }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." }, "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Workflow name." + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Version label, or null when unset." }, "description": { - "description": "Optional workflow description.", "anyOf": [ { - "type": "string", - "maxLength": 50000 + "type": "string" }, { "type": "null" } - ] + ], + "description": "Version release note, or null when unset." }, - "folderPath": { - "$ref": "#/components/schemas/FolderPathInput" + "isActive": { + "type": "boolean", + "description": "Whether this version is currently serving executions." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this version was created.", + "format": "date-time" + }, + "state": { + "description": "Deployed workflow graph snapshot pinned by this version, with credential-bearing values redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata.", + "$ref": "#/components/schemas/DeployedWorkflowState" } }, - "required": ["workspaceId", "name"], + "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], "additionalProperties": false, - "title": "Create workflow request", - "description": "Name, description, workspace, and optional folder for a new workflow." + "title": "Workflow version detail", + "description": "A deployment version together with the workflow state it pins." }, - "WorkflowInputField": { + "WorkflowVersionDetailResponse": { "type": "object", "properties": { - "name": { - "type": "string", - "description": "Input field name." - }, - "type": { - "type": "string", - "description": "Input field type." - }, - "description": { - "description": "Optional input field description.", - "type": "string" + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowVersionDetail" } }, - "required": ["name", "type"], + "required": ["data"], "additionalProperties": false, - "title": "Workflow input field", - "description": "A deployed API trigger input exposed by a workflow." + "title": "Workflow version detail response", + "description": "The deployment version and its pinned workflow graph.", + "examples": [ + { + "data": { + "id": "version_3", + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch.", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "state": { + "blocks": {}, + "edges": [] + } + } + } + ] }, - "WorkflowDetail": { + "WorkflowVersionMetadata": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - }, - "name": { - "type": "string", - "description": "Workflow name.", - "examples": ["Customer support triage"] - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow description, or null when none is set." - }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Operations"] - }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the workflow." - }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow has an active deployment." + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." }, - "deployedAt": { + "name": { "anyOf": [ { "type": "string" @@ -2682,16 +6946,9 @@ "type": "null" } ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" - }, - "runCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." + "description": "Version label, or null when unset." }, - "lastRunAt": { + "description": { "anyOf": [ { "type": "string" @@ -2700,129 +6957,47 @@ "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" - }, - "variables": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Structured workflow variable value." - }, - "description": "Workflow-scoped variables keyed by variable identifier." - }, - "inputs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowInputField" - }, - "description": "Input fields exposed by the workflow API trigger." - } - }, - "required": [ - "id", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt", - "variables", - "inputs" - ], - "additionalProperties": false, - "title": "Workflow detail", - "description": "Full workflow summary with variables and API-trigger input fields." - }, - "WorkflowDetailResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowDetail" + "description": "Version release note, or null when unset." } }, - "required": ["data"], + "required": ["version", "name", "description"], "additionalProperties": false, - "title": "Workflow detail response", - "description": "Detailed workflow metadata, variables, and trigger inputs.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z", - "variables": {}, - "inputs": [] - } - } - ] + "title": "Workflow version metadata", + "description": "Mutable label and release note of a deployment version." }, - "UpdateWorkflowResponse": { + "UpdateWorkflowVersionResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowListItem" + "$ref": "#/components/schemas/WorkflowVersionMetadata" } }, "required": ["data"], "additionalProperties": false, - "title": "Update workflow response", - "description": "The updated workflow summary.", + "title": "Update workflow version response", + "description": "The deployment version metadata after the update.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch." } } ] }, - "UpdateWorkflowRequest": { + "UpdateWorkflowVersionRequest": { "type": "object", "properties": { "name": { - "description": "Replacement workflow name.", + "description": "New label for the deployment version.", "type": "string", "minLength": 1, - "maxLength": 255 + "maxLength": 100 }, "description": { - "description": "Replacement workflow description; null clears it.", + "description": "New release note for the deployment version, or null to clear it.", "anyOf": [ { "type": "string", @@ -2832,70 +7007,85 @@ "type": "null" } ] - }, - "folderPath": { - "description": "Destination folder path; `/` moves the workflow to the workspace root.", - "$ref": "#/components/schemas/FolderPathInput" } }, "additionalProperties": false, - "title": "Update workflow request", - "description": "Fields to update on an existing workflow." + "title": "Update workflow version request", + "description": "Merge-patch body for the mutable metadata of a deployment version.", + "examples": [ + { + "name": "Escalation routing", + "description": "Adds the priority escalation branch." + } + ] }, - "DeleteWorkflowResult": { + "ActiveDeploymentSummary": { "type": "object", "properties": { - "id": { + "deploymentVersionId": { "type": "string", - "description": "Identifier of the deleted workflow." + "description": "Identifier of the active deployment version." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the workflow was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete workflow result", - "description": "Confirmation that a workflow was deleted." - }, - "DeleteWorkflowResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/DeleteWorkflowResult" + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Numeric active deployment version." + }, + "deployedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this version became active.", + "format": "date-time" } }, - "required": ["data"], + "required": ["deploymentVersionId", "version", "deployedAt"], "additionalProperties": false, - "title": "Delete workflow response", - "description": "Confirmation that the workflow was deleted.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "deleted": true - } - } - ] + "title": "Active deployment", + "description": "Summary of the workflow version currently serving API executions." }, - "WorkflowVersion": { + "DeploymentOperationSummary": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique deployment-version identifier." + "description": "Unique deployment operation identifier." + }, + "deploymentVersionId": { + "type": "string", + "description": "Deployment version targeted by this operation." }, "version": { "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991, - "description": "Monotonically increasing deployment version number." + "description": "Numeric deployment version." }, - "name": { - "description": "Optional deployment-version label.", + "action": { + "type": "string", + "enum": ["deploy", "activate"], + "description": "Operation being performed on the deployment version." + }, + "status": { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Current deployment lifecycle status." + }, + "isCurrent": { + "default": true, + "description": "Whether this operation still describes the current deployment attempt.", + "type": "boolean" + }, + "readiness": { + "$ref": "#/components/schemas/DeploymentReadiness" + }, + "requestedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment operation was requested.", + "format": "date-time" + }, + "activatedAt": { + "description": "ISO 8601 activation timestamp, or null before activation completes.", + "format": "date-time", "anyOf": [ { "type": "string" @@ -2905,119 +7095,95 @@ } ] }, - "description": { - "description": "Optional deployment-version release note.", + "error": { + "description": "Deployment failure details, or null when no failure occurred.", "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/DeploymentOperationError" }, { "type": "null" } ] - }, - "isActive": { - "type": "boolean", - "description": "Whether this version is currently serving executions." - }, - "createdAt": { + } + }, + "required": [ + "id", + "deploymentVersionId", + "version", + "action", + "status", + "isCurrent", + "readiness", + "requestedAt" + ], + "additionalProperties": false, + "title": "Deployment operation", + "description": "Lifecycle state of a deployment or version-activation attempt." + }, + "DeploymentReadiness": { + "type": "object", + "properties": { + "webhooks": { "type": "string", - "description": "ISO 8601 timestamp when this version was created.", - "format": "date-time" + "enum": ["pending", "ready", "not_applicable"], + "description": "Webhook synchronization readiness." }, - "deployedBy": { - "description": "Display name of the user who created the deployment, when available.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "schedules": { + "type": "string", + "enum": ["pending", "ready", "not_applicable"], + "description": "Schedule synchronization readiness." }, - "latestOperationStatus": { - "description": "Latest lifecycle-operation status for this version.", - "anyOf": [ - { - "type": "string", - "enum": ["preparing", "activating", "active", "failed", "superseded"] - }, - { - "type": "null" - } - ] + "mcp": { + "type": "string", + "enum": ["pending", "ready", "not_applicable"], + "description": "MCP synchronization readiness." } }, - "required": ["id", "version", "isActive", "createdAt"], + "required": ["webhooks", "schedules", "mcp"], "additionalProperties": false, - "title": "Workflow version", - "description": "A saved deployment version of a workflow." + "title": "Deployment readiness", + "description": "Readiness of the side effects required to activate a deployment." }, - "WorkflowVersionListResponse": { + "DeploymentOperationError": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowVersion" - }, - "description": "Items in the current page." + "code": { + "type": "string", + "description": "Stable deployment failure code." }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "message": { + "type": "string", + "description": "Human-readable deployment failure message." + }, + "retryable": { + "type": "boolean", + "description": "Whether retrying the deployment may succeed." } }, - "required": ["data", "nextCursor"], + "required": ["code", "message", "retryable"], "additionalProperties": false, - "title": "Workflow version list response", - "description": "A cursor-paginated page of deployment versions.", - "examples": [ - { - "data": [ - { - "id": "version_3", - "version": 3, - "name": "Escalation routing", - "description": "Adds the priority escalation branch.", - "isActive": true, - "createdAt": "2026-06-12T10:30:00.000Z", - "deployedBy": "Jane Smith", - "latestOperationStatus": "active" - } - ], - "nextCursor": null - } - ] + "title": "Deployment operation error", + "description": "Failure details for a deployment lifecycle operation." }, - "DeployedWorkflowState": { - "title": "Deployed workflow state", - "description": "Workflow graph snapshot pinned by a deployment version.", - "type": "object", - "additionalProperties": true + "VersionActivationResult": { + "title": "Version activation result", + "description": "Activation attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state.", + "$ref": "#/components/schemas/RollbackResult" }, - "WorkflowVersionDetail": { + "RollbackResult": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique deployment-version identifier." + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Monotonically increasing deployment version number." + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." }, - "name": { + "deployedAt": { "anyOf": [ { "type": "string" @@ -3026,213 +7192,242 @@ "type": "null" } ], - "description": "Version label, or null when unset." + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] }, - "description": { + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + }, + "activeDeployment": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/ActiveDeploymentSummary" }, { "type": "null" } ], - "description": "Version release note, or null when unset." - }, - "isActive": { - "type": "boolean", - "description": "Whether this version is currently serving executions." + "description": "Currently live deployment version, or null while no version is active." }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this version was created.", - "format": "date-time" + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." }, - "state": { - "description": "Deployed workflow graph snapshot pinned by this version, with credential-bearing values redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata.", - "$ref": "#/components/schemas/DeployedWorkflowState" + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Deployment version selected for re-activation." } }, - "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "version" + ], "additionalProperties": false, - "title": "Workflow version detail", - "description": "A deployment version together with the workflow state it pins." + "title": "Rollback result", + "description": "Rollback attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state." }, - "WorkflowVersionDetailResponse": { + "ActivateWorkflowVersionResponse": { "type": "object", "properties": { "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + }, + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" + }, + { + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." + }, + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." + }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Deployment version selected for re-activation." + } + }, + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "version" + ], + "additionalProperties": false, "description": "Response data.", - "$ref": "#/components/schemas/WorkflowVersionDetail" + "$ref": "#/components/schemas/VersionActivationResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Workflow version detail response", - "description": "The deployment version and its pinned workflow graph.", + "title": "Activate workflow version response", + "description": "Current deployment state after accepting the activation attempt.", "examples": [ { "data": { - "id": "version_3", - "version": 3, - "name": "Escalation routing", - "description": "Adds the priority escalation branch.", - "isActive": true, - "createdAt": "2026-06-12T10:30:00.000Z", - "state": { - "blocks": {}, - "edges": [] - } - } - } - ] - }, - "ActiveDeploymentSummary": { - "type": "object", - "properties": { - "deploymentVersionId": { - "type": "string", - "description": "Identifier of the active deployment version." - }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Numeric active deployment version." - }, - "deployedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this version became active.", - "format": "date-time" - } - }, - "required": ["deploymentVersionId", "version", "deployedAt"], - "additionalProperties": false, - "title": "Active deployment", - "description": "Summary of the workflow version currently serving API executions." + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": { + "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", + "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", + "version": 3, + "action": "activate", + "status": "activating", + "isCurrent": true, + "readiness": { + "webhooks": "ready", + "schedules": "ready", + "mcp": "not_applicable" + }, + "requestedAt": "2026-06-12T10:30:00.000Z", + "activatedAt": null, + "error": null + }, + "version": 3 + } + } + ] }, - "DeploymentOperationSummary": { + "ActivateWorkflowVersionRequest": { + "default": {}, + "title": "Activate workflow version request", + "description": "No body. The version to promote is named by the request path.", + "examples": [{}], + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "RevertWorkflowVersionResult": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique deployment operation identifier." - }, - "deploymentVersionId": { - "type": "string", - "description": "Deployment version targeted by this operation." + "description": "Unique workflow identifier." }, "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Numeric deployment version." - }, - "action": { - "type": "string", - "enum": ["deploy", "activate"], - "description": "Operation being performed on the deployment version." - }, - "status": { - "type": "string", - "enum": ["preparing", "activating", "active", "failed", "superseded"], - "description": "Current deployment lifecycle status." - }, - "isCurrent": { - "default": true, - "description": "Whether this operation still describes the current deployment attempt.", - "type": "boolean" - }, - "readiness": { - "$ref": "#/components/schemas/DeploymentReadiness" - }, - "requestedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment operation was requested.", - "format": "date-time" - }, - "activatedAt": { - "description": "ISO 8601 activation timestamp, or null before activation completes.", - "format": "date-time", "anyOf": [ { - "type": "string" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 }, { - "type": "null" + "type": "string", + "const": "active" } - ] + ], + "description": "Deployment version loaded into the draft, or `active` for the live version." }, - "error": { - "description": "Deployment failure details, or null when no failure occurred.", - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationError" - }, - { - "type": "null" - } - ] + "lastSaved": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Epoch milliseconds at which the overwritten draft was saved." } }, - "required": [ - "id", - "deploymentVersionId", - "version", - "action", - "status", - "isCurrent", - "readiness", - "requestedAt" - ], + "required": ["id", "version", "lastSaved"], "additionalProperties": false, - "title": "Deployment operation", - "description": "Lifecycle state of a deployment or version-activation attempt." + "title": "Revert workflow version result", + "description": "The draft after it was overwritten by a deployment version." }, - "DeploymentReadiness": { + "RevertWorkflowVersionResponse": { "type": "object", "properties": { - "webhooks": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "Webhook synchronization readiness." - }, - "schedules": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "Schedule synchronization readiness." - }, - "mcp": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "MCP synchronization readiness." + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/RevertWorkflowVersionResult" } }, - "required": ["webhooks", "schedules", "mcp"], + "required": ["data"], "additionalProperties": false, - "title": "Deployment readiness", - "description": "Readiness of the side effects required to activate a deployment." + "title": "Revert workflow version response", + "description": "The draft after it was overwritten by the deployment version.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "version": 3, + "lastSaved": 1765535400000 + } + } + ] }, - "DeploymentOperationError": { + "RevertWorkflowVersionRequest": { + "default": {}, + "title": "Revert workflow version request", + "description": "No body. The version to load into the draft is named by the request path.", + "examples": [{}], "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable deployment failure code." - }, - "message": { - "type": "string", - "description": "Human-readable deployment failure message." - }, - "retryable": { - "type": "boolean", - "description": "Whether retrying the deployment may succeed." - } - }, - "required": ["code", "message", "retryable"], - "additionalProperties": false, - "title": "Deployment operation error", - "description": "Failure details for a deployment lifecycle operation." + "properties": {}, + "additionalProperties": false }, "WorkflowDeployment": { "type": "object", @@ -3291,6 +7486,10 @@ "needsRedeployment": { "type": "boolean", "description": "Whether the editable draft has diverged from the live deployment version. False while a deployment attempt is still preparing or activating, and false when nothing is deployed." + }, + "isPublicApi": { + "type": "boolean", + "description": "Whether the deployed workflow accepts unauthenticated public API execution. While true, anyone holding the execution URL can run the workflow — and be billed for it — without an API key, so this is the field an audit of what a deployment exposes reads. Changed with `PATCH /workflows/{workflowId}/deployment`." } }, "required": [ @@ -3300,7 +7499,8 @@ "warnings", "activeDeployment", "latestDeploymentAttempt", - "needsRedeployment" + "needsRedeployment", + "isPublicApi" ], "additionalProperties": false, "title": "Workflow deployment", @@ -3317,13 +7517,14 @@ "required": ["data"], "additionalProperties": false, "title": "Workflow deployment response", - "description": "Current deployment state, including draft-versus-live drift.", + "description": "Current deployment state, including draft-versus-live drift and whether the deployment is publicly executable.", "examples": [ { "data": { "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "isDeployed": true, "needsRedeployment": true, + "isPublicApi": false, "deployedAt": "2026-06-12T10:30:00.000Z", "warnings": [], "activeDeployment": { @@ -3351,6 +7552,62 @@ } ] }, + "WorkflowPublicApiSettings": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier." + }, + "isPublicApi": { + "type": "boolean", + "description": "Whether the deployed workflow accepts unauthenticated public API execution." + } + }, + "required": ["id", "isPublicApi"], + "additionalProperties": false, + "title": "Workflow public API settings", + "description": "Whether a deployed workflow is executable without an API key." + }, + "UpdateWorkflowPublicApiResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowPublicApiSettings" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow public API response", + "description": "Public API access after the update.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isPublicApi": true + } + } + ] + }, + "UpdateWorkflowPublicApiRequest": { + "type": "object", + "properties": { + "isPublicApi": { + "type": "boolean", + "description": "Whether the deployed workflow should accept unauthenticated public API execution." + } + }, + "required": ["isPublicApi"], + "additionalProperties": false, + "title": "Update workflow public API request", + "description": "Enable or disable unauthenticated public execution of the deployed workflow.", + "examples": [ + { + "isPublicApi": true + } + ] + }, "DeployResult": { "type": "object", "properties": { @@ -3422,7 +7679,7 @@ ], "additionalProperties": false, "title": "Deploy result", - "description": "Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned only here. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{id}/versions`." + "description": "Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned by every deployment mutation as well as this read. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{workflowId}/versions`." }, "DeployWorkflowResponse": { "type": "object", @@ -3569,100 +7826,26 @@ "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/UndeployResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Undeploy workflow response", - "description": "Deployment state after deactivating the active version.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": null - } - } - ] - }, - "RollbackResult": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." - }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." - }, - "activeDeployment": { - "anyOf": [ - { - "$ref": "#/components/schemas/ActiveDeploymentSummary" - }, - { - "type": "null" - } - ], - "description": "Currently live deployment version, or null while no version is active." - }, - "latestDeploymentAttempt": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationSummary" - }, - { - "type": "null" - } - ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." - }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Deployment version selected for re-activation." - } - }, - "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt", - "version" - ], + "description": "Response data.", + "$ref": "#/components/schemas/UndeployResult" + } + }, + "required": ["data"], "additionalProperties": false, - "title": "Rollback result", - "description": "Rollback attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state." + "title": "Undeploy workflow response", + "description": "Deployment state after deactivating the active version.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } + } + ] }, "RollbackWorkflowResponse": { "type": "object", @@ -3823,134 +8006,664 @@ } } } - ] + ] + }, + "ImportedWorkflow": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the imported workflow." + }, + "name": { + "type": "string", + "description": "Imported workflow name." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Imported workflow description." + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the imported workflow." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path.", + "maxLength": 4096 + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was imported.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "workspaceId", + "folderPath", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Imported workflow", + "description": "Workflow created by an import operation." + }, + "ImportWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ImportedWorkflow" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Import workflow response", + "description": "The workflow created by the import.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderPath": "/Operations", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + } + ] + }, + "ImportWorkflowRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to import the workflow." + }, + "workflow": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "description": "JSON string containing a workflow export object or bare workflow state." + }, + { + "type": "object", + "additionalProperties": true, + "description": "Workflow export object or bare workflow state." + } + ], + "description": "Workflow export object, bare workflow state, or JSON string containing either form." + }, + "folderPath": { + "description": "Destination folder path; omit for the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + }, + "name": { + "description": "Override for the imported workflow name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "description": "Override for the imported workflow description.", + "type": "string", + "maxLength": 2000 + } + }, + "required": ["workspaceId", "workflow"], + "additionalProperties": false, + "title": "Import workflow request", + "description": "Portable workflow data and destination metadata for an import." + }, + "ChatDeploymentListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique chat deployment identifier." + }, + "workflowId": { + "type": "string", + "description": "Workflow this deployment publishes." + }, + "workspaceId": { + "type": "string", + "description": "Workspace the deployment belongs to, derived from its workflow." + }, + "identifier": { + "type": "string", + "description": "URL slug the deployed chat answers on. Unique across live deployments." + }, + "url": { + "type": "string", + "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", + "examples": ["https://sim.ai/chat/support"] + }, + "title": { + "type": "string", + "description": "Title shown to visitors." + }, + "description": { + "type": "string", + "description": "Description shown to visitors. Empty when unset." + }, + "isActive": { + "type": "boolean", + "description": "Whether the deployment answers requests." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." + }, + "outputConfigs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" + }, + "description": "Block outputs surfaced to visitors." + }, + "includeThinking": { + "type": "boolean", + "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." + }, + "includeToolCalls": { + "type": "boolean", + "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "workflowId", + "workspaceId", + "identifier", + "url", + "title", + "description", + "isActive", + "authType", + "outputConfigs", + "includeThinking", + "includeToolCalls", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Chat deployment list entry", + "description": "A workflow published as a hosted chat, without the fields the detail read gates." + }, + "StoredChatDeploymentOutputConfig": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block whose output the chat streams." + }, + "path": { + "type": "string", + "description": "Path within that block output. Empty means the whole output." + } + }, + "required": ["blockId", "path"], + "additionalProperties": false, + "title": "Stored chat deployment output config", + "description": "One block output currently surfaced to chat visitors." + }, + "ChatDeploymentListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatDeploymentListItem" + }, + "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 is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Chat deployment list response", + "description": "A cursor-paginated page of chat deployments.", + "examples": [ + { + "data": [ + { + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" + } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "StoredChatDeploymentCustomizations": { + "type": "object", + "properties": { + "primaryColor": { + "description": "CSS color used for the chat accent.", + "type": "string" + }, + "welcomeMessage": { + "description": "First message shown to a visitor.", + "type": "string" + }, + "imageUrl": { + "description": "Avatar image shown beside assistant messages.", + "type": "string" + } + }, + "additionalProperties": false, + "title": "Stored chat deployment customizations", + "description": "Presentation overrides currently stored on the deployed chat." }, - "ImportedWorkflow": { + "ChatDeployment": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the imported workflow." + "description": "Unique chat deployment identifier." }, - "name": { + "workflowId": { "type": "string", - "description": "Imported workflow name." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Imported workflow description." + "description": "Workflow this deployment publishes." }, "workspaceId": { "type": "string", - "description": "Workspace that owns the imported workflow." + "description": "Workspace the deployment belongs to, derived from its workflow." }, - "folderPath": { + "identifier": { "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path.", - "maxLength": 4096 + "description": "URL slug the deployed chat answers on. Unique across live deployments." + }, + "url": { + "type": "string", + "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", + "examples": ["https://sim.ai/chat/support"] + }, + "title": { + "type": "string", + "description": "Title shown to visitors." + }, + "description": { + "type": "string", + "description": "Description shown to visitors. Empty when unset." + }, + "isActive": { + "type": "boolean", + "description": "Whether the deployment answers requests." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." + }, + "hasPassword": { + "type": "boolean", + "description": "Whether a password is stored. The password itself is never readable." + }, + "allowedEmails": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Email addresses or domains admitted under `email` and `sso` gating. Empty otherwise." + }, + "customizations": { + "description": "Presentation overrides. Unset fields fall back to platform defaults.", + "$ref": "#/components/schemas/StoredChatDeploymentCustomizations" + }, + "outputConfigs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" + }, + "description": "Block outputs surfaced to visitors." + }, + "includeThinking": { + "type": "boolean", + "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." + }, + "includeToolCalls": { + "type": "boolean", + "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." }, "createdAt": { "type": "string", - "description": "ISO 8601 timestamp when the workflow was imported.", + "description": "ISO 8601 timestamp when the deployment was created.", "format": "date-time" }, "updatedAt": { "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", + "description": "ISO 8601 timestamp when the deployment was last modified.", "format": "date-time" } }, "required": [ "id", - "name", - "description", + "workflowId", "workspaceId", - "folderPath", + "identifier", + "url", + "title", + "description", + "isActive", + "authType", + "hasPassword", + "allowedEmails", + "customizations", + "outputConfigs", + "includeThinking", + "includeToolCalls", "createdAt", "updatedAt" ], "additionalProperties": false, - "title": "Imported workflow", - "description": "Workflow created by an import operation." + "title": "Chat deployment", + "description": "A workflow published as a hosted chat." }, - "ImportWorkflowResponse": { + "GetWorkflowChatDeploymentResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/ImportedWorkflow" + "$ref": "#/components/schemas/ChatDeployment" } }, "required": ["data"], "additionalProperties": false, - "title": "Import workflow response", - "description": "The workflow created by the import.", + "title": "Get workflow chat deployment response", + "description": "The workflow's chat deployment.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "folderPath": "/Operations", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "hasPassword": false, + "allowedEmails": [], + "customizations": { + "primaryColor": "#6F3DFA", + "welcomeMessage": "Hi there! How can I help?" + }, + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" + } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" } } ] }, - "ImportWorkflowRequest": { + "ReplaceWorkflowChatDeploymentResponse": { "type": "object", "properties": { - "workspaceId": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ChatDeployment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Replace workflow chat deployment response", + "description": "The chat deployment as stored after the replace.", + "examples": [ + { + "data": { + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "hasPassword": false, + "allowedEmails": [], + "customizations": { + "primaryColor": "#6F3DFA", + "welcomeMessage": "Hi there! How can I help?" + }, + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" + } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "ChatDeploymentCustomizations": { + "type": "object", + "properties": { + "primaryColor": { + "description": "CSS color used for the chat accent.", "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to import the workflow." + "maxLength": 64 }, - "workflow": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "description": "JSON string containing a workflow export object or bare workflow state." - }, - { - "type": "object", - "additionalProperties": true, - "description": "Workflow export object or bare workflow state." - } - ], - "description": "Workflow export object, bare workflow state, or JSON string containing either form." + "welcomeMessage": { + "description": "First message shown to a visitor.", + "type": "string", + "maxLength": 2000 }, - "folderPath": { - "description": "Destination folder path; omit for the workspace root.", - "$ref": "#/components/schemas/FolderPathInput" + "imageUrl": { + "description": "Avatar image shown beside assistant messages.", + "type": "string", + "maxLength": 2048 + } + }, + "additionalProperties": false, + "title": "Chat deployment customizations", + "description": "Presentation overrides for the deployed chat." + }, + "ChatDeploymentOutputConfig": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "description": "Block whose output the chat streams." }, - "name": { - "description": "Override for the imported workflow name.", + "path": { "type": "string", "minLength": 1, - "maxLength": 200 + "description": "Path within that block output." + } + }, + "required": ["blockId", "path"], + "additionalProperties": false, + "title": "Chat deployment output config", + "description": "One block output surfaced to chat visitors." + }, + "ReplaceChatDeploymentRequest": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9-]+$", + "description": "URL slug the deployed chat answers on. Must be free across live deployments." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Title shown to visitors." }, "description": { - "description": "Override for the imported workflow description.", + "description": "Description shown to visitors. Omitted clears it.", "type": "string", "maxLength": 2000 + }, + "customizations": { + "description": "Presentation overrides. Omitted fields take platform defaults.", + "$ref": "#/components/schemas/ChatDeploymentCustomizations" + }, + "authType": { + "description": "How visitors are gated. `public` leaves the chat open to anyone holding the URL.", + "default": "public", + "type": "string", + "enum": ["public", "password", "email", "sso"] + }, + "password": { + "description": "Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "allowedEmails": { + "description": "Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "outputConfigs": { + "description": "Block outputs to surface to visitors. Omitted surfaces none.", + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatDeploymentOutputConfig" + } + }, + "includeThinking": { + "description": "Allow visitors to receive provider thinking events.", + "default": false, + "type": "boolean" + }, + "includeToolCalls": { + "description": "Allow visitors to receive tool lifecycle events.", + "default": false, + "type": "boolean" } }, - "required": ["workspaceId", "workflow"], + "required": ["identifier", "title"], "additionalProperties": false, - "title": "Import workflow request", - "description": "Portable workflow data and destination metadata for an import." + "title": "Replace chat deployment request", + "description": "The complete desired state of a workflow's chat.", + "examples": [ + { + "identifier": "support", + "title": "Support chat" + } + ] + }, + "DeleteChatDeploymentResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the removed chat deployment." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the deployment was removed." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete chat deployment result", + "description": "Chat deployment removal acknowledgement." + }, + "DeleteWorkflowChatDeploymentResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/DeleteChatDeploymentResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete workflow chat deployment response", + "description": "Acknowledgement that the chat deployment was removed.", + "examples": [ + { + "data": { + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "deleted": true + } + } + ] }, "ExecutionError": { "type": "object", @@ -4170,10 +8883,10 @@ "type": "boolean" }, "base64MaxBytes": { - "description": "Maximum total bytes of file content to inline as base64. Rejected when `async` is true.", + "description": "Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true.", "type": "integer", "exclusiveMinimum": 0, - "maximum": 10485760 + "maximum": 16777216 } }, "additionalProperties": false, @@ -4339,6 +9052,48 @@ } ] }, + "V2RunFile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier to address this file by on the download endpoint." + }, + "name": { + "type": "string", + "description": "File name, including its extension." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "File size in bytes." + }, + "type": { + "type": "string", + "description": "MIME type recorded for the file." + }, + "downloadPath": { + "type": "string", + "description": "Path to fetch this file's bytes from, relative to the API host." + }, + "base64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Base64-encoded contents when `includeFileBase64` was requested and the file fits the inline ceiling, otherwise null." + } + }, + "required": ["id", "name", "size", "type", "downloadPath", "base64"], + "additionalProperties": false, + "title": "Workflow run file", + "description": "A file produced by a workflow run." + }, "WorkflowRunStatus": { "type": "object", "properties": { @@ -4567,6 +9322,20 @@ } ], "description": "Outputs of the blocks named by `selectedOutputs`, or null when none were requested. Gated by `selectedOutputs` alone — `includeOutput` governs `output` only." + }, + "files": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2RunFile" + } + }, + { + "type": "null" + } + ], + "description": "Files this run produced, or null when `includeOutput` is false. Matches the nullability of `output`." } }, "required": [ @@ -4581,7 +9350,8 @@ "cost", "error", "output", - "blockOutputs" + "blockOutputs", + "files" ], "additionalProperties": false, "title": "Workflow run status", @@ -4617,7 +9387,17 @@ "output": { "result": "Ticket routed to Support" }, - "blockOutputs": null + "blockOutputs": null, + "files": [ + { + "id": "file_1a2b3c", + "name": "summary.pdf", + "size": 20480, + "type": "application/pdf", + "downloadPath": "/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a/files/file_1a2b3c", + "base64": null + } + ] } } ] diff --git a/apps/sim/app/api/chat/error-policy.ts b/apps/sim/app/api/chat/error-policy.ts new file mode 100644 index 00000000000..c1ae0655ee3 --- /dev/null +++ b/apps/sim/app/api/chat/error-policy.ts @@ -0,0 +1,54 @@ +import { + createInternalResourceConcealmentPolicy, + type InternalErrorPolicy, + internalErrorResponse, +} from '@/lib/api/server/routes' +import { ChatIdentifierInUseError } from '@/lib/chat-deployments/application' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' + +/** + * Message the editor has always received for both a missing deployment and one + * in a workspace the caller cannot reach — the two must stay indistinguishable. + */ +const CHAT_NOT_FOUND_MESSAGE = 'Chat not found or access denied' + +/** + * The internal chat surface's error envelope. + * + * Mirrors the `{ error, code }` body the hand-written routes emitted, including + * the `code` derived from the message, so the editor's client sees no change. + * + * The one deliberate special case is {@link ChatIdentifierInUseError}. It is a + * conflict, and the public API reports it as `409`; the editor has always + * received `400` for it and its client recognises that pairing, so the status + * is pinned here rather than by weakening the domain error. + */ +export function createInternalChatDeploymentErrorPolicy(fallback: string): InternalErrorPolicy { + if (!fallback.trim()) throw new Error('Internal chat deployment error fallback is required') + return createInternalResourceConcealmentPolicy({ + notFoundMessage: CHAT_NOT_FOUND_MESSAGE, + base: { + project(error) { + if (error instanceof ChatIdentifierInUseError) { + return internalErrorResponse(400, { + error: error.message, + code: legacyCode(error.message), + }) + } + const classified = asOrchestrationError(error) + if (!classified) return null + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + code: legacyCode(classified.message), + }) + }, + unhandled() { + return internalErrorResponse(500, { error: fallback, code: legacyCode(fallback) }) + }, + }, + }) +} + +function legacyCode(message: string): string { + return message.toUpperCase().replace(/\s+/g, '_') +} diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index f0ec9e9d695..5274eb6e58b 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -1,12 +1,16 @@ /** - * Tests for chat edit API route + * Tests for the internal chat-deployment management routes. + * + * These are adapters over `lib/chat-deployments/application`, so the seams + * mocked here are the canonical reads, the workspace permission resolver, and + * the deployment orchestration — not a route-local access helper. * * @vitest-environment node */ import { auditMock, + auditMockFns, authMockFns, - dbChainMockFns, encryptionMock, encryptionMockFns, resetDbChainMock, @@ -14,38 +18,51 @@ import { resetEnvMock, setEnv, setEnvFlags, - workflowsApiUtilsMock, - workflowsApiUtilsMockFns, - workflowsOrchestrationMock, - workflowsOrchestrationMockFns, - workflowsPersistenceUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckChatAccess, mockCheckNeedsRedeployment, mockValidateChatDeployAuth } = vi.hoisted( - () => ({ - mockCheckChatAccess: vi.fn(), - mockCheckNeedsRedeployment: vi.fn(), - mockValidateChatDeployAuth: vi.fn(), - }) -) - -const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse -const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse -const mockEncryptSecret = encryptionMockFns.mockEncryptSecret -const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy -const mockPerformChatUndeploy = workflowsOrchestrationMockFns.mockPerformChatUndeploy -const mockGetWorkflowDeploymentSummary = - workflowsOrchestrationMockFns.mockGetWorkflowDeploymentSummary -const mockNotifySocketDeploymentChanged = - workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + loadWorkspaceContext: vi.fn(), + getChatDeploymentWithWorkspace: vi.fn(), + getIdentifierOwner: vi.fn(), + updateChatDeploymentRow: vi.fn(), + getWorkflowDeploymentSummary: vi.fn(), + performFullDeploy: vi.fn(), + performChatUndeploy: vi.fn(), + checkNeedsRedeployment: vi.fn(), + validateChatDeployAuth: vi.fn(), +})) vi.mock('@sim/audit', () => auditMock) -vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspaceContext, +})) +vi.mock('@/lib/chat-deployments/queries', () => ({ + getChatDeploymentWithWorkspace: mocks.getChatDeploymentWithWorkspace, + getChatDeploymentIdOwningIdentifier: mocks.getIdentifierOwner, + updateChatDeploymentRow: mocks.updateChatDeploymentRow, + listWorkspaceChatDeployments: vi.fn(), +})) vi.mock('@/lib/core/security/encryption', () => encryptionMock) -vi.mock('@/app/api/chat/utils', () => ({ - checkChatAccess: mockCheckChatAccess, +vi.mock('@/lib/workflows/orchestration', () => ({ + getWorkflowDeploymentSummary: mocks.getWorkflowDeploymentSummary, + performFullDeploy: mocks.performFullDeploy, + performChatUndeploy: mocks.performChatUndeploy, + performChatDeploy: vi.fn(), +})) +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.checkNeedsRedeployment, })) vi.mock('@/ee/access-control/utils/permission-check', () => { class ChatDeployAuthNotAllowedError extends Error { @@ -54,17 +71,59 @@ vi.mock('@/ee/access-control/utils/permission-check', () => { this.name = 'ChatDeployAuthNotAllowedError' } } - return { validateChatDeployAuth: mockValidateChatDeployAuth, ChatDeployAuthNotAllowedError } + return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } }) -vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) -vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) -vi.mock('@/lib/workflows/deployment-status', () => ({ - checkNeedsRedeployment: mockCheckNeedsRedeployment, -})) +import { chatDeploymentOperations } from '@/lib/chat-deployments/application' import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' +const CHAT_ID = 'chat-123' +const WORKFLOW_ID = 'workflow-1' +const WORKSPACE_ID = 'workspace-1' + +function chatRow(overrides: Record = {}) { + return { + id: CHAT_ID, + workflowId: WORKFLOW_ID, + userId: 'owner-1', + identifier: 'support', + title: 'Support chat', + description: 'Ask us anything', + isActive: true, + customizations: { primaryColor: '#000', welcomeMessage: 'Hi' }, + authType: 'public', + password: null, + allowedEmails: [], + outputConfigs: [{ blockId: 'block-1', path: 'output' }], + includeThinking: false, + includeToolCalls: false, + archivedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:30:00.000Z'), + ...overrides, + } +} + +function patchRequest(body: unknown) { + return new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const params = { params: Promise.resolve({ id: CHAT_ID }) } + +async function patch(body: unknown) { + return PATCH(patchRequest(body), { params: Promise.resolve({ id: CHAT_ID }) }) +} + +/** The column values the update use case settled on, as written to the row. */ +function writtenValues(): Record { + return mocks.updateChatDeploymentRow.mock.calls[0][1] +} + beforeAll(() => { setEnvFlags({ isDev: true }) setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) @@ -75,534 +134,511 @@ afterAll(() => { resetEnvMock() }) -describe('Chat Edit API Route', () => { +describe('internal chat deployment routes', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockPerformChatUndeploy.mockResolvedValue({ success: true }) - - mockCreateSuccessResponse.mockImplementation((data) => { - return new Response(JSON.stringify(data), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1', name: 'Admin', email: 'admin@example.com' }, + session: { id: 'session-1' }, }) - mockCreateErrorResponse.mockImplementation((message, status = 500) => { - return new Response(JSON.stringify({ error: message }), { - status, - headers: { 'Content-Type': 'application/json' }, - }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadWorkspaceContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', }) - - mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) - mockGetWorkflowDeploymentSummary.mockResolvedValue({ - activeDeployment: null, + mocks.getChatDeploymentWithWorkspace.mockResolvedValue({ + chat: chatRow(), + workspaceId: WORKSPACE_ID, + }) + mocks.getIdentifierOwner.mockResolvedValue(null) + mocks.updateChatDeploymentRow.mockImplementation(async (_id, values) => chatRow({ ...values })) + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: { deploymentVersionId: 'dv-1', version: 1, deployedAt: null }, latestDeploymentAttempt: null, warnings: [], }) - mockCheckNeedsRedeployment.mockResolvedValue(false) - mockPerformFullDeploy.mockResolvedValue({ + mocks.checkNeedsRedeployment.mockResolvedValue(false) + mocks.performFullDeploy.mockResolvedValue({ success: true, - version: 1, + version: 2, latestDeploymentAttempt: { status: 'active' }, }) - mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) + mocks.performChatUndeploy.mockResolvedValue({ success: true }) + mocks.validateChatDeployAuth.mockResolvedValue(undefined) + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) + }) + + /** + * The read serves the visitor gate — `allowedEmails`, `authType`, + * `hasPassword`, and the customization blob — which this surface has always + * required workspace admin for. Its siblings pin their role; this one did not, + * which is why a demotion to `read` went unnoticed. + */ + it('keeps every chat-deployment operation an admin operation', () => { + expect(chatDeploymentOperations.read.minimumRole).toBe('admin') + expect(chatDeploymentOperations.update.minimumRole).toBe('admin') + expect(chatDeploymentOperations.delete.minimumRole).toBe('admin') }) describe('GET', () => { - it('should return 401 when user is not authenticated', async () => { + it('returns 401 when there is no session', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123') - const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) + const response = await GET( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`), + params + ) expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') + expect(mocks.getChatDeploymentWithWorkspace).not.toHaveBeenCalled() }) - it('should return 404 when chat not found or access denied', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, + it('serves the deployment without its password and with the public URL', async () => { + mocks.getChatDeploymentWithWorkspace.mockResolvedValue({ + chat: chatRow({ password: 'encrypted', authType: 'password' }), + workspaceId: WORKSPACE_ID, }) - mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) + const response = await GET( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`), + params + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toMatchObject({ + id: CHAT_ID, + identifier: 'support', + title: 'Support chat', + hasPassword: true, + chatUrl: 'http://localhost:3000/chat/support', + isActive: true, + }) + expect(body).not.toHaveProperty('password') + }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123') - const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) + it('answers 404 for a deployment the caller cannot reach', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`), + params + ) expect(response.status).toBe(404) - const data = await response.json() - expect(data.error).toBe('Chat not found or access denied') - expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id') }) - it('should return chat details when user has access', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + it('answers 404 for a deployment that does not exist', async () => { + mocks.getChatDeploymentWithWorkspace.mockResolvedValue(null) - const mockChat = { - id: 'chat-123', - identifier: 'test-chat', - title: 'Test Chat', - description: 'A test chat', - password: 'encrypted-password', - customizations: { primaryColor: '#000000' }, - includeThinking: true, - includeToolCalls: null, - } + const response = await GET( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`), + params + ) - mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) + expect(response.status).toBe(404) + }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123') - const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) + it('refuses a workspace member below admin the gate configuration', async () => { + mocks.resolvePermission.mockResolvedValue('read') - expect(response.status).toBe(200) - const data = await response.json() - expect(data.id).toBe('chat-123') - expect(data.identifier).toBe('test-chat') - expect(data.title).toBe('Test Chat') - expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat') - expect(data.hasPassword).toBe(true) - // Stored null is not an opt-in. - expect(data.includeToolCalls).toBe(false) + const response = await GET( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`), + params + ) + + expect(response.status).toBe(403) }) }) describe('PATCH', () => { - it('should return 401 when user is not authenticated', async () => { + it('returns 401 when there is no session', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ title: 'Updated Chat' }), - }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + const response = await patch({ title: 'New title' }) expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() }) - it('should return 404 when chat not found or access denied', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + it('updates the deployment and returns its public URL', async () => { + const response = await patch({ title: 'New title', identifier: 'support-v2' }) - mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) - - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ title: 'Updated Chat' }), + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + id: CHAT_ID, + chatUrl: 'http://localhost:3000/chat/support-v2', + message: 'Chat deployment updated successfully', }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) - - expect(response.status).toBe(404) - const data = await response.json() - expect(data.error).toBe('Chat not found or access denied') - expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id') + expect(writtenValues()).toMatchObject({ title: 'New title', identifier: 'support-v2' }) }) - it('should update chat when user has access', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + /** + * Restored verbatim from the pre-extraction suite: the editor renders + * `error` directly, so a contract refusal has to name the field it refused + * rather than the generic "Validation error" the route builder renders by + * default — and body validation runs before anything reads or encrypts. + */ + it('rejects a whitespace-only replacement password', async () => { + const response = await patch({ authType: 'password', password: ' ' }) - const mockChat = { - id: 'chat-123', - identifier: 'test-chat', - title: 'Test Chat', - authType: 'public', - workflowId: 'workflow-123', - includeThinking: true, - includeToolCalls: null, - } - - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: mockChat, - workspaceId: 'workspace-123', - }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Password cannot contain only whitespace') + expect(mocks.getChatDeploymentWithWorkspace).not.toHaveBeenCalled() + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }), - }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + it('names the field an identifier refusal rejected', async () => { + const response = await patch({ identifier: 'Support Chat' }) - expect(response.status).toBe(200) - expect(dbChainMockFns.update).toHaveBeenCalled() - // An unrelated field update materializes the stored null as false. - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ includeToolCalls: false }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe( + 'Identifier can only contain lowercase letters, numbers, and hyphens' ) - const data = await response.json() - expect(data.id).toBe('chat-123') - expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat') - expect(data.message).toBe('Chat deployment updated successfully') + expect(mocks.getChatDeploymentWithWorkspace).not.toHaveBeenCalled() }) - it('leaves tool calls off when a row without a tool policy disables thinking', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) + it('refuses to re-point the deployment at a different workflow', async () => { + const response = await patch({ workflowId: 'workflow-2' }) - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: { - id: 'chat-123', - identifier: 'test-chat', - title: 'Test Chat', - authType: 'public', - workflowId: 'workflow-123', - includeThinking: true, - includeToolCalls: null, - }, - workspaceId: 'workspace-123', - }) - - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ includeThinking: false }), - }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) - - expect(response.status).toBe(200) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ includeThinking: false, includeToolCalls: false }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe( + 'Changing the workflow of a chat deployment is not allowed' ) + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() }) - it('returns 403 when the updated auth type changes to a blocked mode', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + it('answers 404 for a deployment the caller cannot reach', async () => { + mocks.resolvePermission.mockResolvedValue(null) - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: { - id: 'chat-123', - identifier: 'test-chat', - authType: 'password', - workflowId: 'workflow-123', - }, - workspaceId: 'workspace-123', - }) - mockValidateChatDeployAuth.mockRejectedValueOnce(new ChatDeployAuthNotAllowedError()) + const response = await patch({ title: 'New title' }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ authType: 'public' }), - }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + expect(response.status).toBe(404) + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() + }) + + it('refuses a workspace member below admin', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + const response = await patch({ title: 'New title' }) expect(response.status).toBe(403) - expect(mockValidateChatDeployAuth).toHaveBeenCalledWith('user-id', 'workspace-123', 'public') - expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() }) - it('does not re-check the auth mode when it is unchanged (grandfathered)', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + describe('auth-type field-clearing matrix', () => { + /** + * Each mode owns exactly one gate column, so switching must clear the + * other. A leftover password on an email-gated chat, or a leftover + * allow-list on a public one, is a stale gate nothing else erases. + */ + it('clears both gates when switching to public', async () => { + mocks.getChatDeploymentWithWorkspace.mockResolvedValue({ + chat: chatRow({ authType: 'email', allowedEmails: ['a@example.com'] }), + workspaceId: WORKSPACE_ID, + }) - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: { - id: 'chat-123', - identifier: 'test-chat', + await patch({ authType: 'public' }) + + expect(writtenValues()).toMatchObject({ authType: 'public', - workflowId: 'workflow-123', - }, - workspaceId: 'workspace-123', + password: null, + allowedEmails: [], + }) }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ authType: 'public', title: 'Renamed' }), - }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + it('clears the allow-list when switching to password', async () => { + mocks.getChatDeploymentWithWorkspace.mockResolvedValue({ + chat: chatRow({ authType: 'email', allowedEmails: ['a@example.com'] }), + workspaceId: WORKSPACE_ID, + }) - expect(response.status).toBe(200) - expect(mockValidateChatDeployAuth).not.toHaveBeenCalled() - }) + await patch({ authType: 'password', password: 'secret' }) - it('rejects the update without admitting a new deploy while an attempt is in flight', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: { id: 'chat-123', identifier: 'test-chat', workflowId: 'workflow-123' }, - workspaceId: 'workspace-123', - }) - mockGetWorkflowDeploymentSummary.mockResolvedValue({ - activeDeployment: null, - latestDeploymentAttempt: { status: 'preparing' }, - warnings: [], + const values = writtenValues() + expect(values.authType).toBe('password') + expect(values.allowedEmails).toEqual([]) + expect(values.password).toBe('encrypted-password') }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ title: 'Updated Chat' }), - }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + it.each(['email', 'sso'] as const)( + 'clears the password when switching to %s', + async (authType) => { + mocks.getChatDeploymentWithWorkspace.mockResolvedValue({ + chat: chatRow({ authType: 'password', password: 'encrypted' }), + workspaceId: WORKSPACE_ID, + }) - expect(response.status).toBe(409) - expect(mockPerformFullDeploy).not.toHaveBeenCalled() - }) + await patch({ authType, allowedEmails: ['a@example.com'] }) - it('skips redeploying when the active version already matches the draft', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: { id: 'chat-123', identifier: 'test-chat', workflowId: 'workflow-123' }, - workspaceId: 'workspace-123', - }) - mockGetWorkflowDeploymentSummary.mockResolvedValue({ - activeDeployment: { - deploymentVersionId: 'dv-1', - version: 3, - deployedAt: '2026-07-15T00:00:00.000Z', - }, - latestDeploymentAttempt: { status: 'active' }, - warnings: [], - }) - mockCheckNeedsRedeployment.mockResolvedValue(false) + expect(writtenValues()).toMatchObject({ + authType, + password: null, + allowedEmails: ['a@example.com'], + }) + } + ) + + /** + * The regression this matrix exists for: a password sent alongside a + * non-password mode used to re-arm the secret the matrix had just + * cleared. + */ + it('never stores a supplied password on a chat that is not password-gated', async () => { + mocks.getChatDeploymentWithWorkspace.mockResolvedValue({ + chat: chatRow({ authType: 'password', password: 'encrypted' }), + workspaceId: WORKSPACE_ID, + }) + + await patch({ authType: 'email', allowedEmails: ['a@example.com'], password: 'secret' }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ title: 'Updated Chat' }), + expect(writtenValues().password).toBeNull() + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) - expect(response.status).toBe(200) - expect(mockPerformFullDeploy).not.toHaveBeenCalled() - expect(dbChainMockFns.update).toHaveBeenCalled() - }) + it('leaves the stored password untouched when nothing about it changes', async () => { + mocks.getChatDeploymentWithWorkspace.mockResolvedValue({ + chat: chatRow({ authType: 'password', password: 'encrypted' }), + workspaceId: WORKSPACE_ID, + }) - it('should handle identifier conflicts', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, + await patch({ title: 'New title' }) + + expect(writtenValues()).not.toHaveProperty('password') + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() }) - const mockChat = { - id: 'chat-123', - identifier: 'test-chat', - title: 'Test Chat', - workflowId: 'workflow-123', - } + it('re-encrypts a replacement password for a password-gated chat', async () => { + mocks.getChatDeploymentWithWorkspace.mockResolvedValue({ + chat: chatRow({ authType: 'password', password: 'old-encrypted' }), + workspaceId: WORKSPACE_ID, + }) - mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) + await patch({ password: 'new-secret' }) - dbChainMockFns.limit.mockResolvedValueOnce([ - { id: 'other-chat-id', identifier: 'new-identifier' }, - ]) + expect(encryptionMockFns.mockEncryptSecret).toHaveBeenCalledWith('new-secret') + expect(writtenValues().password).toBe('encrypted-password') + }) + + it('refuses password protection with nothing to protect it with', async () => { + const response = await patch({ authType: 'password' }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ identifier: 'new-identifier' }), + expect(response.status).toBe(400) + expect((await response.json()).error).toBe( + 'Password is required when using password protection' + ) + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + }) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toBe('Identifier already in use') + it('checks the auth-mode allow-list only when the mode changes', async () => { + await patch({ authType: 'public', title: 'New title' }) + + expect(mocks.validateChatDeployAuth).not.toHaveBeenCalled() }) - it('should validate password requirement for password auth', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + it('refuses a mode the permission group blocks', async () => { + mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) - const mockChat = { - id: 'chat-123', - identifier: 'test-chat', - title: 'Test Chat', - authType: 'public', - password: null, - workflowId: 'workflow-123', - } + const response = await patch({ authType: 'email', allowedEmails: ['a@example.com'] }) - mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) + expect(response.status).toBe(403) + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ authType: 'password' }), - }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + /** + * A uniqueness conflict, reported to this surface as the `400` its client + * has always recognised. The public API reports the same domain error as a + * `409`. + */ + it('reports an identifier collision as 400', async () => { + mocks.getIdentifierOwner.mockResolvedValue('other-chat') + + const response = await patch({ identifier: 'taken' }) expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toBe('Password is required when using password protection') + expect((await response.json()).error).toBe('Identifier already in use') + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() }) - it('rejects a whitespace-only replacement password', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + it('allows re-saving the identifier the deployment already holds', async () => { + mocks.getIdentifierOwner.mockResolvedValue('other-chat') - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ authType: 'password', password: ' ' }), - }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + const response = await patch({ identifier: 'support' }) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toBe('Password cannot contain only whitespace') - expect(mockCheckChatAccess).not.toHaveBeenCalled() - expect(mockEncryptSecret).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(mocks.getIdentifierOwner).not.toHaveBeenCalled() }) - it('should keep the existing password when updating a password-protected chat', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + describe('redeploy gating', () => { + it('refuses with 409 while a deployment attempt is in flight, admitting no new version', async () => { + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { status: 'preparing' }, + warnings: [], + }) + + const response = await patch({ title: 'New title' }) + + expect(response.status).toBe(409) + expect((await response.json()).error).toBe( + 'A workflow deployment is still preparing. Retry the chat update after it becomes active.' + ) + expect(mocks.performFullDeploy).not.toHaveBeenCalled() + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() + }) + + /** + * A deploy settles asynchronously, so `success` only admits the attempt. + * Advancing the chat row before cutover would strand it on the previous + * version with no error. + */ + it('refuses with 409 when the admitted deploy has not cut over, leaving the row untouched', async () => { + mocks.checkNeedsRedeployment.mockResolvedValue(true) + mocks.performFullDeploy.mockResolvedValue({ + success: true, + version: 2, + warnings: ['Webhook sync still pending'], + latestDeploymentAttempt: { status: 'preparing' }, + }) - const mockChat = { - id: 'chat-123', - identifier: 'test-chat', - title: 'Test Chat', - authType: 'password', - password: 'encrypted-password', - workflowId: 'workflow-123', - } - - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: mockChat, - workspaceId: 'workspace-123', + const response = await patch({ title: 'New title' }) + + expect(response.status).toBe(409) + expect((await response.json()).error).toBe('Webhook sync still pending') + expect(mocks.updateChatDeploymentRow).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ authType: 'password', title: 'Updated Chat' }), + it('skips redeploying when the live version already matches the draft', async () => { + const response = await patch({ title: 'New title' }) + + expect(response.status).toBe(200) + expect(mocks.performFullDeploy).not.toHaveBeenCalled() }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) - expect(response.status).toBe(200) - expect(mockEncryptSecret).not.toHaveBeenCalled() - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ - authType: 'password', - allowedEmails: [], - updatedAt: expect.any(Date), - }) - ) + it('redeploys when the draft has drifted', async () => { + mocks.checkNeedsRedeployment.mockResolvedValue(true) - const updatePayload = dbChainMockFns.set.mock.calls[0]?.[0] - expect(updatePayload.password).toBeUndefined() - }) + const response = await patch({ title: 'New title' }) - it('should allow access when user has workspace admin permission', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'admin-user-id' }, + expect(response.status).toBe(200) + expect(mocks.performFullDeploy).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'admin-1', + }) }) - const mockChat = { - id: 'chat-123', - identifier: 'test-chat', - title: 'Test Chat', - authType: 'public', - workflowId: 'workflow-123', - } - - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: mockChat, - workspaceId: 'workspace-123', - }) + it('surfaces a redeploy validation failure as 400', async () => { + mocks.checkNeedsRedeployment.mockResolvedValue(true) + mocks.performFullDeploy.mockResolvedValue({ + success: false, + errorCode: 'validation', + error: 'Workflow has no start block', + }) + + const response = await patch({ title: 'New title' }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'PATCH', - body: JSON.stringify({ title: 'Admin Updated Chat' }), + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Workflow has no start block') }) - const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + }) - expect(response.status).toBe(200) - expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id') + it('records one audit entry derived from the authoritative row', async () => { + await patch({ title: 'New title', identifier: 'support-v2' }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + resourceId: CHAT_ID, + resourceName: 'New title', + metadata: expect.objectContaining({ + identifier: 'support-v2', + chatUrl: 'http://localhost:3000/chat/support-v2', + }), + }) + ) }) }) describe('DELETE', () => { - it('should return 401 when user is not authenticated', async () => { + it('returns 401 when there is no session', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'DELETE', - }) - const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) + const response = await DELETE( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`, { method: 'DELETE' }), + params + ) expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') + expect(mocks.performChatUndeploy).not.toHaveBeenCalled() }) - it('should return 404 when chat not found or access denied', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, + it('undeploys the chat within its derived workspace', async () => { + const response = await DELETE( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`, { method: 'DELETE' }), + params + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + message: 'Chat deployment deleted successfully', }) + expect(mocks.performChatUndeploy).toHaveBeenCalledWith({ + chatId: CHAT_ID, + userId: 'admin-1', + workspaceId: WORKSPACE_ID, + projectLegacyAudit: false, + }) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1) + }) - mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) + it('answers 404 for a deployment the caller cannot reach', async () => { + mocks.resolvePermission.mockResolvedValue(null) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'DELETE', - }) - const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) + const response = await DELETE( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`, { method: 'DELETE' }), + params + ) expect(response.status).toBe(404) - const data = await response.json() - expect(data.error).toBe('Chat not found or access denied') - expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id') + expect(mocks.performChatUndeploy).not.toHaveBeenCalled() }) - it('should delete chat when user has access', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + it('refuses a workspace member below admin', async () => { + mocks.resolvePermission.mockResolvedValue('write') - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: { title: 'Test Chat', workflowId: 'workflow-123' }, - workspaceId: 'workspace-123', - }) - - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'DELETE', - }) - const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) + const response = await DELETE( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`, { method: 'DELETE' }), + params + ) - expect(response.status).toBe(200) - expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ - chatId: 'chat-123', - userId: 'user-id', - workspaceId: 'workspace-123', - }) - const data = await response.json() - expect(data.message).toBe('Chat deployment deleted successfully') + expect(response.status).toBe(403) + expect(mocks.performChatUndeploy).not.toHaveBeenCalled() }) - it('should allow deletion when user has workspace admin permission', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'admin-user-id' }, - }) - - mockCheckChatAccess.mockResolvedValue({ - hasAccess: true, - chat: { title: 'Test Chat', workflowId: 'workflow-123' }, - workspaceId: 'workspace-123', + /** An infrastructure fault must not be concealed as a missing deployment. */ + it('propagates an undeploy infrastructure failure as a 500', async () => { + mocks.performChatUndeploy.mockResolvedValue({ + success: false, + error: 'delete from "chat" failed: connection terminated', }) - const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { - method: 'DELETE', - }) - const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) + const response = await DELETE( + new NextRequest(`http://localhost:3000/api/chat/manage/${CHAT_ID}`, { method: 'DELETE' }), + params + ) - expect(response.status).toBe(200) - expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id') - expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ - chatId: 'chat-123', - userId: 'admin-user-id', - workspaceId: 'workspace-123', - }) + expect(response.status).toBe(500) + const body = await response.json() + expect(body.error).toBe('Failed to delete chat deployment') + expect(JSON.stringify(body)).not.toContain('connection terminated') + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() }) }) }) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 8088df80d29..75770ea5666 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -1,366 +1,83 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { chat } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { chatIdParamsSchema, updateChatContract } from '@/lib/api/contracts/chats' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { isDev } from '@/lib/core/config/env-flags' -import { encryptSecret } from '@/lib/core/security/encryption' -import { getEmailDomain } from '@/lib/core/utils/urls' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import { deleteChatContract, updateChatContract } from '@/lib/api/contracts/chats' +import { getChatDetailContract } from '@/lib/api/contracts/deployments' +import { getValidationErrorMessage } from '@/lib/api/server' import { - getWorkflowDeploymentSummary, - performChatUndeploy, - performFullDeploy, -} from '@/lib/workflows/orchestration' -import { checkChatAccess } from '@/app/api/chat/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' + chatDeploymentOperations, + deleteChatDeployment, + readChatDeployment, + updateChatDeployment, +} from '@/lib/chat-deployments/application' +import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' +import { createInternalChatDeploymentErrorPolicy } from '@/app/api/chat/error-policy' +import { toChatDetailResponse } from '@/app/api/chat/presenters' +import { createErrorResponse } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' export const maxDuration = 120 -const logger = createLogger('ChatDetailAPI') - -/** - * GET endpoint to fetch a specific chat deployment by ID - */ -export const GET = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = chatIdParamsSchema.parse(await params) - const chatId = id - - try { - const session = await getSession() - - if (!session) { - return createErrorResponse('Unauthorized', 401) - } - - const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id) - - if (!hasAccess || !chatRecord) { - return createErrorResponse('Chat not found or access denied', 404) - } - - const { password, ...safeData } = chatRecord - - const baseDomain = getEmailDomain() - const protocol = isDev ? 'http' : 'https' - const chatUrl = `${protocol}://${baseDomain}/chat/${chatRecord.identifier}` - - const result = { - ...safeData, - includeToolCalls: safeData.includeToolCalls ?? false, - chatUrl, - hasPassword: !!password, - } - - return createSuccessResponse(result) - } catch (error) { - logger.error('Error fetching chat deployment:', error) - return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployment'), 500) - } - } -) - -/** - * PATCH endpoint to update an existing chat deployment - */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - try { - const session = await getSession() - - if (!session) { - return createErrorResponse('Unauthorized', 401) - } - - const parsed = await parseRequest(updateChatContract, request, context, { - validationErrorResponse: (error) => - createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'), - }) - if (!parsed.success) return parsed.response - - const { id: chatId } = parsed.data.params - const validatedData = parsed.data.body - - const { - hasAccess, - chat: existingChatRecord, - workspaceId: chatWorkspaceId, - } = await checkChatAccess(chatId, session.user.id) - - if (!hasAccess || !existingChatRecord) { - return createErrorResponse('Chat not found or access denied', 404) - } - - const existingChat = [existingChatRecord] - - const { - workflowId, - identifier, - title, - description, - customizations, - authType, - password, - allowedEmails, - outputConfigs, - includeThinking, - includeToolCalls, - } = validatedData - - if (workflowId && workflowId !== existingChat[0].workflowId) { - return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400) - } - - // Enforce the permission group's chat auth-mode allow-list only when the - // mode actually changes, so a grandfathered mode already saved on this chat - // can still be re-saved (e.g. a title-only edit) without a 403. - if (authType && authType !== existingChatRecord.authType && chatWorkspaceId) { - try { - await validateChatDeployAuth(session.user.id, chatWorkspaceId, authType) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - return createErrorResponse(error.message, 403) - } - throw error - } - } - - if (identifier && identifier !== existingChat[0].identifier) { - const existingIdentifier = await db - .select() - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1) - - if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) { - return createErrorResponse('Identifier already in use', 400) - } - } - - let encryptedPassword - - if (password) { - const { encrypted } = await encryptSecret(password) - encryptedPassword = encrypted - logger.info('Password provided, will be updated') - } else if (authType === 'password' && !password) { - if (existingChat[0].authType !== 'password' || !existingChat[0].password) { - return createErrorResponse('Password is required when using password protection', 400) - } - logger.info('Keeping existing password') - } - - /** - * A settings update only redeploys when the draft actually drifted from - * the active version, and never while another attempt is in flight — - * otherwise each blocked retry would admit a fresh deployment version - * on top of the pending one. - */ - const deploymentSummary = await getWorkflowDeploymentSummary(existingChat[0].workflowId) - const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status - if (attemptStatus === 'preparing' || attemptStatus === 'activating') { - return createErrorResponse( - 'A workflow deployment is still preparing. Retry the chat update after it becomes active.', - 409 - ) - } - - const needsRedeploy = - !deploymentSummary.activeDeployment || - (await checkNeedsRedeployment(existingChat[0].workflowId)) - - if (needsRedeploy) { - const deployResult = await performFullDeploy({ - workflowId: existingChat[0].workflowId, - userId: session.user.id, - }) - - if (!deployResult.success) { - logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`) - const status = - deployResult.errorCode === 'validation' - ? 400 - : deployResult.errorCode === 'not_found' - ? 404 - : 500 - return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status) - } - /** - * Deploys settle asynchronously: `success` only admits the attempt. - * The chat record must not advance until cutover finished, otherwise - * a later preparation failure strands the chat on the previous - * version with no error. A blocked retry lands in the in-flight gate - * above without admitting another version. Mirrors performChatDeploy. - */ - if (deployResult.latestDeploymentAttempt?.status !== 'active') { - return createErrorResponse( - deployResult.warnings?.[0] ?? - 'Workflow deployment is still preparing. Retry the chat update after it becomes active.', - 409 - ) - } - logger.info( - `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` - ) - } - - const updateData: Record = { - updatedAt: new Date(), - } - - if (identifier) updateData.identifier = identifier - if (title) updateData.title = title - if (description !== undefined) updateData.description = description - if (customizations) updateData.customizations = customizations - - if (authType) { - updateData.authType = authType - - if (authType === 'public') { - updateData.password = null - updateData.allowedEmails = [] - } else if (authType === 'password') { - updateData.allowedEmails = [] - } else if (authType === 'email' || authType === 'sso') { - updateData.password = null - } - } - - /** - * Only store a new password when the chat ends up password-protected. - * Applying it unconditionally re-armed the secret that the branch above - * just cleared, so `PATCH { authType: 'email', password }` persisted an - * encrypted password on an email-gated chat. - */ - if (encryptedPassword && (authType ?? existingChat[0].authType) === 'password') { - updateData.password = encryptedPassword - } - - if (allowedEmails) { - updateData.allowedEmails = allowedEmails - } - - if (outputConfigs) { - updateData.outputConfigs = outputConfigs - } - - if (includeThinking !== undefined) { - updateData.includeThinking = includeThinking - } - - // Partial updates keep the stored value; a row predating the column reads false. - updateData.includeToolCalls = includeToolCalls ?? existingChatRecord.includeToolCalls ?? false - - const emailCount = Array.isArray(updateData.allowedEmails) - ? updateData.allowedEmails.length - : undefined - const outputConfigsCount = Array.isArray(updateData.outputConfigs) - ? updateData.outputConfigs.length - : undefined - - logger.info('Updating chat deployment with values:', { - chatId, - authType: updateData.authType, - hasPassword: updateData.password !== undefined, - emailCount, - outputConfigsCount, - includeThinking: updateData.includeThinking, - includeToolCalls: updateData.includeToolCalls, - }) - - await db.update(chat).set(updateData).where(eq(chat.id, chatId)) - - const updatedIdentifier = identifier || existingChat[0].identifier - - const baseDomain = getEmailDomain() - const protocol = isDev ? 'http' : 'https' - const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}` - - logger.info(`Chat "${chatId}" updated successfully`) - - recordAudit({ - workspaceId: chatWorkspaceId || null, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CHAT_UPDATED, - resourceType: AuditResourceType.CHAT, - resourceId: chatId, - resourceName: title || existingChatRecord.title, - description: `Updated chat deployment "${title || existingChatRecord.title}"`, - metadata: { - identifier: updatedIdentifier, - authType: updateData.authType || existingChatRecord.authType, - workflowId: workflowId || existingChatRecord.workflowId, - chatUrl, - }, - request, - }) - - return createSuccessResponse({ - id: chatId, - chatUrl, - message: 'Chat deployment updated successfully', - }) - } catch (error) { - logger.error('Error updating chat deployment:', error) - return createErrorResponse(getErrorMessage(error, 'Failed to update chat deployment'), 500) - } - } -) - /** - * DELETE endpoint to remove a chat deployment + * The workspace editor's chat-deployment surface. + * + * Every method is an adapter over the same application use cases the public API + * and the Copilot tools call. Authorization, the auth-type field-clearing + * matrix, identifier uniqueness, and the redeploy-gating protocol live in + * `lib/chat-deployments/application`; this file owns only session + * authentication and the editor's wire shapes. */ -export const DELETE = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = chatIdParamsSchema.parse(await params) - const chatId = id - - try { - const session = await getSession() - - if (!session) { - return createErrorResponse('Unauthorized', 401) - } - - const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess( - chatId, - session.user.id - ) - - if (!hasAccess) { - return createErrorResponse('Chat not found or access denied', 404) - } - - const result = await performChatUndeploy({ - chatId, - userId: session.user.id, - workspaceId: chatWorkspaceId, - }) - - if (!result.success) { - return createErrorResponse(result.error || 'Failed to delete chat', 500) - } - - return createSuccessResponse({ - message: 'Chat deployment deleted successfully', - }) - } catch (error) { - logger.error('Error deleting chat deployment:', error) - return createErrorResponse(getErrorMessage(error, 'Failed to delete chat deployment'), 500) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getChatDetailContract, + auth: internalSessionAuth, + operation: chatDeploymentOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI chat reads retain their existing admission policy.', + }), + errorPolicy: createInternalChatDeploymentErrorPolicy('Failed to fetch chat deployment'), + mapInput: ({ params }) => ({ chatDeploymentId: params.id }), + useCase: readChatDeployment, + present: ({ deployment }) => + toChatDetailResponse(deployment, buildChatDeploymentUrl(deployment.identifier)), +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updateChatContract, + auth: internalSessionAuth, + operation: chatDeploymentOperations.update, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI chat updates retain their existing admission policy.', + }), + errorPolicy: createInternalChatDeploymentErrorPolicy('Failed to update chat deployment'), + parseOptions: { + /** + * The editor renders `error` verbatim, so a contract refusal has to name the + * field it refused — the builder default renders every 400 as the literal + * "Validation error" and demotes the specifics to `details`. + */ + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'), + }, + mapInput: ({ params, body }) => ({ chatDeploymentId: params.id, ...body }), + useCase: updateChatDeployment, + present: ({ deployment, chatUrl }) => ({ + id: deployment.id, + chatUrl, + message: 'Chat deployment updated successfully', + }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteChatContract, + auth: internalSessionAuth, + operation: chatDeploymentOperations.delete, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI chat deletions retain their existing admission policy.', + }), + errorPolicy: createInternalChatDeploymentErrorPolicy('Failed to delete chat deployment'), + mapInput: ({ params }) => ({ chatDeploymentId: params.id }), + useCase: deleteChatDeployment, + present: () => ({ message: 'Chat deployment deleted successfully' }), +}) diff --git a/apps/sim/app/api/chat/presenters.ts b/apps/sim/app/api/chat/presenters.ts new file mode 100644 index 00000000000..e91a0a1d687 --- /dev/null +++ b/apps/sim/app/api/chat/presenters.ts @@ -0,0 +1,28 @@ +import type { ChatDetail } from '@/lib/api/contracts/deployments' +import type { ChatDeploymentView } from '@/lib/chat-deployments/application' + +/** + * Projects a chat deployment onto the internal editor's detail shape. + * + * The stored `customizations`, `allowedEmails`, and `outputConfigs` are + * schemaless JSON columns, so their defaults are applied here rather than + * assumed: a row written before a field existed reads as its empty value + * instead of `null` reaching the client. + */ +export function toChatDetailResponse(deployment: ChatDeploymentView, chatUrl: string): ChatDetail { + return { + id: deployment.id, + identifier: deployment.identifier, + title: deployment.title, + description: deployment.description ?? '', + authType: deployment.authType as ChatDetail['authType'], + allowedEmails: (deployment.allowedEmails as string[] | null) ?? [], + outputConfigs: (deployment.outputConfigs as ChatDetail['outputConfigs'] | null) ?? [], + includeThinking: deployment.includeThinking, + includeToolCalls: deployment.includeToolCalls ?? false, + customizations: (deployment.customizations as ChatDetail['customizations']) ?? undefined, + isActive: deployment.isActive, + chatUrl, + hasPassword: deployment.hasPassword, + } +} diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 9772b567188..581b1af1e13 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -1,36 +1,58 @@ /** - * Tests for chat API route + * Tests for the internal chat collection route. + * + * `POST` is an adapter over the `workflows.chat.deploy` use case, so its seams + * are the canonical workflow load, the workspace permission resolver, and the + * deploy orchestration. * * @vitest-environment node */ import { + auditMock, authMockFns, - dbChainMockFns, + resetDbChainMock, resetEnvMock, setEnv, workflowsApiUtilsMock, workflowsApiUtilsMockFns, - workflowsOrchestrationMock, - workflowsOrchestrationMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckWorkflowAccessForChatCreation, mockValidateChatDeployAuth } = vi.hoisted(() => ({ - mockCheckWorkflowAccessForChatCreation: vi.fn(), - mockValidateChatDeployAuth: vi.fn(), +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + performChatDeploy: vi.fn(), + validateChatDeployAuth: vi.fn(), + getLiveChatDeployment: vi.fn(), + getIdentifierOwner: vi.fn(), })) const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse -const mockPerformChatDeploy = workflowsOrchestrationMockFns.mockPerformChatDeploy +vi.mock('@sim/audit', () => auditMock) vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) - -vi.mock('@/app/api/chat/utils', () => ({ - checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/chat-deployments/queries', () => ({ + getLiveChatDeploymentForWorkflow: mocks.getLiveChatDeployment, + getChatDeploymentIdOwningIdentifier: mocks.getIdentifierOwner, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + performChatDeploy: mocks.performChatDeploy, + performChatUndeploy: vi.fn(), })) - vi.mock('@/ee/access-control/utils/permission-check', () => { class ChatDeployAuthNotAllowedError extends Error { constructor() { @@ -38,14 +60,73 @@ vi.mock('@/ee/access-control/utils/permission-check', () => { this.name = 'ChatDeployAuthNotAllowedError' } } - return { validateChatDeployAuth: mockValidateChatDeployAuth, ChatDeployAuthNotAllowedError } + return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } }) -vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) - -import { GET, POST } from '@/app/api/chat/route' +import { POST } from '@/app/api/chat/route' import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' +const WORKFLOW_ID = 'workflow-1' +const WORKSPACE_ID = 'workspace-1' + +const validBody = { + workflowId: WORKFLOW_ID, + identifier: 'support', + title: 'Support chat', + customizations: { primaryColor: '#000', welcomeMessage: 'Hi' }, +} + +function postRequest(body: unknown) { + return new NextRequest('http://localhost:3000/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +async function post(body: unknown) { + return POST(postRequest(body), { params: Promise.resolve({}) }) +} + +const settledRow = { + id: 'chat-1', + workflowId: WORKFLOW_ID, + userId: 'admin-1', + identifier: 'support', + title: 'Support chat', + description: null, + isActive: true, + customizations: {}, + authType: 'public', + password: null, + allowedEmails: [], + outputConfigs: [], + includeThinking: false, + includeToolCalls: false, + archivedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:30:00.000Z'), +} + +/** + * The canonical chat reads `deployWorkflowChat` performs: the existing + * deployment before the write, the identifier owner, and the settled row it + * re-reads afterwards. + */ +function queueChatLookups(existing: unknown | null, identifierOwnerId: string | null) { + mocks.getLiveChatDeployment.mockResolvedValue(existing) + mocks.getIdentifierOwner.mockResolvedValue(identifierOwnerId) + mocks.performChatDeploy.mockImplementation(async () => { + mocks.getLiveChatDeployment.mockResolvedValue(settledRow) + return { + success: true, + chatId: 'chat-1', + chatUrl: 'http://localhost:3000/chat/support', + isUpdate: false, + } + }) +} + describe('Chat API Route', () => { afterAll(() => { resetEnvMock() @@ -53,7 +134,28 @@ describe('Chat API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() setEnv({ NODE_ENV: 'development', NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1', name: 'Admin' }, + session: { id: 'session-1' }, + }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolveWorkflowContext.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workflow: { id: WORKFLOW_ID, name: 'Support', workspaceId: WORKSPACE_ID }, + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.validateChatDeployAuth.mockResolvedValue(undefined) + mocks.performChatDeploy.mockResolvedValue({ + success: true, + chatId: 'chat-1', + chatUrl: 'http://localhost:3000/chat/support', + isUpdate: false, + }) mockCreateSuccessResponse.mockImplementation((data) => { return new Response(JSON.stringify(data), { @@ -61,409 +163,200 @@ describe('Chat API Route', () => { headers: { 'Content-Type': 'application/json' }, }) }) - mockCreateErrorResponse.mockImplementation((message, status = 500) => { return new Response(JSON.stringify({ error: message }), { status, headers: { 'Content-Type': 'application/json' }, }) }) - - mockPerformChatDeploy.mockResolvedValue({ - success: true, - chatId: 'test-uuid', - chatUrl: 'http://localhost:3000/chat/test-chat', - }) - }) - - describe('GET', () => { - it('should return 401 when user is not authenticated', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = new NextRequest('http://localhost:3000/api/chat') - const response = await GET(req) - - expect(response.status).toBe(401) - expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401) - }) - - it('should return chat deployments for authenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) - - const mockDeployments = [{ id: 'deployment-1' }, { id: 'deployment-2' }] - dbChainMockFns.where.mockResolvedValueOnce(mockDeployments) - - const req = new NextRequest('http://localhost:3000/api/chat') - const response = await GET(req) - - expect(response.status).toBe(200) - // Each row is normalized so a missing tool policy reads as off. - expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ - deployments: mockDeployments.map((deployment) => ({ - ...deployment, - includeToolCalls: false, - })), - }) - expect(dbChainMockFns.where).toHaveBeenCalled() - }) - - it('should handle errors when fetching deployments', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) - - dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) - - const req = new NextRequest('http://localhost:3000/api/chat') - const response = await GET(req) - - expect(response.status).toBe(500) - expect(mockCreateErrorResponse).toHaveBeenCalledWith('Database error', 500) - }) }) describe('POST', () => { - it('should return 401 when user is not authenticated', async () => { + it('returns 401 when there is no session', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify({}), - }) - const response = await POST(req) + const response = await post(validBody) expect(response.status).toBe(401) - expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() }) - it('should validate request data', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) - - const invalidData = { title: 'Test Chat' } // Missing required fields - - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(invalidData), - }) - const response = await POST(req) + it('validates the request body before touching the workflow', async () => { + const response = await post({ workflowId: WORKFLOW_ID }) expect(response.status).toBe(400) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() }) - it('should reject if identifier already exists', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) - - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } - - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'existing-chat' }]) // Identifier exists - mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false }) - - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), - }) - const response = await POST(req) + /** + * The deploy modal renders `error` verbatim, so a refusal has to name the + * field it refused rather than the generic "Validation error" the route + * builder renders by default. + */ + it('names the field a contract refusal rejected', async () => { + const response = await post({ ...validBody, identifier: 'Support Chat' }) expect(response.status).toBe(400) - expect(mockCreateErrorResponse).toHaveBeenCalledWith('Identifier already in use', 400) - }) - - it('should reject if workflow not found', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) - - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } - - dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available - mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false }) - - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), - }) - const response = await POST(req) - - expect(response.status).toBe(404) - expect(mockCreateErrorResponse).toHaveBeenCalledWith( - 'Workflow not found or access denied', - 404 + expect((await response.json()).error).toBe( + 'Identifier can only contain lowercase letters, numbers, and hyphens' ) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() }) - it('should allow chat deployment when user owns workflow directly', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id', email: 'user@example.com' }, - }) + it('deploys the chat through the shared use case', async () => { + queueChatLookups(null, null) - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } - - dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available - mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ - hasAccess: true, - workflow: { userId: 'user-id', workspaceId: null, isDeployed: true }, - }) - - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), - }) - const response = await POST(req) + const response = await post(validBody) expect(response.status).toBe(200) - expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') - expect(mockPerformChatDeploy).toHaveBeenCalledWith( + expect(await response.json()).toMatchObject({ + id: 'chat-1', + chatId: 'chat-1', + chatUrl: 'http://localhost:3000/chat/support', + message: 'Chat deployment created successfully', + }) + expect(mocks.performChatDeploy).toHaveBeenCalledWith( expect.objectContaining({ - workflowId: 'workflow-123', - userId: 'user-id', - identifier: 'test-chat', + workflowId: WORKFLOW_ID, + identifier: 'support', + title: 'Support chat', + workspaceId: WORKSPACE_ID, + userId: 'admin-1', + projectLegacyAudit: false, }) ) }) - it('returns 403 when the chat auth type is blocked by the permission group', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id', email: 'user@example.com' }, - }) - - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', - authType: 'public', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } - - dbChainMockFns.limit.mockResolvedValueOnce([]) - mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ - hasAccess: true, - workflow: { userId: 'user-id', workspaceId: 'workspace-1', isDeployed: true }, - }) - mockValidateChatDeployAuth.mockRejectedValueOnce(new ChatDeployAuthNotAllowedError()) - - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), - }) - const response = await POST(req) - - expect(response.status).toBe(403) - expect(mockValidateChatDeployAuth).toHaveBeenCalledWith('user-id', 'workspace-1', 'public') - expect(mockPerformChatDeploy).not.toHaveBeenCalled() - }) - - it('passes chat customizations and outputConfigs through in the API request shape', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id', email: 'user@example.com' }, - }) + it('passes customizations and output configs through unchanged', async () => { + queueChatLookups(null, null) - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', + await post({ + ...validBody, customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - imageUrl: 'https://example.com/icon.png', + primaryColor: '#ff0000', + welcomeMessage: 'Welcome', + imageUrl: 'https://example.com/logo.png', }, - outputConfigs: [{ blockId: 'agent-1', path: 'content' }], - } - - dbChainMockFns.limit.mockResolvedValueOnce([]) - mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ - hasAccess: true, - workflow: { userId: 'user-id', workspaceId: null, isDeployed: true }, + outputConfigs: [{ blockId: 'block-1', path: 'result' }], }) - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), - }) - const response = await POST(req) - - expect(response.status).toBe(200) - expect(mockPerformChatDeploy).toHaveBeenCalledWith( + expect(mocks.performChatDeploy).toHaveBeenCalledWith( expect.objectContaining({ - workflowId: 'workflow-123', - identifier: 'test-chat', customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - imageUrl: 'https://example.com/icon.png', + primaryColor: '#ff0000', + welcomeMessage: 'Welcome', + imageUrl: 'https://example.com/logo.png', }, - outputConfigs: [{ blockId: 'agent-1', path: 'content' }], + outputConfigs: [{ blockId: 'block-1', path: 'result' }], }) ) }) - it('should allow chat deployment when user has workspace admin permission', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id', email: 'user@example.com' }, - }) + it('rejects an identifier another live deployment already holds', async () => { + queueChatLookups(null, 'other-chat') - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } + const response = await post(validBody) - dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available - mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ - hasAccess: true, - workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true }, - }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Identifier already in use') + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), - }) - const response = await POST(req) + it('conceals a workflow the caller cannot reach', async () => { + mocks.resolvePermission.mockResolvedValue(null) - expect(response.status).toBe(200) - expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') - expect(mockPerformChatDeploy).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-123', - workspaceId: 'workspace-123', - }) - ) + const response = await post(validBody) + + expect(response.status).toBe(404) + expect(mocks.performChatDeploy).not.toHaveBeenCalled() }) - it('should reject when workflow is in workspace but user lacks admin permission', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + it('refuses a workspace member below admin', async () => { + mocks.resolvePermission.mockResolvedValue('write') - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } + const response = await post(validBody) - dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available - mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ - hasAccess: false, - }) + expect(response.status).toBe(403) + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + it('refuses an auth mode the permission group blocks', async () => { + queueChatLookups(null, null) + mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), + const response = await post({ + ...validBody, + authType: 'email', + allowedEmails: ['a@example.com'], }) - const response = await POST(req) - expect(response.status).toBe(404) - expect(mockCreateErrorResponse).toHaveBeenCalledWith( - 'Workflow not found or access denied', - 404 - ) - expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') + expect(response.status).toBe(403) + expect(mocks.performChatDeploy).not.toHaveBeenCalled() }) - it('should handle workspace permission check errors gracefully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id' }, - }) + /** + * An email- or SSO-gated chat with an empty allow-list is unenterable, so + * it is refused in the use case rather than only at this boundary. + */ + it.each([ + ['email', 'At least one email or domain is required when using email access control'], + ['sso', 'At least one email or domain is required when using SSO access control'], + ])('refuses %s gating with an empty allow-list', async (authType, message) => { + queueChatLookups(null, null) - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } + const response = await post({ ...validBody, authType, allowedEmails: [] }) - dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available - mockCheckWorkflowAccessForChatCreation.mockRejectedValue(new Error('Permission check failed')) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe(message) + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), + it('surfaces a deploy validation failure as a 400', async () => { + queueChatLookups(null, null) + mocks.performChatDeploy.mockResolvedValue({ + success: false, + errorCode: 'validation', + error: 'Password is required when using password protection', }) - const response = await POST(req) - expect(response.status).toBe(500) - expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') + const response = await post(validBody) + + expect(response.status).toBe(400) + expect((await response.json()).error).toBe( + 'Password is required when using password protection' + ) }) - it('should call performChatDeploy for undeployed workflow', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-id', email: 'user@example.com' }, + /** A retryable in-flight deployment is a conflict, not a malformed request. */ + it('surfaces an in-flight workflow deployment as a 409', async () => { + queueChatLookups(null, null) + mocks.performChatDeploy.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: + 'A workflow deployment is still preparing. Retry chat deployment after it becomes active.', }) - const validData = { - workflowId: 'workflow-123', - identifier: 'test-chat', - title: 'Test Chat', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } + const response = await post(validBody) - dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available - mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ - hasAccess: true, - workflow: { userId: 'user-id', workspaceId: null, isDeployed: false }, - }) + expect(response.status).toBe(409) + expect((await response.json()).error).toContain('still preparing') + }) - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), + it('keeps an internal invariant failure a 500 with a generic message', async () => { + queueChatLookups(null, null) + mocks.performChatDeploy.mockResolvedValue({ + success: false, + errorCode: 'internal', + error: 'Workflow deployment reported active without a live deployment version.', }) - const response = await POST(req) - expect(response.status).toBe(200) - expect(mockPerformChatDeploy).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-123', - userId: 'user-id', - includeThinking: false, - includeToolCalls: false, - }) - ) + const response = await post(validBody) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body.error).toBe('Failed to create chat deployment') + expect(JSON.stringify(body)).not.toContain('live deployment version') }) }) }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index e916d48b2da..c2eda996222 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -1,156 +1,47 @@ -import { db } from '@sim/db' -import { chat } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { createChatContract } from '@/lib/api/contracts/chats' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performChatDeploy } from '@/lib/workflows/orchestration' -import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { getValidationErrorMessage } from '@/lib/api/server' import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' - -const logger = createLogger('ChatAPI') - -export const GET = withRouteHandler(async (_request: NextRequest) => { - try { - const session = await getSession() - - if (!session) { - return createErrorResponse('Unauthorized', 401) - } - - // Get the user's chat deployments - const deployments = await db - .select() - .from(chat) - .where(and(eq(chat.userId, session.user.id), isNull(chat.archivedAt))) - - return createSuccessResponse({ - deployments: deployments.map((deployment) => ({ - ...deployment, - includeToolCalls: deployment.includeToolCalls ?? false, - })), - }) - } catch (error) { - logger.error('Error fetching chat deployments:', error) - return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployments'), 500) - } -}) - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - - if (!session) { - return createErrorResponse('Unauthorized', 401) - } - - const parsed = await parseRequest( - createChatContract, - request, - {}, - { - validationErrorResponse: (error) => - createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'), - } - ) - if (!parsed.success) return parsed.response - - const { - workflowId, - identifier, - title, - description = '', - customizations, - authType = 'public', - password, - allowedEmails = [], - outputConfigs = [], - includeThinking = false, - includeToolCalls = false, - } = parsed.data.body - - if (authType === 'password' && !password) { - return createErrorResponse('Password is required when using password protection', 400) - } - - if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { - return createErrorResponse( - 'At least one email or domain is required when using email access control', - 400 - ) - } - - if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { - return createErrorResponse( - 'At least one email or domain is required when using SSO access control', - 400 - ) - } - - const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([ - db - .select() - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1), - checkWorkflowAccessForChatCreation(workflowId, session.user.id), - ]) - - if (existingIdentifier.length > 0) { - return createErrorResponse('Identifier already in use', 400) - } - - if (!hasAccess || !workflowRecord) { - return createErrorResponse('Workflow not found or access denied', 404) - } - - if (workflowRecord.workspaceId) { - try { - await validateChatDeployAuth(session.user.id, workflowRecord.workspaceId, authType) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - return createErrorResponse(error.message, 403) - } - throw error - } - } - - const result = await performChatDeploy({ - workflowId, - userId: session.user.id, - identifier, - title, - description, - customizations, - authType, - password, - allowedEmails, - outputConfigs, - includeThinking, - includeToolCalls, - workspaceId: workflowRecord.workspaceId, - }) - - if (!result.success) { - return createErrorResponse(result.error || 'Failed to deploy chat', 500) - } - - return createSuccessResponse({ - id: result.chatId, - chatId: result.chatId, - chatUrl: result.chatUrl, - message: 'Chat deployment created successfully', - }) - } catch (error) { - logger.error('Error creating chat deployment:', error) - return createErrorResponse(getErrorMessage(error, 'Failed to create chat deployment'), 500) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { generateRequestId } from '@/lib/core/utils/request' +import { deployWorkflowChat } from '@/lib/workflows/application/chat-deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { createInternalChatDeploymentErrorPolicy } from '@/app/api/chat/error-policy' +import { createErrorResponse } from '@/app/api/workflows/utils' + +/** + * Deploys a workflow as a chat. + * + * An adapter over `workflows.chat.deploy` — the same use case the Copilot + * `deploy_chat` tool calls. The route previously reimplemented that operation's + * authorization, identifier-uniqueness check, and auth-mode policy inline, so + * the two could disagree about who may deploy a chat. + */ +export const POST = defineInternalJsonRoute({ + contract: createChatContract, + auth: internalSessionAuth, + operation: workflowOperations.deployChat, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI chat deployments retain their existing admission policy.', + }), + errorPolicy: createInternalChatDeploymentErrorPolicy('Failed to create chat deployment'), + parseOptions: { + /** + * The editor's deploy modal renders `error` verbatim, so a contract refusal + * has to name the field it refused — the builder default renders every 400 + * as the literal "Validation error" and demotes the specifics to `details`. + */ + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'), + }, + mapInput: ({ body }) => ({ ...body, requestId: generateRequestId() }), + useCase: deployWorkflowChat, + present: (result) => ({ + id: result.chatId, + chatId: result.chatId, + chatUrl: result.chatUrl, + message: 'Chat deployment created successfully', + }), }) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 0cd669ecfa2..dbe52c82785 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -462,7 +462,12 @@ describe('POST /api/credentials', () => { expect(data).toEqual({ code: 'invalid_credentials', error: 'invalid_credentials' }) }) - it('maps a provider outage to a 502, not a 400', async () => { + /** + * A provider outage is `503`, matching `PROVIDER_OUTAGE_CODES` and the v2 + * surface. It was `502` here alone — the same failure rendered three ways + * across the two surfaces and the shared status helper. + */ + it('maps a provider outage to a 503 with a Retry-After, not a 400', async () => { mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce( new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'zoom_token_mint', @@ -481,7 +486,8 @@ describe('POST /api/credentials', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(502) + expect(response.status).toBe(503) + expect(response.headers.get('Retry-After')).toBe('5') expect(data).toEqual({ code: 'provider_unavailable', error: 'provider_unavailable' }) }) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 88f33ff6b54..35649fa28f0 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -1,26 +1,32 @@ -import { dbReplica } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { - type DashboardStatsResponse, - type SegmentStats, - statsQueryParamsSchema, - type WorkflowStats, -} from '@/lib/api/contracts/logs' +import { type DashboardStatsResponse, statsQueryParamsSchema } from '@/lib/api/contracts/logs' import { isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { buildDashboardStats, resolveLogStatsWindow } from '@/lib/logs/stats' +import { readLogStatsBounds, readLogStatsSegments } from '@/lib/logs/stats-queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsStatsAPI') export const revalidate = 0 +/** + * Session-authenticated dashboard stats. + * + * The read itself lives in `lib/logs/stats-queries.ts` and the aggregation in + * `lib/logs/stats.ts`, both shared with the public `GET /api/v2/logs/stats`. + * The authorization does not: this route answers a caller without workspace + * access with a zeroed 200, where v2 conceals the workspace as a 404. Migrating + * this route to the shared use case would change that, so it keeps its legacy + * check and consumes only the surface-neutral halves. + */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -65,199 +71,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: true }) const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter - const boundsQuery = await dbReplica - .select({ - minTime: sql`MIN(${workflowExecutionLogs.startedAt})`, - maxTime: sql`MAX(${workflowExecutionLogs.startedAt})`, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(whereCondition) - - const bounds = boundsQuery[0] - const now = new Date() - - let startTime: Date - let endTime: Date - - if (!bounds?.minTime || !bounds?.maxTime) { - endTime = now - startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) - } else { - startTime = new Date(bounds.minTime) - endTime = new Date(Math.max(new Date(bounds.maxTime).getTime(), now.getTime())) - } - - const totalMs = Math.max(1, endTime.getTime() - startTime.getTime()) - const segmentMs = Math.max(60000, Math.floor(totalMs / params.segmentCount)) - const startTimeIso = startTime.toISOString() - - const statsQuery = await dbReplica - .select({ - workflowId: sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, - workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, - segmentIndex: - sql`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as( - 'segment_index' - ), - totalExecutions: sql`COUNT(*)`.as('total_executions'), - successfulExecutions: - sql`COUNT(*) FILTER (WHERE ${workflowExecutionLogs.level} != 'error')`.as( - 'successful_executions' - ), - avgDurationMs: - sql`COALESCE(AVG(${workflowExecutionLogs.totalDurationMs}) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0), 0)`.as( - 'avg_duration_ms' - ), - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(whereCondition) - .groupBy( - sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, - sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, - sql`segment_index` - ) - - const workflowMap = new Map< - string, - { - workflowId: string - workflowName: string - segments: Map - totalExecutions: number - totalSuccessful: number - } - >() - - for (const row of statsQuery) { - const segmentIndex = Math.min( - params.segmentCount - 1, - Math.max(0, Math.floor(Number(row.segmentIndex))) - ) - - if (!workflowMap.has(row.workflowId)) { - workflowMap.set(row.workflowId, { - workflowId: row.workflowId, - workflowName: row.workflowName, - segments: new Map(), - totalExecutions: 0, - totalSuccessful: 0, - }) - } - - const wf = workflowMap.get(row.workflowId)! - wf.totalExecutions += Number(row.totalExecutions) - wf.totalSuccessful += Number(row.successfulExecutions) - - const existing = wf.segments.get(segmentIndex) - if (existing) { - const oldTotal = existing.totalExecutions - const newTotal = oldTotal + Number(row.totalExecutions) - existing.totalExecutions = newTotal - existing.successfulExecutions += Number(row.successfulExecutions) - existing.avgDurationMs = - newTotal > 0 - ? (existing.avgDurationMs * oldTotal + - Number(row.avgDurationMs || 0) * Number(row.totalExecutions)) / - newTotal - : 0 - } else { - wf.segments.set(segmentIndex, { - timestamp: new Date(startTime.getTime() + segmentIndex * segmentMs).toISOString(), - totalExecutions: Number(row.totalExecutions), - successfulExecutions: Number(row.successfulExecutions), - avgDurationMs: Number(row.avgDurationMs || 0), - }) - } - } - - const workflows: WorkflowStats[] = [] - for (const wf of workflowMap.values()) { - const segments: SegmentStats[] = [] - for (let i = 0; i < params.segmentCount; i++) { - const existing = wf.segments.get(i) - if (existing) { - segments.push(existing) - } else { - segments.push({ - timestamp: new Date(startTime.getTime() + i * segmentMs).toISOString(), - totalExecutions: 0, - successfulExecutions: 0, - avgDurationMs: 0, - }) - } - } - - workflows.push({ - workflowId: wf.workflowId, - workflowName: wf.workflowName, - segments, - totalExecutions: wf.totalExecutions, - totalSuccessful: wf.totalSuccessful, - overallSuccessRate: - wf.totalExecutions > 0 ? (wf.totalSuccessful / wf.totalExecutions) * 100 : 100, - }) - } - - workflows.sort((a, b) => { - const errA = a.overallSuccessRate < 100 ? 1 - a.overallSuccessRate / 100 : 0 - const errB = b.overallSuccessRate < 100 ? 1 - b.overallSuccessRate / 100 : 0 - if (errA !== errB) return errB - errA - return a.workflowName.localeCompare(b.workflowName) - }) - - const aggregateSegments: SegmentStats[] = [] - let totalRuns = 0 - let totalErrors = 0 - let weightedLatencySum = 0 - let latencyCount = 0 - - for (let i = 0; i < params.segmentCount; i++) { - let segTotal = 0 - let segSuccess = 0 - let segWeightedLatency = 0 - let segLatencyCount = 0 - - for (const wf of workflows) { - const seg = wf.segments[i] - segTotal += seg.totalExecutions - segSuccess += seg.successfulExecutions - if (seg.avgDurationMs > 0 && seg.totalExecutions > 0) { - segWeightedLatency += seg.avgDurationMs * seg.totalExecutions - segLatencyCount += seg.totalExecutions - } - } - - totalRuns += segTotal - totalErrors += segTotal - segSuccess - weightedLatencySum += segWeightedLatency - latencyCount += segLatencyCount - - aggregateSegments.push({ - timestamp: new Date(startTime.getTime() + i * segmentMs).toISOString(), - totalExecutions: segTotal, - successfulExecutions: segSuccess, - avgDurationMs: segLatencyCount > 0 ? segWeightedLatency / segLatencyCount : 0, - }) - } - - const avgLatency = latencyCount > 0 ? weightedLatencySum / latencyCount : 0 - - const response: DashboardStatsResponse = { - workflows, - aggregateSegments, - totalRuns, - totalErrors, - avgLatency, - timeBounds: { - start: startTime.toISOString(), - end: endTime.toISOString(), - }, - segmentMs, - } + const bounds = await readLogStatsBounds(whereCondition) + const window = resolveLogStatsWindow(bounds, params.segmentCount) + const rows = await readLogStatsSegments( + whereCondition, + window.startTime.toISOString(), + window.segmentMs + ) + const { stats } = buildDashboardStats(rows, window, params.segmentCount) - return NextResponse.json(response, { status: 200 }) + return NextResponse.json(stats, { status: 200 }) } catch (validationError) { if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid logs stats request parameters`, { diff --git a/apps/sim/app/api/table/bulk-delete/route.ts b/apps/sim/app/api/table/bulk-delete/route.ts index 8af204a51a8..870e58b7055 100644 --- a/apps/sim/app/api/table/bulk-delete/route.ts +++ b/apps/sim/app/api/table/bulk-delete/route.ts @@ -18,8 +18,9 @@ export const POST = defineInternalJsonRoute({ errorPolicy: internalTableErrorPolicies.bulk, mapInput: ({ body }) => ({ assertedWorkspaceId: body.workspaceId, + folderKeying: 'ids' as const, tableIds: body.tableIds, - folderIds: body.folderIds, + folders: body.folderIds, }), useCase: bulkDeleteTables, present: ({ deleted, skipped, notFound, failed, deletedItems }) => ({ diff --git a/apps/sim/app/api/table/bulk-move/route.ts b/apps/sim/app/api/table/bulk-move/route.ts index 1e8ac395fff..2dfc260b2aa 100644 --- a/apps/sim/app/api/table/bulk-move/route.ts +++ b/apps/sim/app/api/table/bulk-move/route.ts @@ -18,9 +18,10 @@ export const POST = defineInternalJsonRoute({ errorPolicy: internalTableErrorPolicies.bulk, mapInput: ({ body }) => ({ assertedWorkspaceId: body.workspaceId, + folderKeying: 'ids' as const, tableIds: body.tableIds, - folderIds: body.folderIds, - targetFolderId: body.targetFolderId, + folders: body.folderIds, + targetFolder: body.targetFolderId, }), useCase: bulkMoveTables, present: ({ moved, skipped, notFound, failed }) => ({ diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[auditLogId]/route.ts similarity index 86% rename from apps/sim/app/api/v2/audit-logs/[id]/route.ts rename to apps/sim/app/api/v2/audit-logs/[auditLogId]/route.ts index 54d5292bb29..bf37d146527 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[auditLogId]/route.ts @@ -12,7 +12,7 @@ import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' export const revalidate = 0 /** - * GET /api/v2/audit-logs/[id] + * GET /api/v2/audit-logs/[auditLogId] * * Returns a single audit log entry scoped to an explicitly selected * organization. Audit logs are personal-key-only because a workspace-scoped @@ -24,7 +24,10 @@ export const GET = defineV2JsonRoute({ operation: auditLogOperations.readDetail, rateLimit: v2RateLimits.publicApi, errorPolicy: v2OrchestrationErrorPolicy, - mapInput: ({ params, query }) => ({ id: params.id, organizationId: query.organizationId }), + mapInput: ({ params, query }) => ({ + id: params.auditLogId, + organizationId: query.organizationId, + }), useCase: getAuditLog, present: ({ log }) => ({ data: formatV2AuditLogEntry(log) }), }) diff --git a/apps/sim/app/api/v2/audit-logs/route.test.ts b/apps/sim/app/api/v2/audit-logs/route.test.ts index 59f7e6107bc..9b6bd5c65e4 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -31,7 +31,7 @@ vi.mock('@/lib/audit-logs/application/get-audit-log', () => ({ import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET as getDetail } from '@/app/api/v2/audit-logs/[id]/route' +import { GET as getDetail } from '@/app/api/v2/audit-logs/[auditLogId]/route' import { GET as listLogs } from '@/app/api/v2/audit-logs/route' const auth = { @@ -244,7 +244,7 @@ describe('v2 audit-log routes', () => { 'http://localhost:3000/api/v2/audit-logs/audit-1?organizationId=org-1' ) const response = await getDetail(request, { - params: Promise.resolve({ id: 'audit-1' }), + params: Promise.resolve({ auditLogId: 'audit-1' }), }) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/v2/blocks/[blockId]/route.ts b/apps/sim/app/api/v2/blocks/[blockId]/route.ts new file mode 100644 index 00000000000..31236183629 --- /dev/null +++ b/apps/sim/app/api/v2/blocks/[blockId]/route.ts @@ -0,0 +1,23 @@ +import { v2GetBlockContract } from '@/lib/api/contracts/v2/catalog' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { getCatalogBlock } from '@/lib/catalog/application/get-block' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/blocks/{blockId} — Read one block's full configuration shape. */ +export const GET = defineV2JsonRoute({ + contract: v2GetBlockContract, + operation: catalogOperations.readBlock, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + blockId: params.blockId, + }), + useCase: getCatalogBlock, + present: ({ block }) => ({ data: block }), +}) diff --git a/apps/sim/app/api/v2/blocks/route.test.ts b/apps/sim/app/api/v2/blocks/route.test.ts new file mode 100644 index 00000000000..764fa491dcc --- /dev/null +++ b/apps/sim/app/api/v2/blocks/route.test.ts @@ -0,0 +1,246 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ list: vi.fn(), read: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/catalog/application/list-blocks', () => ({ + listCatalogBlocks: { operation: { id: 'catalog.blocks.list' }, execute: mocks.list }, +})) +vi.mock('@/lib/catalog/application/get-block', () => ({ + getCatalogBlock: { operation: { id: 'catalog.blocks.read' }, execute: mocks.read }, +})) + +import { v2ListBlocksContract } from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET as GET_BLOCK } from '@/app/api/v2/blocks/[blockId]/route' +import { GET } from '@/app/api/v2/blocks/route' +import { cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const summary = { + id: 'slack', + name: 'Slack', + description: 'Send messages in Slack.', + category: 'tools', + source: 'builtin' as const, + triggerAllowed: true, + triggerCapable: true, + triggerIds: [], + toolIds: ['slack_message'], + operationIds: ['send'], + preview: false, + tags: ['messaging'], +} + +const detail = { + ...summary, + inputSchema: [], + operationInputSchema: {}, + inputDefinitions: {}, + operations: {}, + tools: [], + triggers: [], + outputs: {}, +} + +/** A cursor exactly as this route mints one, built from the shared codec. */ +function blockCursor({ + offset, + search, + category, + capability, + source, + sortBy = 'id', + sortOrder = 'asc', +}: { + offset: number + search?: string + category?: string + capability?: string + source?: string + sortBy?: string + sortOrder?: string +}): string { + return encodeOffsetCursor( + cursorSortKey(sortBy, sortOrder), + cursorScopeKey(cursorRoute(v2ListBlocksContract), { + workspaceId: WORKSPACE_ID, + search, + category, + capability, + source, + }), + offset + ) +} + +function request(url: string) { + return new NextRequest(`http://localhost:3000${url}`, { headers: { 'x-api-key': 'key' } }) +} + +describe('/api/v2/blocks', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.list.mockResolvedValue({ entries: [summary], hasMore: false, offset: 0, limit: 50 }) + mocks.read.mockResolvedValue({ block: detail }) + }) + + it('returns the v2 list envelope and keeps the response out of shared caches', async () => { + const response = await GET(request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(await response.json()).toEqual({ data: [summary], nextCursor: null }) + expect(mocks.list).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + search: undefined, + category: undefined, + capability: undefined, + source: undefined, + sortBy: 'id', + sortOrder: 'asc', + limit: 50, + cursor: undefined, + offset: 0, + }, + request: expect.anything(), + }) + }) + + it('resumes from the offset cursor and mints the next one while pages remain', async () => { + mocks.list.mockResolvedValue({ entries: [summary], hasMore: true, offset: 2, limit: 2 }) + const cursor = blockCursor({ offset: 2 }) + + const response = await GET( + request( + `/api/v2/blocks?workspaceId=${WORKSPACE_ID}&limit=2&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).nextCursor).toBe(blockCursor({ offset: 4 })) + }) + + it('rejects a cursor replayed after a filter change', async () => { + const cursor = blockCursor({ offset: 2 }) + + const response = await GET( + request( + `/api/v2/blocks?workspaceId=${WORKSPACE_ID}&capability=trigger&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it.each([ + ['an unknown param', 'bogus=1'], + ['a fractional limit', 'limit=1.5'], + ['a zero limit', 'limit=0'], + ['an over-cap limit', 'limit=101'], + ['an empty search', 'search='], + ['an unknown sort field', 'sortBy=popularity'], + ['an unknown capability', 'capability=response'], + ])('rejects %s instead of ignoring it', async (_label, query) => { + const response = await GET(request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}&${query}`)) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('names the bound in a limit rejection', async () => { + const response = await GET(request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}&limit=101`)) + + expect((await response.json()).error.message).toContain('limit cannot exceed 100') + }) + + it('conceals a workspace the caller cannot reach as absent', async () => { + mocks.list.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found')) + + const response = await GET(request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Workspace not found', + }) + }) +}) + +describe('/api/v2/blocks/[blockId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.read.mockResolvedValue({ block: detail }) + }) + + it('returns one block in the single-resource envelope', async () => { + const response = await GET_BLOCK(request(`/api/v2/blocks/slack?workspaceId=${WORKSPACE_ID}`), { + params: Promise.resolve({ blockId: 'slack' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: detail }) + expect(mocks.read).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, blockId: 'slack' }, + request: expect.anything(), + }) + }) + + it('requires the workspace whose availability rules decide the answer', async () => { + const response = await GET_BLOCK(request('/api/v2/blocks/slack'), { + params: Promise.resolve({ blockId: 'slack' }), + }) + + expect(response.status).toBe(400) + expect(mocks.read).not.toHaveBeenCalled() + }) + + it('answers not found for a block this caller cannot see', async () => { + mocks.read.mockRejectedValue(new OrchestrationError('not_found', 'Block not found')) + + const response = await GET_BLOCK( + request(`/api/v2/blocks/preview_thing?workspaceId=${WORKSPACE_ID}`), + { params: Promise.resolve({ blockId: 'preview_thing' }) } + ) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Block not found') + }) +}) diff --git a/apps/sim/app/api/v2/blocks/route.ts b/apps/sim/app/api/v2/blocks/route.ts new file mode 100644 index 00000000000..3c07c77cfd3 --- /dev/null +++ b/apps/sim/app/api/v2/blocks/route.ts @@ -0,0 +1,56 @@ +import { type V2ListBlocksQuery, v2ListBlocksContract } from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { listCatalogBlocks } from '@/lib/catalog/application/list-blocks' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Every param that changes which blocks, in which order, this list returns. */ +function blockCursorFilters(query: V2ListBlocksQuery) { + return cursorScopeKey(cursorRoute(v2ListBlocksContract), { + workspaceId: query.workspaceId, + search: query.search, + category: query.category, + capability: query.capability, + source: query.source, + }) +} + +/** GET /api/v2/blocks — List the blocks available in a workspace. */ +export const GET = defineV2JsonRoute({ + contract: v2ListBlocksContract, + operation: catalogOperations.listBlocks, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + /** + * An offset cursor, matching `GET /api/v2/skills`: the sequence merges a + * static code registry with per-workspace DB rows and re-sorts in JS, so no + * ordered SQL read exists for a keyset predicate to act on. Every param that + * decides which sequence that is gets stamped into the token; `limit` does + * not, because it selects how much of the sequence to return. + */ + mapInput: ({ query }) => ({ + ...query, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + blockCursorFilters(query) + ), + }), + useCase: listCatalogBlocks, + present: ({ entries, hasMore, offset, limit }, { query }) => ({ + data: entries, + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + blockCursorFilters(query), + offset + limit + ) + : null, + }), +}) diff --git a/apps/sim/app/api/v2/chat-deployments/route.test.ts b/apps/sim/app/api/v2/chat-deployments/route.test.ts new file mode 100644 index 00000000000..8e6da94ade2 --- /dev/null +++ b/apps/sim/app/api/v2/chat-deployments/route.test.ts @@ -0,0 +1,296 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + resetDbChainMock, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + loadWorkspaceContext: vi.fn(), + resolveWorkflowContext: vi.fn(), + listDeployments: vi.fn(), + getLiveChatDeployment: vi.fn(), + getIdentifierOwner: vi.fn(), + performChatDeploy: vi.fn(), + validateChatDeployAuth: vi.fn(), + audit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { CHAT_DEPLOYED: 'chat.deployed', CHAT_DELETED: 'chat.deleted' }, + AuditResourceType: { CHAT: 'chat' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspaceContext, + resolveActiveWorkspaceApplicationContext: async (workspaceId: string) => { + const context = await mocks.loadWorkspaceContext(workspaceId) + if (!context) throw new Error('Workspace not found') + return context + }, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/chat-deployments/queries', () => ({ + listWorkspaceChatDeployments: mocks.listDeployments, + getLiveChatDeploymentForWorkflow: mocks.getLiveChatDeployment, + getChatDeploymentIdOwningIdentifier: mocks.getIdentifierOwner, + getChatDeploymentWithWorkspace: vi.fn(), + updateChatDeploymentRow: vi.fn(), +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + performChatDeploy: mocks.performChatDeploy, + performChatUndeploy: vi.fn(), +})) +vi.mock('@/ee/access-control/utils/permission-check', () => { + class ChatDeployAuthNotAllowedError extends Error { + constructor() { + super('This chat authentication mode is not allowed') + this.name = 'ChatDeployAuthNotAllowedError' + } + } + return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } +}) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { GET } from '@/app/api/v2/chat-deployments/route' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' + +const personalKeyAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const workspaceKeyAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:workspace-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +function chatRow(overrides: Record = {}) { + return { + id: 'chat-1', + workflowId: WORKFLOW_ID, + userId: 'owner-1', + identifier: 'support', + title: 'Support chat', + description: 'Ask us anything', + isActive: true, + customizations: { primaryColor: '#000', welcomeMessage: 'Hi' }, + authType: 'public', + password: null, + allowedEmails: [], + outputConfigs: [], + includeThinking: false, + includeToolCalls: null, + archivedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:30:00.000Z'), + ...overrides, + } +} + +async function get(search = `?workspaceId=${WORKSPACE_ID}`) { + return GET(new NextRequest(`http://localhost/api/v2/chat-deployments${search}`), { + params: Promise.resolve({}), + }) +} + +describe('/api/v2/chat-deployments', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + v2RouteMocks.authenticate.mockResolvedValue(personalKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.resolveWorkflowContext.mockResolvedValue({ + ...workspaceContext, + workflowId: WORKFLOW_ID, + workflow: { id: WORKFLOW_ID, name: 'Support', workspaceId: WORKSPACE_ID }, + }) + mocks.listDeployments.mockResolvedValue({ data: [chatRow()], nextCursorKeys: null }) + mocks.getLiveChatDeployment.mockResolvedValue(null) + mocks.getIdentifierOwner.mockResolvedValue(null) + mocks.validateChatDeployAuth.mockResolvedValue(undefined) + mocks.performChatDeploy.mockImplementation(async () => { + mocks.getLiveChatDeployment.mockResolvedValue(chatRow()) + return { + success: true, + chatId: 'chat-1', + chatUrl: 'http://localhost:3000/chat/support', + isUpdate: false, + } + }) + }) + + describe('GET', () => { + it('publishes the deployment with its public URL and no password', async () => { + const response = await get() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toHaveLength(1) + expect(body.data[0]).toMatchObject({ + id: 'chat-1', + workflowId: WORKFLOW_ID, + workspaceId: WORKSPACE_ID, + identifier: 'support', + url: expect.stringContaining('/chat/support'), + includeToolCalls: false, + }) + expect(body.data[0]).not.toHaveProperty('password') + expect(body.nextCursor).toBeNull() + }) + + /** `url` must be a path, not a host: there is no chat subdomain to publish. */ + it('never publishes a per-deployment host', async () => { + const body = await (await get()).json() + + expect(new URL(body.data[0].url).hostname).not.toContain('support') + expect(body.data[0]).not.toHaveProperty('subdomain') + }) + + /** + * The list is a `read` operation reachable by a workspace API key, so it + * must not carry what the admin-gated detail read exists to gate. Asserted + * against the serialized body rather than the parsed keys, so a field + * reintroduced at any depth — nested under a future wrapper, say — is still + * caught. + */ + it('omits the fields the admin-gated detail read carries', async () => { + mocks.listDeployments.mockResolvedValue({ + data: [ + chatRow({ + authType: 'password', + password: 'encrypted-secret', + allowedEmails: ['gated@example.com'], + customizations: { primaryColor: '#gated', welcomeMessage: 'gated-welcome' }, + }), + ], + nextCursorKeys: null, + }) + + const response = await get() + const body = await response.json() + const serialized = JSON.stringify(body) + + expect(response.status).toBe(200) + expect(serialized).not.toContain('allowedEmails') + expect(serialized).not.toContain('hasPassword') + expect(serialized).not.toContain('customizations') + expect(serialized).not.toContain('gated@example.com') + expect(serialized).not.toContain('gated-welcome') + expect(serialized).not.toContain('encrypted-secret') + }) + + /** Narrowing must not cost discovery: the mode label and identity stay. */ + it('still carries what a caller needs to decide whether to fetch the detail', async () => { + mocks.listDeployments.mockResolvedValue({ + data: [chatRow({ authType: 'password', password: 'encrypted-secret' })], + nextCursorKeys: null, + }) + + const body = await (await get()).json() + + expect(body.data[0]).toMatchObject({ + id: 'chat-1', + identifier: 'support', + title: 'Support chat', + authType: 'password', + isActive: true, + url: expect.stringContaining('/chat/support'), + createdAt: '2026-06-12T10:30:00.000Z', + }) + }) + + it('passes the workflow and active filters to the read', async () => { + await get(`?workspaceId=${WORKSPACE_ID}&workflowId=${WORKFLOW_ID}&isActive=false`) + + expect(mocks.listDeployments).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, isActive: false }) + ) + }) + + it('rejects a cursor minted under different filters', async () => { + mocks.listDeployments.mockResolvedValue({ + data: [chatRow()], + nextCursorKeys: [{ key: 'createdAt', value: '2026-06-12T10:30:00.000Z' }], + }) + const cursor = (await (await get()).json()).nextCursor + expect(cursor).toEqual(expect.any(String)) + + const response = await get( + `?workspaceId=${WORKSPACE_ID}&isActive=true&cursor=${encodeURIComponent(cursor)}` + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + }) + + it('accepts a workspace API key for the read', async () => { + v2RouteMocks.authenticate.mockResolvedValue(workspaceKeyAuth) + + const response = await get() + + expect(response.status).toBe(200) + }) + + it('conceals a workspace the caller cannot reach as 404', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await get() + + expect(response.status).toBe(404) + expect(mocks.listDeployments).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + expect((await get()).status).toBe(401) + }) + }) +}) diff --git a/apps/sim/app/api/v2/chat-deployments/route.ts b/apps/sim/app/api/v2/chat-deployments/route.ts new file mode 100644 index 00000000000..660db2aa332 --- /dev/null +++ b/apps/sim/app/api/v2/chat-deployments/route.ts @@ -0,0 +1,78 @@ +import { v2ListChatDeploymentsContract } from '@/lib/api/contracts/v2/chat-deployments' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { chatDeploymentOperations, listChatDeployments } from '@/lib/chat-deployments/application' +import { + chatDeploymentErrorPolicy, + toV2ChatDeploymentListItem, +} from '@/app/api/v2/chat-deployments/utils' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +/** Every param that changes which deployments, in which order, this list returns. */ +function chatDeploymentCursorFilters(query: { + workspaceId: string + workflowId?: string + isActive?: boolean +}) { + return cursorScopeKey(cursorRoute(v2ListChatDeploymentsContract), { + workspaceId: query.workspaceId, + workflowId: query.workflowId, + isActive: query.isActive, + }) +} + +/** + * GET /api/v2/chat-deployments — List a workspace's chat deployments. + * + * Workspace-scoped, not creator-scoped: a chat deployment is workspace + * property, and every write on it is authorized by workspace admin. + * + * The only chat-deployment path that is not under a workflow, and deliberately + * so. Every write addresses one workflow's chat singleton at + * `/api/v2/workflows/{workflowId}/deployments/chat`, but "what does this workspace + * serve" is a cross-parent question no per-workflow path can answer. Filter by + * `workflowId` to resolve one workflow's chat without holding its id. + * + * Deliberately a narrower projection than the detail read. Discovery is a + * `read`-level concern, so this stays callable by any workspace member and by a + * workspace API key — which is only sound because the entries carry no + * `allowedEmails`, `hasPassword`, or `customizations`. Those live on the + * admin-gated detail read, so the list cannot be used to route around it. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListChatDeploymentsContract, + auth: v2ApiKeyAuth, + operation: chatDeploymentOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: chatDeploymentErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + workflowId: query.workflowId, + isActive: query.isActive, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + chatDeploymentCursorFilters(query) + ), + }), + useCase: listChatDeployments, + present: ({ deployments, nextCursorKeys }, { query }) => ({ + data: deployments.map((deployment) => + toV2ChatDeploymentListItem(deployment, query.workspaceId) + ), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + chatDeploymentCursorFilters(query) + ), + }), +}) diff --git a/apps/sim/app/api/v2/chat-deployments/utils.ts b/apps/sim/app/api/v2/chat-deployments/utils.ts new file mode 100644 index 00000000000..160cc4cafef --- /dev/null +++ b/apps/sim/app/api/v2/chat-deployments/utils.ts @@ -0,0 +1,137 @@ +import { + V2_CHAT_DEPLOYMENT_CUSTOMIZATION_KEYS, + type V2ChatDeployment, + type V2ChatDeploymentListItem, + v2ChatDeploymentListItemSchema, + v2ChatDeploymentSchema, +} from '@/lib/api/contracts/v2/chat-deployments' +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' +import type { ChatDeploymentView } from '@/lib/chat-deployments/application' +import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' + +/** + * Shared serialization + error mapping for the v2 chat-deployment surface. + */ + +type V2ChatDeploymentCustomizations = V2ChatDeployment['customizations'] +type V2ChatDeploymentOutputConfig = V2ChatDeployment['outputConfigs'][number] + +/** + * The customizations a stored blob may contribute to a read. + * + * `chat.customizations` is schemaless JSONB with several writers — the internal + * editor declares `logoUrl` and `headerText` that this surface does not, and the + * Copilot deploy tool stores whatever it is handed. Spreading the blob into a + * parse published those keys, and once the shape was tightened it failed the + * response parse instead, turning a legitimate row into a `500` on the detail + * read and on every list page it appeared in. Projecting onto the declared keys + * makes the read canonical by construction. + */ +function normalizeStoredCustomizations(raw: unknown): V2ChatDeploymentCustomizations { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} + const source = raw as Record + const picked: Record = {} + for (const key of V2_CHAT_DEPLOYMENT_CUSTOMIZATION_KEYS) { + const value = source[key] + if (typeof value === 'string') picked[key] = value + } + return picked +} + +/** + * The output configs a stored blob may contribute to a read. + * + * Same JSONB reasoning as {@link normalizeStoredCustomizations}: the create path + * accepts an entry with an empty `path` and with keys beyond `blockId`/`path`, + * so an entry is projected rather than parsed. An entry naming no block is + * unusable to a caller and is dropped. + */ +function normalizeStoredOutputConfigs(raw: unknown): V2ChatDeploymentOutputConfig[] { + if (!Array.isArray(raw)) return [] + const configs: V2ChatDeploymentOutputConfig[] = [] + for (const entry of raw) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue + const { blockId, path } = entry as Record + if (typeof blockId !== 'string' || blockId.length === 0) continue + configs.push({ blockId, path: typeof path === 'string' ? path : '' }) + } + return configs +} + +/** The allow-list a stored blob may contribute to a read. */ +function normalizeStoredAllowedEmails(raw: unknown): string[] { + if (!Array.isArray(raw)) return [] + return raw.filter((entry): entry is string => typeof entry === 'string') +} + +/** + * Projects a chat deployment onto the public shape. + * + * The stored `customizations`, `allowedEmails`, and `outputConfigs` are + * schemaless JSON columns, so a row carrying a key or a value the published + * shape does not declare would fail the response parse. Each is projected onto + * the declared shape here, which is also what makes the published schema honest + * about never returning null. + * + * The password never reaches this function: `ChatDeploymentView` has already + * dropped it and replaced it with `hasPassword`. + */ +export function toV2ChatDeployment( + deployment: ChatDeploymentView, + workspaceId: string +): V2ChatDeployment { + return v2ChatDeploymentSchema.parse({ + id: deployment.id, + workflowId: deployment.workflowId, + workspaceId, + identifier: deployment.identifier, + url: buildChatDeploymentUrl(deployment.identifier), + title: deployment.title, + description: deployment.description ?? '', + isActive: deployment.isActive, + authType: deployment.authType, + hasPassword: deployment.hasPassword, + allowedEmails: normalizeStoredAllowedEmails(deployment.allowedEmails), + customizations: normalizeStoredCustomizations(deployment.customizations), + outputConfigs: normalizeStoredOutputConfigs(deployment.outputConfigs), + includeThinking: deployment.includeThinking, + includeToolCalls: deployment.includeToolCalls ?? false, + createdAt: deployment.createdAt.toISOString(), + updatedAt: deployment.updatedAt.toISOString(), + }) +} + +/** + * Projects a chat deployment onto the list shape. + * + * Serialized field by field rather than by stripping the detail shape, so a + * field added to `V2ChatDeployment` cannot reach the workspace-wide list by + * default — the same reason `toV2Credential` enumerates rather than spreads a + * row. `allowedEmails`, `hasPassword`, and `customizations` are deliberately + * absent: they are gated behind the admin-only detail read. + */ +export function toV2ChatDeploymentListItem( + deployment: ChatDeploymentView, + workspaceId: string +): V2ChatDeploymentListItem { + return v2ChatDeploymentListItemSchema.parse({ + id: deployment.id, + workflowId: deployment.workflowId, + workspaceId, + identifier: deployment.identifier, + url: buildChatDeploymentUrl(deployment.identifier), + title: deployment.title, + description: deployment.description ?? '', + isActive: deployment.isActive, + authType: deployment.authType, + outputConfigs: normalizeStoredOutputConfigs(deployment.outputConfigs), + includeThinking: deployment.includeThinking, + includeToolCalls: deployment.includeToolCalls ?? false, + createdAt: deployment.createdAt.toISOString(), + updatedAt: deployment.updatedAt.toISOString(), + }) +} + +export const chatDeploymentErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Chat deployment not found', +}) diff --git a/apps/sim/app/api/v2/connector-types/route.test.ts b/apps/sim/app/api/v2/connector-types/route.test.ts new file mode 100644 index 00000000000..2d4578661b8 --- /dev/null +++ b/apps/sim/app/api/v2/connector-types/route.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ connectorTypes: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/catalog/application/list-connector-types', () => ({ + listCatalogConnectorTypes: { + operation: { id: 'catalog.connector_types.list' }, + execute: mocks.connectorTypes, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/v2/connector-types/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const connectorType = { + connectorType: 'google_drive', + name: 'Google Drive', + description: 'Sync Drive documents.', + version: '1.0.0', + auth: { mode: 'oauth' as const, provider: 'google-drive' }, + configFields: [ + { + id: 'folderSelector', + title: 'Folder', + type: 'selector' as const, + canonicalParamId: 'folderId', + mode: 'basic' as const, + multi: true, + }, + ], + supportsIncrementalSync: true, + tagDefinitions: [], +} + +function request(url: string) { + return new NextRequest(`http://localhost:3000${url}`, { headers: { 'x-api-key': 'key' } }) +} + +describe('/api/v2/connector-types', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.connectorTypes.mockResolvedValue({ connectorTypes: [connectorType] }) + }) + + it('returns the whole catalog in one page and keeps it out of shared caches', async () => { + const response = await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(await response.json()).toEqual({ data: [connectorType], nextCursor: null }) + }) + + it('publishes the multi and canonical-pair properties a caller configures against', async () => { + const response = await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}`)) + + const [field] = (await response.json()).data[0].configFields + expect(field.multi).toBe(true) + expect(field.canonicalParamId).toBe('folderId') + }) + + it('rejects pagination params a full-set list does not implement', async () => { + const response = await GET( + request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&limit=1`) + ) + + expect(response.status).toBe(400) + expect(mocks.connectorTypes).not.toHaveBeenCalled() + }) + + it('requires the workspace whose availability rules decide the answer', async () => { + expect((await GET(request('/api/v2/connector-types'))).status).toBe(400) + expect(mocks.connectorTypes).not.toHaveBeenCalled() + }) + + it('conceals a workspace the caller cannot reach as absent', async () => { + mocks.connectorTypes.mockRejectedValue( + new OrchestrationError('not_found', 'Workspace not found') + ) + + const response = await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Workspace not found', + }) + }) +}) diff --git a/apps/sim/app/api/v2/connector-types/route.ts b/apps/sim/app/api/v2/connector-types/route.ts new file mode 100644 index 00000000000..176f5c0de6f --- /dev/null +++ b/apps/sim/app/api/v2/connector-types/route.ts @@ -0,0 +1,20 @@ +import { v2ListConnectorTypesContract } from '@/lib/api/contracts/v2/catalog' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { listCatalogConnectorTypes } from '@/lib/catalog/application/list-connector-types' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/connector-types — List every knowledge-base connector type. */ +export const GET = defineV2JsonRoute({ + contract: v2ListConnectorTypesContract, + operation: catalogOperations.listConnectorTypes, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + mapInput: ({ query }) => query, + useCase: listCatalogConnectorTypes, + present: ({ connectorTypes }) => ({ data: connectorTypes, nextCursor: null }), +}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts index 38362bef21f..6b7cf080ea9 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -12,21 +12,51 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ execute: vi.fn() })) +const mocks = vi.hoisted(() => ({ + update: vi.fn(), + remove: vi.fn(), +})) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + vi.mock('@/lib/credentials/application/service-account', () => ({ deleteCredentialUseCase: { operation: { id: 'credentials.delete' }, - execute: mocks.execute, + execute: mocks.remove, }, })) -import { DELETE } from '@/app/api/v2/credentials/[credentialId]/route' +import { PrincipalKindAuthorizationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' +import { DELETE, PATCH } from '@/app/api/v2/credentials/[credentialId]/route' + +vi.mock('@/lib/credentials/application/credential-crud', async () => { + const { OrchestrationError: BaseError } = await import('@/lib/core/orchestration/types') + class MockCredentialProviderOperationError extends BaseError { + constructor( + message: string, + readonly providerErrorCode: string, + readonly providerUnavailable: boolean + ) { + super('validation', message) + this.name = 'CredentialProviderOperationError' + } + } + return { + CredentialProviderOperationError: MockCredentialProviderOperationError, + updateWorkspaceCredentialUseCase: { + operation: { id: 'credentials.update' }, + execute: mocks.update, + }, + } +}) const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const CREDENTIAL_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7' + const auth = { principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, rolloutUserId: 'user-1', @@ -35,6 +65,179 @@ const auth = { keyType: 'personal' as const, } +const credential = { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom automation', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + encryptedServiceAccountKey: 'MUST_NOT_LEAK_CIPHERTEXT', +} + +const context = { params: Promise.resolve({ credentialId: CREDENTIAL_ID }) } + +function patchRequest(body: unknown, query = `?workspaceId=${WORKSPACE_ID}`): NextRequest { + return new NextRequest(`http://localhost:3000/api/v2/credentials/${CREDENTIAL_ID}${query}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) +} + +describe('PATCH /api/v2/credentials/[credentialId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.update.mockResolvedValue({ + credential, + access: { isAdmin: true }, + previousDisplayName: 'Zoom automation', + updatedFields: ['encryptedServiceAccountKey'], + auditMetadata: {}, + }) + }) + + it('rotates secret material and returns the credential without it', async () => { + const request = patchRequest({ clientSecret: 'rotated-secret' }) + const response = await PATCH(request, context) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + const body = await response.text() + expect(JSON.parse(body)).toEqual({ + data: { + id: CREDENTIAL_ID, + type: 'service_account', + displayName: 'Zoom automation', + description: null, + providerId: 'zoom-service-account', + accountId: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + }) + expect(body).not.toContain('rotated-secret') + expect(body).not.toContain('MUST_NOT_LEAK_CIPHERTEXT') + }) + + it('asserts the workspace scope and preserves the credential id', async () => { + const request = patchRequest({ displayName: 'Zoom prod' }) + await PATCH(request, context) + + expect(mocks.update).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + displayName: 'Zoom prod', + credentialId: CREDENTIAL_ID, + assertedWorkspaceId: WORKSPACE_ID, + }, + request, + }) + }) + + it('clears a description with an explicit null and leaves an omitted field alone', async () => { + await PATCH(patchRequest({ description: null }), context) + + expect(mocks.update).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ description: null }) }) + ) + expect(mocks.update.mock.calls[0][0].input).not.toHaveProperty('displayName') + }) + + it('rejects an empty patch rather than reporting a no-op success', async () => { + const response = await PATCH(patchRequest({}), context) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: { code: 'BAD_REQUEST' } }) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('rejects an undeclared body field', async () => { + const response = await PATCH(patchRequest({ providerId: 'other-provider' }), context) + + expect(response.status).toBe(400) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('requires the workspace assertion', async () => { + const response = await PATCH(patchRequest({ displayName: 'Zoom prod' }, ''), context) + + expect(response.status).toBe(400) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('refuses a workspace API key with 403 rather than acting on it', async () => { + mocks.update.mockRejectedValue( + new PrincipalKindAuthorizationError('workspace_api_key', 'credentials.update') + ) + + const response = await PATCH(patchRequest({ displayName: 'Zoom prod' }), context) + + expect(response.status).toBe(403) + }) + + /** + * A provider that cannot be reached is transient. Rendering it as the `400` + * the base `OrchestrationError('validation')` projects would tell the caller + * its secret is permanently wrong and invite it to revoke a good credential. + */ + it('answers a provider outage with 503 and a Retry-After', async () => { + mocks.update.mockRejectedValue( + new CredentialProviderOperationError('upstream unreachable', 'provider_unavailable', true) + ) + + const response = await PATCH(patchRequest({ clientSecret: 'rotated' }), context) + + expect(response.status).toBe(503) + expect(response.headers.get('Retry-After')).not.toBeNull() + await expect(response.json()).resolves.toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Credential provider is temporarily unavailable', + }, + }) + }) + + it('answers a provider rejection with 400 and the provider code', async () => { + mocks.update.mockRejectedValue( + new CredentialProviderOperationError('invalid_credentials', 'invalid_credentials', false) + ) + + const response = await PATCH(patchRequest({ clientSecret: 'rotated' }), context) + + expect(response.status).toBe(400) + expect(response.headers.get('Retry-After')).toBeNull() + await expect(response.json()).resolves.toEqual({ + error: { + code: 'BAD_REQUEST', + message: 'invalid_credentials', + details: { providerErrorCode: 'invalid_credentials' }, + }, + }) + }) + + it('conceals a cross-tenant credential as a not-found', async () => { + mocks.update.mockRejectedValue(new OrchestrationError('not_found', 'Credential not found')) + + const response = await PATCH(patchRequest({ displayName: 'Zoom prod' }), context) + + expect(response.status).toBe(404) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) +}) + describe('DELETE /api/v2/credentials/[credentialId]', () => { beforeEach(() => { vi.clearAllMocks() @@ -42,36 +245,34 @@ describe('DELETE /api/v2/credentials/[credentialId]', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ credential: { id: 'credential-1' } }) + mocks.remove.mockResolvedValue({ credential, deleted: true }) }) it('disconnects a credential through the application operation', async () => { const request = new NextRequest( - `http://localhost:3000/api/v2/credentials/credential-1?workspaceId=${WORKSPACE_ID}`, + `http://localhost:3000/api/v2/credentials/${CREDENTIAL_ID}?workspaceId=${WORKSPACE_ID}`, { method: 'DELETE' } ) - const response = await DELETE(request, { - params: Promise.resolve({ credentialId: 'credential-1' }), - }) + const response = await DELETE(request, context) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: { id: 'credential-1', deleted: true } }) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(await response.json()).toEqual({ data: { id: CREDENTIAL_ID, deleted: true } }) + expect(mocks.remove).toHaveBeenCalledWith({ principal: auth.principal, - input: { workspaceId: WORKSPACE_ID, credentialId: 'credential-1' }, + input: { workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }, request, }) }) it('requires the asserted workspace scope', async () => { const response = await DELETE( - new NextRequest('http://localhost:3000/api/v2/credentials/credential-1', { + new NextRequest(`http://localhost:3000/api/v2/credentials/${CREDENTIAL_ID}`, { method: 'DELETE', }), - { params: Promise.resolve({ credentialId: 'credential-1' }) } + context ) expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.remove).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts index 0baea423259..b0b6a05895e 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts @@ -1,18 +1,78 @@ -import { v2DeleteCredentialContract } from '@/lib/api/contracts/v2/credentials' +import { + v2DeleteCredentialContract, + v2UpdateCredentialContract, +} from '@/lib/api/contracts/v2/credentials' import { createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits, } from '@/lib/api/server/routes' +import { + CredentialProviderOperationError, + updateWorkspaceCredentialUseCase, +} from '@/lib/credentials/application/credential-crud' import { credentialOperations } from '@/lib/credentials/application/operations' +import { toV2Credential } from '@/lib/credentials/application/presentation' import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * Separates a provider rejecting the submitted secret from the provider being + * unreachable while it is verified. + * + * `CredentialProviderOperationError` extends `OrchestrationError('validation')`, + * so the default projection renders both as `400`. That tells a caller whose + * provider is merely down that its secret material is permanently wrong, which + * invites it to revoke a credential that is fine. `503` says the opposite, and + * `v2Error` attaches `Retry-After` from its status table. The outage message is + * deliberately generic — the underlying value is a provider transport failure, + * not anything the caller submitted — while a genuine rejection keeps the + * provider's own code in `details.providerErrorCode` so a client can map it. + */ +function renderCredentialProviderError(error: unknown) { + if (!(error instanceof CredentialProviderOperationError)) return null + return error.providerUnavailable + ? v2Error('SERVICE_UNAVAILABLE', 'Credential provider is temporarily unavailable') + : v2Error('BAD_REQUEST', error.message, { + details: { providerErrorCode: error.providerErrorCode }, + }) +} + const credentialErrorPolicy = createV2ResourceConcealmentPolicy({ notFoundMessage: 'Credential not found', + render: (error) => renderCredentialProviderError(error) ?? v2CaughtOrchestrationError(error), +}) + +/** + * PATCH /api/v2/credentials/[credentialId] — Rotate secret material or rename. + * + * The credential ID is preserved, so every workflow block, deployment, paused + * run, knowledge connector, and webhook already referencing it keeps working — + * which delete-and-recreate, the only rotation path before this route, does not. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.update, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialErrorPolicy, + mapInput: ({ params, query, body }) => ({ + ...body, + credentialId: params.credentialId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: updateWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + data: toV2Credential({ + ...credential, + hasServiceAccountKey: Boolean(credential.encryptedServiceAccountKey), + role: access.isAdmin ? 'admin' : 'member', + }), + }), }) export const DELETE = defineV2JsonRoute({ diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[customToolId]/route.test.ts similarity index 97% rename from apps/sim/app/api/v2/custom-tools/[id]/route.test.ts rename to apps/sim/app/api/v2/custom-tools/[customToolId]/route.test.ts index 11a5b6956c0..0ccd10859a0 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/[customToolId]/route.test.ts @@ -60,7 +60,7 @@ vi.mock('@/lib/custom-tools/application/use-cases', () => ({ }, })) -import { DELETE, GET, PATCH } from '@/app/api/v2/custom-tools/[id]/route' +import { DELETE, GET, PATCH } from '@/app/api/v2/custom-tools/[customToolId]/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } @@ -91,7 +91,7 @@ const tool = { createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-02T00:00:00Z'), } -const context = { params: Promise.resolve({ id: tool.id }) } +const context = { params: Promise.resolve({ customToolId: tool.id }) } /** * The read and delete verbs scope themselves with `?workspaceId=`; the write @@ -111,7 +111,7 @@ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { }) } -describe('/api/v2/custom-tools/[id]', () => { +describe('/api/v2/custom-tools/[customToolId]', () => { beforeEach(() => { vi.clearAllMocks() mocks.authenticate.mockResolvedValue(AUTH) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[customToolId]/route.ts similarity index 86% rename from apps/sim/app/api/v2/custom-tools/[id]/route.ts rename to apps/sim/app/api/v2/custom-tools/[customToolId]/route.ts index bd654423142..e6f5c12d399 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.ts +++ b/apps/sim/app/api/v2/custom-tools/[customToolId]/route.ts @@ -36,19 +36,22 @@ const customToolResourceErrorPolicy = createV2ResourceConcealmentPolicy({ : v2CaughtOrchestrationError(error), }) -/** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ +/** GET /api/v2/custom-tools/[customToolId] — Fetch a single custom tool. */ export const GET = defineV2JsonRoute({ contract: v2GetCustomToolContract, operation: customToolOperations.read, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: customToolResourceErrorPolicy, - mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, toolId: params.id }), + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + toolId: params.customToolId, + }), useCase: getWorkspaceCustomToolUseCase, present: ({ tool }) => ({ data: toV2CustomTool(tool) }), }) -/** PATCH /api/v2/custom-tools/[id] — Update a custom tool. */ +/** PATCH /api/v2/custom-tools/[customToolId] — Update a custom tool. */ export const PATCH = defineV2JsonRoute({ contract: v2UpdateCustomToolContract, operation: customToolOperations.update, @@ -57,14 +60,14 @@ export const PATCH = defineV2JsonRoute({ errorPolicy: customToolResourceErrorPolicy, mapInput: ({ params, body }) => ({ ...body, - toolId: params.id, + toolId: params.customToolId, source: 'api' as const, }), useCase: updateWorkspaceCustomToolUseCase, present: ({ tool }) => ({ data: toV2CustomTool(tool) }), }) -/** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */ +/** DELETE /api/v2/custom-tools/[customToolId] — Delete a custom tool. */ export const DELETE = defineV2JsonRoute({ contract: v2DeleteCustomToolContract, operation: customToolOperations.delete, @@ -73,7 +76,7 @@ export const DELETE = defineV2JsonRoute({ errorPolicy: customToolResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, - toolId: params.id, + toolId: params.customToolId, source: 'api' as const, }), useCase: deleteWorkspaceCustomToolUseCase, diff --git a/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts new file mode 100644 index 00000000000..7972d8812bd --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts @@ -0,0 +1,210 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + readText: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/read-workspace-file-text', () => ({ + readWorkspaceFileText: { + operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.readText, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_TEXT_EXTRACTION_BYTES } from '@/lib/uploads/utils/file-utils' +import { GET } from '@/app/api/v2/files/[fileId]/text/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const FILE_ID = 'wf_doc' +const context = { params: Promise.resolve({ fileId: FILE_ID }) } + +const AUTH = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function textRequest(query = `workspaceId=${WORKSPACE_ID}`) { + return new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/text?${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +function result(overrides: Record = {}) { + return { + file: { id: FILE_ID, name: 'notes.txt', type: 'text/plain' }, + text: 'hello there!', + truncated: false, + degraded: false, + degradedReason: null, + byteCount: 12, + ...overrides, + } +} + +describe('GET /api/v2/files/[fileId]/text', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.readText.mockResolvedValue(result()) + }) + + it('returns extracted text with its quality flags', async () => { + const response = await GET(textRequest(), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + fileId: FILE_ID, + name: 'notes.txt', + type: 'text/plain', + text: 'hello there!', + truncated: false, + degraded: false, + degradedReason: null, + charCount: 12, + byteCount: 12, + }, + }) + }) + + /** + * `degraded` must be present on every response, not only degraded ones: an + * omittable field lets a client that never checks it treat guessed text as + * extracted text, which is the whole hazard. + */ + it('always emits degraded, even on a clean extraction', async () => { + const response = await GET(textRequest(), context) + const body = await response.json() + + expect(body.data).toHaveProperty('degraded') + expect(typeof body.data.degraded).toBe('boolean') + }) + + it('carries a degraded extraction and its reason to the wire', async () => { + mocks.readText.mockResolvedValueOnce( + result({ + file: { id: FILE_ID, name: 'legacy.doc', type: 'application/msword' }, + text: 'Unable to extract text from DOC file.', + degraded: true, + degradedReason: 'Basic text extraction used. For better results, convert to DOCX format.', + }) + ) + + const response = await GET(textRequest(), context) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.degraded).toBe(true) + expect(body.data.degradedReason).toContain('Basic text extraction used') + }) + + it('reports parser truncation', async () => { + mocks.readText.mockResolvedValueOnce(result({ text: 'partial', truncated: true })) + + const body = await (await GET(textRequest(), context)).json() + + expect(body.data.truncated).toBe(true) + expect(body.data.charCount).toBe('partial'.length) + }) + + it('forwards a caller maxBytes to the use case', async () => { + await GET(textRequest(`workspaceId=${WORKSPACE_ID}&maxBytes=4096`), context) + + expect(mocks.readText).toHaveBeenCalledWith( + expect.objectContaining({ + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, maxBytes: 4096 }, + }) + ) + }) + + it('rejects a query with an undeclared key', async () => { + const response = await GET(textRequest(`workspaceId=${WORKSPACE_ID}&format=html`), context) + + expect(response.status).toBe(400) + expect(mocks.readText).not.toHaveBeenCalled() + }) + + it('rejects a maxBytes above the server ceiling and echoes the bound', async () => { + const response = await GET( + textRequest(`workspaceId=${WORKSPACE_ID}&maxBytes=${500 * 1024 * 1024}`), + context + ) + const body = await response.json() + + expect(response.status).toBe(400) + expect(JSON.stringify(body.error.details)).toContain( + `maxBytes cannot exceed ${MAX_TEXT_EXTRACTION_BYTES}` + ) + expect(mocks.readText).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(textRequest(), context) + + expect(response.status).toBe(401) + expect(mocks.readText).not.toHaveBeenCalled() + }) + + it('conceals a cross-tenant file as a missing file', async () => { + mocks.readText.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(textRequest(), context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'File not found' }, + }) + }) + + it('surfaces an unsupported type as 400 naming the download endpoint', async () => { + mocks.readText.mockRejectedValueOnce( + new OrchestrationError( + 'validation', + `Text extraction is not supported for "photo.heic"; download the raw bytes with GET /api/v2/files/${FILE_ID}` + ) + ) + + const response = await GET(textRequest(), context) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain(`GET /api/v2/files/${FILE_ID}`) + }) + + it('surfaces an oversized source as 413 without Retry-After', async () => { + mocks.readText.mockRejectedValueOnce( + new OrchestrationError('payload_too_large', 'above the 25 MB text-extraction limit') + ) + + const response = await GET(textRequest(), context) + + expect(response.status).toBe(413) + expect(response.headers.get('Retry-After')).toBeNull() + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/text/route.ts b/apps/sim/app/api/v2/files/[fileId]/text/route.ts new file mode 100644 index 00000000000..f3a2e543ef4 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/text/route.ts @@ -0,0 +1,49 @@ +import { v2ReadFileTextContract } from '@/lib/api/contracts/v2/files' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text' + +export const dynamic = 'force-dynamic' + +/** + * GET /api/v2/files/[fileId]/text — extract a file's text. + * + * Runs on the existing `files.read_content` operation: extracting text reads + * exactly the bytes that operation already authorizes. + * + * `degraded: true` means extraction did not fully succeed and the returned + * text may be incomplete or synthesized from raw bytes. The legacy `doc` and + * `ppt` parsers deliberately return best-effort content instead of throwing, + * so the flag — not an error — is how that is reported. + * + * Head-safe: no audit is projected and nothing is written. The read does pull + * bytes from object storage, but so does the metadata read beside it, and a + * bodiless `HEAD` would answer no useful question here. + */ +export const GET = defineV2JsonRoute({ + contract: v2ReadFileTextContract, + auth: v2ApiKeyAuth, + operation: fileOperations.readContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + maxBytes: query.maxBytes, + }), + useCase: readWorkspaceFileText, + present: ({ file, text, truncated, degraded, degradedReason, byteCount }) => ({ + data: { + fileId: file.id, + name: file.name, + type: file.type, + text, + truncated, + degraded, + degradedReason, + charCount: text.length, + byteCount, + }, + }), +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/unzip/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/unzip/route.test.ts new file mode 100644 index 00000000000..a4720645375 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/unzip/route.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + extract: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/extract-workspace-file', () => ({ + extractWorkspaceFile: { + operation: { id: 'files.extract_archive', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.extract, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/files/[fileId]/unzip/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const FILE_ID = 'wf_archive' +const context = { params: Promise.resolve({ fileId: FILE_ID }) } + +const AUTH = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function unzipRequest(body: Record = { workspaceId: WORKSPACE_ID }) { + return new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/unzip`, { + method: 'POST', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('POST /api/v2/files/[fileId]/unzip', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.extract.mockResolvedValue({ + folderName: 'archive', + folderDisplayPath: 'Engineering/archive', + extractedCount: 12, + skippedCount: 2, + }) + }) + + /** + * The widening itself: a workspace API key previously could not reach this + * operation at all, because it was declared `['session']` only. + */ + it('unarchives for a workspace API key', async () => { + const response = await POST(unzipRequest(), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { folderPath: '/Engineering/archive', extractedFileCount: 12, skippedFileCount: 2 }, + }) + }) + + it('unarchives for a personal API key', async () => { + v2RouteMocks.authenticate.mockResolvedValueOnce({ + ...AUTH, + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-2' }, + keyType: 'personal' as const, + }) + + const response = await POST(unzipRequest(), context) + + expect(response.status).toBe(200) + }) + + /** + * Counts and a path only. Returning the unpacked files would materialize a + * large archive's whole contents into one response body. + */ + it('does not return the unpacked files', async () => { + const response = await POST(unzipRequest(), context) + const body = await response.json() + + expect(body.data).not.toHaveProperty('files') + expect(Object.keys(body.data).sort()).toEqual([ + 'extractedFileCount', + 'folderPath', + 'skippedFileCount', + ]) + }) + + it('rejects a body with an undeclared key', async () => { + const response = await POST( + unzipRequest({ workspaceId: WORKSPACE_ID, destination: '/elsewhere' }), + context + ) + + expect(response.status).toBe(400) + expect(mocks.extract).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST(unzipRequest(), context) + + expect(response.status).toBe(401) + expect(mocks.extract).not.toHaveBeenCalled() + }) + + it('conceals a cross-tenant archive as a missing file', async () => { + mocks.extract.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await POST(unzipRequest(), context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'File not found' }, + }) + }) + + /** The zip-bomb and size guards must still surface, not be softened. */ + it('surfaces the archive size ceiling as 413', async () => { + mocks.extract.mockRejectedValueOnce( + new OrchestrationError('payload_too_large', 'Archive exceeds the 100 MB unzip limit') + ) + + const response = await POST(unzipRequest(), context) + + expect(response.status).toBe(413) + expect((await response.json()).error.message).toContain('unzip limit') + }) + + it('surfaces a concurrent unarchive as 409', async () => { + mocks.extract.mockRejectedValueOnce( + new OrchestrationError('conflict', 'This archive is already being unzipped') + ) + + const response = await POST(unzipRequest(), context) + + expect(response.status).toBe(409) + }) + + it('surfaces a non-archive file as 400', async () => { + mocks.extract.mockRejectedValueOnce( + new OrchestrationError('validation', 'Only .zip files can be unzipped') + ) + + const response = await POST(unzipRequest(), context) + + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/unzip/route.ts b/apps/sim/app/api/v2/files/[fileId]/unzip/route.ts new file mode 100644 index 00000000000..6defcf4adde --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/unzip/route.ts @@ -0,0 +1,50 @@ +import { v2UnzipFileContract } from '@/lib/api/contracts/v2/files' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { buildFolderPath } from '@/lib/folders/paths' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { extractWorkspaceFile } from '@/lib/workspace-files/application/extract-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' + +export const dynamic = 'force-dynamic' + +/** + * Matches the internal extract route. Unarchiving downloads the archive and + * writes every entry, and the use case's own budget is deliberately shorter + * than this so the work stops on its terms with rollback still able to run. + */ +export const maxDuration = 300 + +/** + * POST /api/v2/files/[fileId]/unzip — unzip an archive into a new folder + * beside it. + * + * Not `extract`: `GET /api/v2/files/[fileId]/text` is the endpoint that + * extracts a file's text, and the two cannot share a verb. + * + * Answers counts plus the destination `folderPath` rather than the unpacked + * files: a large archive would otherwise materialize thousands of file objects + * into one response. Page `GET /api/v2/files?folderPath=...` for the contents. + * + * Slow by nature — an archive near the size ceiling can run for minutes. + * Concurrent unarchiving of the same archive answers `409`. + */ +export const POST = defineV2JsonRoute({ + contract: v2UnzipFileContract, + auth: v2ApiKeyAuth, + operation: fileOperations.extractArchive, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + }), + useCase: extractWorkspaceFile, + present: (result) => ({ + data: { + folderPath: buildFolderPath(parseWorkspaceFileFolderDisplayPath(result.folderDisplayPath)), + extractedFileCount: result.extractedCount, + skippedFileCount: result.skippedCount, + }, + }), +}) diff --git a/apps/sim/app/api/v2/files/bulk-download/route.test.ts b/apps/sim/app/api/v2/files/bulk-download/route.test.ts new file mode 100644 index 00000000000..cda3a0b07ca --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-download/route.test.ts @@ -0,0 +1,259 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + download: vi.fn(), + authorizeDownload: vi.fn(), + downloadFileStream: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/download-workspace-file-items', () => ({ + downloadWorkspaceFileItems: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.download, + authorize: mocks.authorizeDownload, + }, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mocks.downloadFileStream, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { Readable } from 'node:stream' +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' +import { GET } from '@/app/api/v2/files/bulk-download/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const context = { params: Promise.resolve({}) } + +const AUTH = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function downloadRequest(query = `workspaceId=${WORKSPACE_ID}&fileIds=wf_a,wf_b`) { + return new NextRequest(`http://localhost:3000/api/v2/files/bulk-download?${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +function fileRecord(id: string, name: string) { + return { + id, + name, + key: `workspace/ws/${name}`, + size: 3, + type: 'text/plain', + folderId: null, + storageContext: 'workspace', + } +} + +describe('GET /api/v2/files/bulk-download', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.authorizeDownload.mockResolvedValue(undefined) + mocks.downloadFileStream.mockImplementation(async () => Readable.from([Buffer.from('abc')])) + mocks.download.mockResolvedValue({ + filesToZip: [fileRecord('wf_a', 'a.txt'), fileRecord('wf_b', 'b.txt')], + folderPaths: new Map(), + renderedDocuments: new Map(), + declaredBytes: 6, + }) + }) + + it('streams the selection as a zip', async () => { + const response = await GET(downloadRequest(), context) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/zip') + expect(response.headers.get('Content-Disposition')).toContain('workspace-files.zip') + expect((await response.arrayBuffer()).byteLength).toBeGreaterThan(0) + }) + + /** v2 addresses folders by path; internal folder ids never cross the boundary. */ + it('passes folder paths, never folder ids', async () => { + await GET( + downloadRequest(`workspaceId=${WORKSPACE_ID}&folderPaths=/Engineering,/Design`), + context + ) + + expect(mocks.download).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: WORKSPACE_ID, + fileIds: [], + folderIds: [], + folderPaths: ['/Engineering', '/Design'], + }, + }) + ) + }) + + it('splits a comma-separated selection', async () => { + await GET(downloadRequest(`workspaceId=${WORKSPACE_ID}&fileIds=wf_a,%20wf_b`), context) + + expect(mocks.download).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ fileIds: ['wf_a', 'wf_b'] }) }) + ) + }) + + /** + * v2 rejects a query parameter sent more than once, so the selection is + * comma-separated only; pinned here so the contract never advertises a + * repeated-parameter form the boundary would reject. + */ + it('rejects a repeated selection parameter', async () => { + const response = await GET( + downloadRequest(`workspaceId=${WORKSPACE_ID}&fileIds=wf_a&fileIds=wf_b`), + context + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('at most once') + expect(mocks.download).not.toHaveBeenCalled() + }) + + /** + * The contract's cap is the download's real ceiling, so an over-large + * selection is refused at the boundary rather than validating, resolving, and + * only then failing — and the message names the field and the limit. + */ + it('rejects a file selection above the download ceiling before it resolves', async () => { + const tooMany = Array.from({ length: MAX_ZIP_DOWNLOAD_FILES + 1 }, (_, i) => `wf_${i}`).join( + ',' + ) + + const response = await GET( + downloadRequest(`workspaceId=${WORKSPACE_ID}&fileIds=${tooMany}`), + context + ) + + expect(response.status).toBe(400) + const message = (await response.json()).error.message + expect(message).toContain('fileIds') + expect(message).toContain(String(MAX_ZIP_DOWNLOAD_FILES)) + expect(mocks.download).not.toHaveBeenCalled() + expect(mocks.authorizeDownload).not.toHaveBeenCalled() + }) + + it('accepts a file selection exactly at the download ceiling', async () => { + const atCap = Array.from({ length: MAX_ZIP_DOWNLOAD_FILES }, (_, i) => `wf_${i}`).join(',') + + const response = await GET( + downloadRequest(`workspaceId=${WORKSPACE_ID}&fileIds=${atCap}`), + context + ) + + expect(response.status).toBe(200) + expect(mocks.download).toHaveBeenCalledOnce() + }) + + it('rejects a folder selection above the download ceiling', async () => { + const tooMany = Array.from({ length: MAX_ZIP_DOWNLOAD_FILES + 1 }, (_, i) => `/f${i}`).join(',') + + const response = await GET( + downloadRequest(`workspaceId=${WORKSPACE_ID}&folderPaths=${tooMany}`), + context + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('folderPaths') + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('rejects a query with an undeclared key', async () => { + const response = await GET( + downloadRequest(`workspaceId=${WORKSPACE_ID}&folderIds=folder-1`), + context + ) + + expect(response.status).toBe(400) + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('surfaces an over-broad resolved selection as 400', async () => { + mocks.download.mockRejectedValueOnce( + new OrchestrationError('validation', 'Too many files selected for download.') + ) + + const response = await GET(downloadRequest(), context) + + expect(response.status).toBe(400) + }) + + it('surfaces an unresolvable folder path as 400', async () => { + mocks.download.mockRejectedValueOnce( + new OrchestrationError('validation', 'Folder not found: /Nope') + ) + + const response = await GET( + downloadRequest(`workspaceId=${WORKSPACE_ID}&folderPaths=/Nope`), + context + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('/Nope') + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(downloadRequest(), context) + + expect(response.status).toBe(401) + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('answers 403 for a cross-tenant workspace', async () => { + mocks.download.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(downloadRequest(), context) + + expect(response.status).toBe(403) + }) + + /** + * `headSafe: false`: a HEAD authorizes and answers bodiless without building + * the archive, so it records no audit event. + */ + it('answers an authorized HEAD bodiless without archiving', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files/bulk-download?workspaceId=${WORKSPACE_ID}&fileIds=wf_a`, + { method: 'HEAD' } + ), + context + ) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(mocks.download).not.toHaveBeenCalled() + expect(mocks.authorizeDownload).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/app/api/v2/files/bulk-download/route.ts b/apps/sim/app/api/v2/files/bulk-download/route.ts new file mode 100644 index 00000000000..06c7a9096af --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-download/route.ts @@ -0,0 +1,82 @@ +import { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { ZipArchive } from 'archiver' +import { v2BulkDownloadFilesContract } from '@/lib/api/contracts/v2/files' +import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' + +const logger = createLogger('V2FilesBulkDownloadAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Opens each object only as the archiver reaches it, so peak memory stays flat. */ +function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { + return Readable.from( + (async function* () { + yield* await downloadFileStream({ + key: file.key, + context: file.storageContext ?? 'workspace', + }) + })(), + { objectMode: false } + ) +} + +/** + * GET /api/v2/files/bulk-download — stream a selection of files as one zip. + * + * Folders are addressed by path, matching the rest of the v2 file surface, and + * expand to all their descendants. Selections are capped on input and again on + * the resolved file count and total bytes, so a broad selection is rejected + * rather than streamed forever. + * + * `headSafe: false` because the download records a `FILE_DOWNLOADED` audit + * event and pulls bytes out of object storage. + */ +export const GET = defineV2BinaryRoute({ + contract: v2BulkDownloadFilesContract, + auth: v2ApiKeyAuth, + headSafe: false, + operation: downloadWorkspaceFileItems.operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + fileIds: query.fileIds, + folderIds: [], + folderPaths: query.folderPaths, + }), + useCase: downloadWorkspaceFileItems, + present: ({ filesToZip, folderPaths, renderedDocuments }) => { + const entryPaths = buildZipEntryPaths( + filesToZip.map((file) => ({ + name: file.name, + folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + })) + ) + const archive = new ZipArchive({ store: true }) + archive.on('warning', (error: Error) => { + logger.warn('Archive warning while streaming workspace files', { error }) + }) + filesToZip.forEach((file, index) => { + archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), { + name: entryPaths[index], + }) + }) + archive.finalize().catch((error) => { + logger.error('Failed to finalize workspace file archive', { error }) + }) + + return { + body: nodeReadableToWebStream(archive), + contentType: 'application/zip', + contentDisposition: 'attachment; filename="workspace-files.zip"', + } + }, +}) diff --git a/apps/sim/app/api/v2/files/folders/restore/route.test.ts b/apps/sim/app/api/v2/files/folders/restore/route.test.ts new file mode 100644 index 00000000000..b1cdc1128a9 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/restore/route.test.ts @@ -0,0 +1,147 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + restore: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + restoreWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.restore, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/files/folders/restore/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +const AUTH = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function restoreRequest( + body: Record = { workspaceId: WORKSPACE_ID, path: '/Engineering/Archive' } +) { + return new NextRequest('http://localhost:3000/api/v2/files/folders/restore', { + method: 'POST', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('POST /api/v2/files/folders/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.restore.mockResolvedValue({ + folder: { + name: 'Archive', + path: 'Engineering/Archive', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + }, + restoredItems: { files: 7, folders: 2 }, + }) + }) + + it('restores an archived folder tree addressed by path', async () => { + const response = await POST(restoreRequest(), context()) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + folder: { + name: 'Archive', + path: '/Engineering/Archive', + parentPath: '/Engineering', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + restoredItems: { files: 7, folders: 2 }, + }, + }) + }) + + /** Path-addressed like the rest of the v2 folder family; ids stay internal. */ + it('forwards the path, never a folder id', async () => { + await POST(restoreRequest(), context()) + + expect(mocks.restore).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: WORKSPACE_ID, path: '/Engineering/Archive' }, + }) + ) + }) + + it('rejects restoring the workspace root', async () => { + const response = await POST(restoreRequest({ workspaceId: WORKSPACE_ID, path: '/' }), context()) + + expect(response.status).toBe(400) + expect(mocks.restore).not.toHaveBeenCalled() + }) + + it('rejects a body with an undeclared key', async () => { + const response = await POST( + restoreRequest({ workspaceId: WORKSPACE_ID, path: '/Engineering', folderId: 'folder-1' }), + context() + ) + + expect(response.status).toBe(400) + expect(mocks.restore).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST(restoreRequest(), context()) + + expect(response.status).toBe(401) + expect(mocks.restore).not.toHaveBeenCalled() + }) + + it('answers 404 for a path that is not archived', async () => { + mocks.restore.mockRejectedValueOnce(new OrchestrationError('not_found', 'Folder not found')) + + const response = await POST(restoreRequest(), context()) + + expect(response.status).toBe(404) + }) + + it('answers 403 for a cross-tenant workspace', async () => { + mocks.restore.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await POST(restoreRequest(), context()) + + expect(response.status).toBe(403) + }) +}) + +function context() { + return { params: Promise.resolve({}) } +} diff --git a/apps/sim/app/api/v2/files/folders/restore/route.ts b/apps/sim/app/api/v2/files/folders/restore/route.ts new file mode 100644 index 00000000000..39b20b78328 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/restore/route.ts @@ -0,0 +1,35 @@ +import { v2RestoreFileFolderContract } from '@/lib/api/contracts/v2/files' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { restoreWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' +import { toV2Folder } from '@/app/api/v2/files/folders/utils' +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/folders/restore — restore a soft-deleted folder tree. + * + * `DELETE /api/v2/files/folders` archives recursively, so without this a + * recursive delete was unrecoverable over the API: the archived files stayed + * visible through `GET /api/v2/files?scope=archived`, but nothing could put the + * folder structure back. + * + * Path-addressed, matching the rest of the v2 folder family. Find the path with + * `GET /api/v2/files/folders?scope=archived`. + */ +export const POST = defineV2JsonRoute({ + contract: v2RestoreFileFolderContract, + auth: v2ApiKeyAuth, + operation: fileOperations.restoreFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }), + useCase: restoreWorkspaceFileFolderOperation, + present: ({ folder, restoredItems }) => ({ + data: { + folder: toV2Folder(folder), + restoredItems: { files: restoredItems.files, folders: restoredItems.folders }, + }, + }), +}) diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts index 8c42e0ebd66..70efed92e89 100644 --- a/apps/sim/app/api/v2/files/folders/route.test.ts +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -126,6 +126,7 @@ describe('/api/v2/files/folders', () => { principal: PRINCIPAL, input: { workspaceId: WORKSPACE_ID, + scope: 'active', parentPath: undefined, search: undefined, sortBy: 'name', @@ -135,6 +136,38 @@ describe('/api/v2/files/folders', () => { }) }) + /** + * The archived set is how a caller finds a path to hand to the folder + * restore route; without it a recursive delete is unrecoverable over the API. + */ + it('lists the archived set when scope=archived', async () => { + mocks.listFolders.mockResolvedValueOnce({ folders: [] }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&scope=archived` + ), + { params: Promise.resolve({}) } + ) + + expect(response.status).toBe(200) + expect(mocks.listFolders).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ scope: 'archived' }) }) + ) + }) + + it('rejects an unknown scope', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&scope=everything` + ), + { params: Promise.resolve({}) } + ) + + expect(response.status).toBe(400) + expect(mocks.listFolders).not.toHaveBeenCalled() + }) + it('preserves an escaped slash within a folder name', async () => { mocks.listFolders.mockResolvedValueOnce({ folders: [{ ...folder, name: 'Finance/Legal', path: 'Finance\\/Legal' }], diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index 3cfc7222d4d..a0059d7a0bb 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -5,7 +5,6 @@ import { v2RelocateFileFolderContract, } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { buildFolderPath, parentFolderPath, parseFolderPath } from '@/lib/folders/paths' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { @@ -14,28 +13,10 @@ import { listWorkspaceFileFoldersOperation, updateWorkspaceFileFolderOperation, } from '@/lib/workspace-files/application/workspace-file-folders' -import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' - +import { toV2Folder } from '@/app/api/v2/files/folders/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -function toV2Folder(folder: { name: string; path: string; createdAt: Date; updatedAt: Date }) { - const segments = folder.path.startsWith('/') - ? parseFolderPath(folder.path) - : parseWorkspaceFileFolderDisplayPath(folder.path) - if (segments.at(-1) !== folder.name) { - throw new Error('Workspace file folder path does not match its folder name') - } - const path = buildFolderPath(segments) - return { - name: folder.name, - path, - parentPath: parentFolderPath(path), - createdAt: folder.createdAt.toISOString(), - updatedAt: folder.updatedAt.toISOString(), - } -} - export const GET = defineV2JsonRoute({ contract: v2ListFileFoldersContract, auth: v2ApiKeyAuth, @@ -44,6 +25,7 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2FileErrorPolicies.default, mapInput: ({ query }) => ({ workspaceId: query.workspaceId, + scope: query.scope, parentPath: query.parentPath, search: query.search, sortBy: query.sortBy, diff --git a/apps/sim/app/api/v2/files/folders/utils.ts b/apps/sim/app/api/v2/files/folders/utils.ts new file mode 100644 index 00000000000..760edbad57b --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/utils.ts @@ -0,0 +1,32 @@ +import { buildFolderPath, parentFolderPath, parseFolderPath } from '@/lib/folders/paths' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' + +/** + * Projects a stored folder onto the wire shape. + * + * Shared rather than copied per route: the second copy was written without the + * name/path invariant below, so the same row that made the list read fail loudly + * would have been served with a mismatched `name` and `path` from the restore + * read. One definition means one answer. + */ +export function toV2Folder(folder: { + name: string + path: string + createdAt: Date + updatedAt: Date +}) { + const segments = folder.path.startsWith('/') + ? parseFolderPath(folder.path) + : parseWorkspaceFileFolderDisplayPath(folder.path) + if (segments.at(-1) !== folder.name) { + throw new Error('Workspace file folder path does not match its folder name') + } + const path = buildFolderPath(segments) + return { + name: folder.name, + path, + parentPath: parentFolderPath(path), + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.test.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.test.ts index 193aeee9c65..2a5048bc014 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.test.ts @@ -15,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ abort: vi.fn(), + read: vi.fn(), })) vi.mock('@/lib/uploads/upload-session/application', () => ({ @@ -22,6 +23,10 @@ vi.mock('@/lib/uploads/upload-session/application', () => ({ operation: { id: 'files.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow' }, execute: mocks.abort, }, + readWorkspaceFileUploadOperation: { + operation: { id: 'files.upload.read', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.read, + }, })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -46,7 +51,7 @@ import { NoWorkspaceAccessError, WorkspaceApiKeyAuthorizationError, } from '@/lib/core/application' -import { DELETE } from '@/app/api/v2/files/uploads/[uploadId]/route' +import { DELETE, GET } from '@/app/api/v2/files/uploads/[uploadId]/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const UPLOAD_ID = 'upload-1' @@ -70,6 +75,99 @@ function abortRequest() { ) } +function readRequest(headers: Record = { 'upload-token': 'signed-token' }) { + return new NextRequest( + `http://localhost:3000/api/v2/files/uploads/${UPLOAD_ID}?workspaceId=${WORKSPACE_ID}`, + { headers: { 'x-api-key': 'secret', ...headers } } + ) +} + +describe('GET /api/v2/files/uploads/[uploadId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.read.mockResolvedValue({ id: UPLOAD_ID }) + }) + + it('reads the session through the shared use case', async () => { + const response = await GET(readRequest(), context) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ data: { id: UPLOAD_ID } }) + expect(mocks.abort).not.toHaveBeenCalled() + }) + + /** + * The read must never travel on the cancel operation: a caller allowed to ask + * about a session must not thereby be allowed to destroy it. + */ + it('runs on the read operation, not the cancel operation', async () => { + await GET(readRequest(), context) + + expect(v2RouteMocks.operationRate).toHaveBeenCalledWith( + expect.stringContaining('files.upload.read'), + expect.anything() + ) + expect(v2RouteMocks.operationRate).not.toHaveBeenCalledWith( + expect.stringContaining('files.upload.cancel'), + expect.anything() + ) + }) + + /** The GET is a control leg, so it carries the signed token like the others. */ + it('forwards the signed upload token to the use case', async () => { + await GET(readRequest(), context) + + expect(mocks.read).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + uploadId: UPLOAD_ID, + workspaceId: WORKSPACE_ID, + uploadToken: 'signed-token', + }, + }) + ) + }) + + it('rejects a read missing the signed upload token', async () => { + const response = await GET(readRequest({}), context) + + expect(response.status).toBe(400) + expect(mocks.read).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(readRequest(), context) + + expect(response.status).toBe(401) + expect(mocks.read).not.toHaveBeenCalled() + }) + + it('conceals a cross-tenant reach as a missing upload session', async () => { + mocks.read.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(readRequest(), context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Upload session not found' }, + }) + }) + + it('keeps a workspace-key policy denial as a 403', async () => { + mocks.read.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) + + const response = await GET(readRequest(), context) + + expect(response.status).toBe(403) + }) +}) + describe('DELETE /api/v2/files/uploads/[uploadId]', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index 2db69c408f6..54512fd9232 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -1,10 +1,36 @@ -import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' +import { v2AbortFileUploadContract, v2GetFileUploadContract } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { abortWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { + abortWorkspaceFileUploadOperation, + readWorkspaceFileUploadOperation, +} from '@/lib/uploads/upload-session/application' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +/** + * GET /api/v2/files/uploads/[uploadId] — read an upload session's state. + * + * Lets a caller that lost track of a transfer ask whether the session is still + * alive, already finalized, or failed, instead of only being able to abort it. + * Runs on its own `read` operation rather than reusing the cancel operation, so + * asking does not require permission to destroy. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetFileUploadContract, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadRead, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealUploadAuthorization, + mapInput: ({ params, query, headers }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: readWorkspaceFileUploadOperation, + present: async (session) => ({ data: await toV2FileUpload(session, null) }), +}) + export const DELETE = defineV2JsonRoute({ contract: v2AbortFileUploadContract, auth: v2ApiKeyAuth, diff --git a/apps/sim/app/api/v2/knowledge/[id]/tags/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/tags/route.test.ts deleted file mode 100644 index d6ab7343cc9..00000000000 --- a/apps/sim/app/api/v2/knowledge/[id]/tags/route.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @vitest-environment node - */ -import { - V2_OPERATION_RATE_LIMIT_ALLOWED, - V2_PREAUTH_RATE_LIMIT_ALLOWED, - v2ApiKeyAuthModuleMock, - v2GateModuleMock, - v2RateLimiterModuleMock, - v2RouteMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockListTags } = vi.hoisted(() => ({ - mockListTags: vi.fn(), -})) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) -vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) -vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) - -vi.mock('@/lib/knowledge/application/tags', () => ({ - listKnowledgeTags: { operation: { id: 'knowledge.tags.list' }, execute: mockListTags }, -})) - -import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { GET } from '@/app/api/v2/knowledge/[id]/tags/route' - -const WORKSPACE_ID = 'workspace-1' -const PRINCIPAL = { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' } as const - -function buildRequest(query = `?workspaceId=${WORKSPACE_ID}`) { - return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/tags${query}`, { - headers: { 'x-api-key': 'secret' }, - }) -} - -const context = { params: Promise.resolve({ id: 'kb-1' }) } - -describe('GET /api/v2/knowledge/[id]/tags', () => { - beforeEach(() => { - vi.clearAllMocks() - v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) - v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - v2RouteMocks.gate.mockResolvedValue(null) - v2RouteMocks.authenticate.mockResolvedValue({ - principal: PRINCIPAL, - rolloutUserId: 'billing-owner', - rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], - rateLimitSubscription: null, - keyType: 'workspace', - }) - mockListTags.mockResolvedValue({ - tagDefinitions: [ - { - id: 'tag-def-1', - knowledgeBaseId: 'kb-1', - tagSlot: 'tag1', - displayName: 'category', - fieldType: 'text', - createdAt: new Date('2025-01-10T09:00:00Z'), - updatedAt: new Date('2025-01-10T09:00:00Z'), - }, - ], - }) - }) - - it('returns the tag vocabulary as a full-set list', async () => { - const response = await GET(buildRequest(), context) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - data: [{ displayName: 'category', tagSlot: 'tag1', fieldType: 'text' }], - nextCursor: null, - }) - expect(mockListTags).toHaveBeenCalledWith( - expect.objectContaining({ - principal: PRINCIPAL, - input: { knowledgeBaseId: 'kb-1', assertedWorkspaceId: WORKSPACE_ID }, - }) - ) - expect(response.headers.get('cache-control')).toBe('private, no-store') - }) - - it('does not publish the tag definition identifier or its timestamps', async () => { - const response = await GET(buildRequest(), context) - - const [tag] = (await response.json()).data - expect(Object.keys(tag).sort()).toEqual(['displayName', 'fieldType', 'tagSlot']) - }) - - it('requires the workspace scope', async () => { - const response = await GET(buildRequest(''), context) - - expect(response.status).toBe(400) - expect(mockListTags).not.toHaveBeenCalled() - }) - - it('is reachable by a workspace API key, like its sibling knowledge reads', () => { - expect(knowledgeOperations.listTags.workspaceApiKey).toBe('allow') - expect(knowledgeOperations.listTags.principalKinds).toContain('workspace_api_key') - expect(knowledgeOperations.listTags.workspaceApiKey).toBe( - knowledgeOperations.listDocuments.workspaceApiKey - ) - }) -}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/tags/route.ts b/apps/sim/app/api/v2/knowledge/[id]/tags/route.ts deleted file mode 100644 index fadf77fe83a..00000000000 --- a/apps/sim/app/api/v2/knowledge/[id]/tags/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { v2ListKnowledgeTagsContract } from '@/lib/api/contracts/v2/knowledge' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' -import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { listKnowledgeTags } from '@/lib/knowledge/application/tags' - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** - * GET /api/v2/knowledge/[id]/tags — List the knowledge base's tag vocabulary. - * - * Full-set list: a knowledge base has a fixed number of tag slots, so the whole - * vocabulary is one page and `nextCursor` is always null. - */ -export const GET = defineV2JsonRoute({ - contract: v2ListKnowledgeTagsContract, - auth: v2ApiKeyAuth, - operation: knowledgeOperations.listTags, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, - mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, - assertedWorkspaceId: query.workspaceId, - }), - useCase: listKnowledgeTags, - present: ({ tagDefinitions }) => ({ - data: tagDefinitions.map((definition) => ({ - displayName: definition.displayName, - tagSlot: definition.tagSlot, - fieldType: definition.fieldType, - })), - nextCursor: null, - }), -}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents/route.ts similarity index 90% rename from apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents/route.ts index 35e06711f04..bc1a464c348 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents/route.ts @@ -22,7 +22,7 @@ function connectorDocumentCursorScope( ) { return cursorScopeKey( cursorRoute(v2ListKnowledgeConnectorDocumentsContract, { - id: knowledgeBaseId, + knowledgeBaseId, connectorId, }), { @@ -39,7 +39,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, connectorId: params.connectorId, assertedWorkspaceId: query.workspaceId, includeExcluded: query.includeExcluded, @@ -47,7 +47,7 @@ export const GET = defineV2JsonRoute({ offset: decodeOffsetCursor( query.cursor, CONNECTOR_DOCUMENT_SORT, - connectorDocumentCursorScope(params.id, params.connectorId, query) + connectorDocumentCursorScope(params.knowledgeBaseId, params.connectorId, query) ), }), useCase: listKnowledgeConnectorDocuments, @@ -56,7 +56,7 @@ export const GET = defineV2JsonRoute({ nextCursor: hasMore ? encodeOffsetCursor( CONNECTOR_DOCUMENT_SORT, - connectorDocumentCursorScope(params.id, params.connectorId, query), + connectorDocumentCursorScope(params.knowledgeBaseId, params.connectorId, query), offset + limit ) : null, @@ -70,7 +70,7 @@ export const PATCH = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, body }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, connectorId: params.connectorId, assertedWorkspaceId: body.workspaceId, operation: body.operation, diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route.ts similarity index 95% rename from apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route.ts index e4b1a835bae..7ccf05ea598 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route.ts @@ -27,7 +27,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, connectorId: params.connectorId, assertedWorkspaceId: query.workspaceId, }), @@ -42,7 +42,7 @@ export const PATCH = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, body }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, connectorId: params.connectorId, assertedWorkspaceId: body.workspaceId, updates: { @@ -63,7 +63,7 @@ export const DELETE = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, connectorId: params.connectorId, assertedWorkspaceId: query.workspaceId, deleteDocuments: query.deleteDocuments, diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/sync/route.ts similarity index 96% rename from apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/sync/route.ts index 3e8bf74cd66..a7c7e5c64a8 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/sync/route.ts @@ -12,7 +12,7 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, body }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, connectorId: params.connectorId, assertedWorkspaceId: body.workspaceId, rehydrate: body.rehydrate, diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.test.ts similarity index 94% rename from apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.test.ts index 685a477429a..187abea1511 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.test.ts @@ -74,17 +74,17 @@ vi.mock('@/lib/knowledge/application/connectors', () => ({ import { GET as listConnectorDocuments, PATCH as updateConnectorDocuments, -} from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route' +} from '@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents/route' import { DELETE as deleteConnector, GET as getConnector, PATCH as updateConnector, -} from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/route' -import { POST as syncConnector } from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route' +} from '@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route' +import { POST as syncConnector } from '@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/sync/route' import { POST as createConnector, GET as listConnectors, -} from '@/app/api/v2/knowledge/[id]/connectors/route' +} from '@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const KNOWLEDGE_BASE_ID = 'knowledge-1' @@ -97,9 +97,9 @@ const AUTH = { rateLimitSubscription: null, keyType: 'personal' as const, } -const collectionContext = { params: Promise.resolve({ id: KNOWLEDGE_BASE_ID }) } +const collectionContext = { params: Promise.resolve({ knowledgeBaseId: KNOWLEDGE_BASE_ID }) } const connectorContext = { - params: Promise.resolve({ id: KNOWLEDGE_BASE_ID, connectorId: CONNECTOR_ID }), + params: Promise.resolve({ knowledgeBaseId: KNOWLEDGE_BASE_ID, connectorId: CONNECTOR_ID }), } const connector = { id: CONNECTOR_ID, diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.ts similarity index 90% rename from apps/sim/app/api/v2/knowledge/[id]/connectors/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.ts index 43fb3e5aa70..8170b3b878f 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.ts @@ -18,7 +18,7 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 function connectorCursorScope(knowledgeBaseId: string, workspaceId: string) { - return cursorScopeKey(cursorRoute(v2ListKnowledgeConnectorsContract, { id: knowledgeBaseId }), { + return cursorScopeKey(cursorRoute(v2ListKnowledgeConnectorsContract, { knowledgeBaseId }), { workspaceId, }) } @@ -30,7 +30,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: query.workspaceId, sortBy: query.sortBy, sortOrder: query.sortOrder, @@ -38,7 +38,7 @@ export const GET = defineV2JsonRoute({ offset: decodeOffsetCursor( query.cursor, cursorSortKey(query.sortBy, query.sortOrder), - connectorCursorScope(params.id, query.workspaceId) + connectorCursorScope(params.knowledgeBaseId, query.workspaceId) ), }), useCase: listKnowledgeConnectors, @@ -47,7 +47,7 @@ export const GET = defineV2JsonRoute({ nextCursor: hasMore ? encodeOffsetCursor( cursorSortKey(query.sortBy, query.sortOrder), - connectorCursorScope(params.id, query.workspaceId), + connectorCursorScope(params.knowledgeBaseId, query.workspaceId), offset + limit ) : null, @@ -61,7 +61,7 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, body }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: body.workspaceId, connectorType: body.connectorType, credentialId: body.credentialId, diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route.test.ts new file mode 100644 index 00000000000..3224dae2156 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route.test.ts @@ -0,0 +1,166 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReadChunk, mockUpdateChunk, mockDeleteChunk } = vi.hoisted(() => ({ + mockReadChunk: vi.fn(), + mockUpdateChunk: vi.fn(), + mockDeleteChunk: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/knowledge/application/chunks', () => ({ + readKnowledgeChunk: { operation: { id: 'knowledge.chunks.read' }, execute: mockReadChunk }, + updateKnowledgeChunk: { operation: { id: 'knowledge.chunks.update' }, execute: mockUpdateChunk }, + deleteKnowledgeChunk: { operation: { id: 'knowledge.chunks.delete' }, execute: mockDeleteChunk }, +})) + +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { + DELETE, + GET, + PATCH, +} from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { + params: Promise.resolve({ knowledgeBaseId: 'kb-1', documentId: 'doc-1', chunkId: 'chunk-1' }), +} + +const CHUNK = { + id: 'chunk-1', + chunkIndex: 4, + content: 'Open Settings and choose Security.', + contentLength: 33, + tokenCount: 8, + enabled: false, + startOffset: 0, + endOffset: 33, + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +const URL_BASE = 'http://localhost/api/v2/knowledge/kb-1/documents/doc-1/chunks/chunk-1' + +function readRequest(method: 'GET' | 'DELETE', query = `?workspaceId=${WORKSPACE_ID}`) { + return new NextRequest(`${URL_BASE}${query}`, { method, headers: { 'x-api-key': 'secret' } }) +} + +function patchRequest(body: unknown) { + return new NextRequest(URL_BASE, { + method: 'PATCH', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockReadChunk.mockResolvedValue({ chunk: CHUNK }) + mockUpdateChunk.mockResolvedValue({ chunk: CHUNK }) + mockDeleteChunk.mockResolvedValue({ deleted: true }) +}) + +describe('GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]', () => { + it('resolves the chunk through its knowledge base and document', async () => { + const response = await GET(readRequest('GET'), context) + + expect(response.status).toBe(200) + expect((await response.json()).data.chunkIndex).toBe(4) + expect(mockReadChunk).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + knowledgeBaseId: 'kb-1', + documentId: 'doc-1', + chunkId: 'chunk-1', + assertedWorkspaceId: WORKSPACE_ID, + }, + }) + ) + }) + + it('requires the workspace scope', async () => { + const response = await GET(readRequest('GET', ''), context) + + expect(response.status).toBe(400) + expect(mockReadChunk).not.toHaveBeenCalled() + }) +}) + +describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]', () => { + it('updates the chunk and returns its current representation', async () => { + const response = await PATCH( + patchRequest({ workspaceId: WORKSPACE_ID, enabled: false }), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data.enabled).toBe(false) + }) + + it('rejects a body that names no field to change', async () => { + const response = await PATCH(patchRequest({ workspaceId: WORKSPACE_ID }), context) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('content') + expect(mockUpdateChunk).not.toHaveBeenCalled() + }) + + it('surfaces a connector-managed refusal with a machine-readable cause', async () => { + mockUpdateChunk.mockRejectedValue( + new ForbiddenOperationError( + 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', + 'Chunks from connector-synced documents are read-only' + ) + ) + + const response = await PATCH( + patchRequest({ workspaceId: WORKSPACE_ID, content: 'Corrected text' }), + context + ) + + expect(response.status).toBe(403) + expect((await response.json()).error.details).toEqual({ + code: 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', + }) + }) +}) + +describe('DELETE /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]', () => { + it('acknowledges the deletion with the chunk identifier', async () => { + const response = await DELETE(readRequest('DELETE'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'chunk-1', deleted: true } }) + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route.ts new file mode 100644 index 00000000000..633932eeeb4 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route.ts @@ -0,0 +1,82 @@ +import { + v2DeleteKnowledgeChunkContract, + v2GetKnowledgeChunkContract, + v2UpdateKnowledgeChunkContract, +} from '@/lib/api/contracts/v2/knowledge-chunks' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { + deleteKnowledgeChunk, + readKnowledgeChunk, + updateKnowledgeChunk, +} from '@/lib/knowledge/application/chunks' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { toV2KnowledgeChunk } from '@/app/api/v2/knowledge/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * The public surface supplies no secret provenance — see the sibling collection + * route for why an API caller cannot assert one. + */ +const noPublicContentProvenance = () => undefined + +/** GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId] — Read one chunk. */ +export const GET = defineV2JsonRoute({ + contract: v2GetKnowledgeChunkContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.readChunk, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeChunkAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + documentId: params.documentId, + chunkId: params.chunkId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readKnowledgeChunk, + present: ({ chunk }) => ({ data: toV2KnowledgeChunk(chunk) }), +}) + +/** + * PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId] — Edit a chunk. + * + * Changing `content` re-embeds the chunk and re-derives the document's token + * and character counts, so the correction is reflected in search immediately. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateKnowledgeChunkContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.updateChunk, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeChunkAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + documentId: params.documentId, + chunkId: params.chunkId, + assertedWorkspaceId: body.workspaceId, + content: body.content, + enabled: body.enabled, + resolveContentProvenance: noPublicContentProvenance, + }), + useCase: updateKnowledgeChunk, + present: ({ chunk }) => ({ data: toV2KnowledgeChunk(chunk) }), +}) + +/** DELETE /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId] — Remove a chunk. */ +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteKnowledgeChunkContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.deleteChunk, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeChunkAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + documentId: params.documentId, + chunkId: params.chunkId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: deleteKnowledgeChunk, + present: (_result, { params }) => ({ data: { id: params.chunkId, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts new file mode 100644 index 00000000000..e53acef6548 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts @@ -0,0 +1,345 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListChunks, mockCreateChunk, mockBulkChunks } = vi.hoisted(() => ({ + mockListChunks: vi.fn(), + mockCreateChunk: vi.fn(), + mockBulkChunks: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/knowledge/application/chunks', () => ({ + listKnowledgeChunks: { operation: { id: 'knowledge.chunks.list' }, execute: mockListChunks }, + createKnowledgeChunk: { operation: { id: 'knowledge.chunks.create' }, execute: mockCreateChunk }, + bulkUpdateKnowledgeChunks: { + operation: { id: 'knowledge.chunks.bulk' }, + execute: mockBulkChunks, + }, +})) + +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + GET, + PATCH, + POST, +} from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route' + +const WORKSPACE_ID = 'workspace-1' +const OTHER_WORKSPACE_ID = 'workspace-2' +const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1', documentId: 'doc-1' }) } +const siblingContext = { params: Promise.resolve({ knowledgeBaseId: 'kb-1', documentId: 'doc-2' }) } + +const CHUNK = { + id: 'chunk-1', + chunkIndex: 0, + content: 'Open Settings and choose Security.', + contentLength: 33, + tokenCount: 8, + enabled: true, + startOffset: 0, + endOffset: 33, + tag1: 'billing', + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +function listRequest(query = `?workspaceId=${WORKSPACE_ID}`) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/documents/doc-1/chunks${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +function bodyRequest(method: 'POST' | 'PATCH', body: unknown, documentId = 'doc-1') { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/documents/${documentId}/chunks`, { + method, + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockListChunks.mockResolvedValue({ chunks: [CHUNK], nextCursorKeys: null }) + mockCreateChunk.mockResolvedValue({ chunk: CHUNK }) + mockBulkChunks.mockResolvedValue({ + operation: 'disable', + successCount: 2, + errorCount: 0, + processed: 2, + errors: [], + }) +}) + +describe('GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', () => { + it('projects chunks with their tag slots and no cursor on the last page', async () => { + const response = await GET(listRequest(), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + ...CHUNK, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(response.headers.get('cache-control')).toBe('private, no-store') + }) + + it('mints a cursor bound to the sort when there is another page', async () => { + mockListChunks.mockResolvedValue({ chunks: [CHUNK], nextCursorKeys: [0, 'chunk-1'] }) + + const nextCursor = (await (await GET(listRequest(), context)).json()).nextCursor + expect(typeof nextCursor).toBe('string') + + const resumed = await GET( + listRequest(`?workspaceId=${WORKSPACE_ID}&cursor=${encodeURIComponent(nextCursor)}`), + context + ) + expect(resumed.status).toBe(200) + expect(mockListChunks).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ cursorKeys: [0, 'chunk-1'] }), + }) + ) + }) + + /** + * The cursor names a position in ONE document's chunk sequence. Replaying it + * against a sibling document would answer 200 from a sequence the caller + * never walked, which is the regression `CURSOR_BOUND_PATH_PARAMS` pins. + */ + it('refuses a cursor minted for a sibling document', async () => { + mockListChunks.mockResolvedValue({ chunks: [CHUNK], nextCursorKeys: [0, 'chunk-1'] }) + const nextCursor = (await (await GET(listRequest(), context)).json()).nextCursor + + const response = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge/kb-1/documents/doc-2/chunks?workspaceId=${WORKSPACE_ID}&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'secret' } } + ), + siblingContext + ) + + expect(response.status).toBe(400) + }) + + /** + * `workspaceId` is asserted scope, not a filter: the sequence is the one + * document the path names, and a workspace that does not own it is refused by + * authorization long before paging. Binding it into the cursor would refuse a + * page that did not move, which is the reading the structurally identical + * table-row lists already record in `list-pagination.test.ts`. + */ + it('resumes a cursor under a different asserted workspace, leaving that to authorization', async () => { + mockListChunks.mockResolvedValue({ chunks: [CHUNK], nextCursorKeys: [0, 'chunk-1'] }) + const nextCursor = (await (await GET(listRequest(), context)).json()).nextCursor + + const response = await GET( + listRequest(`?workspaceId=${OTHER_WORKSPACE_ID}&cursor=${encodeURIComponent(nextCursor)}`), + context + ) + + expect(response.status).toBe(200) + expect(mockListChunks).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + assertedWorkspaceId: OTHER_WORKSPACE_ID, + cursorKeys: [0, 'chunk-1'], + }), + }) + ) + }) + + it('refuses a cursor replayed under a different sort', async () => { + mockListChunks.mockResolvedValue({ chunks: [CHUNK], nextCursorKeys: [0, 'chunk-1'] }) + const nextCursor = (await (await GET(listRequest(), context)).json()).nextCursor + + const response = await GET( + listRequest( + `?workspaceId=${WORKSPACE_ID}&sortBy=tokenCount&cursor=${encodeURIComponent(nextCursor)}` + ), + context + ) + + expect(response.status).toBe(400) + }) + + it('rejects a fractional limit rather than passing it to the query', async () => { + const response = await GET(listRequest(`?workspaceId=${WORKSPACE_ID}&limit=1.5`), context) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('limit') + expect(mockListChunks).not.toHaveBeenCalled() + }) + + it('rejects an undeclared query key rather than dropping it', async () => { + const response = await GET(listRequest(`?workspaceId=${WORKSPACE_ID}&offset=10`), context) + + expect(response.status).toBe(400) + expect(mockListChunks).not.toHaveBeenCalled() + }) + + it('answers 409 while the document is still processing', async () => { + mockListChunks.mockRejectedValue(new KnowledgeDocumentNotReadyError('processing')) + + const response = await GET(listRequest(), context) + + expect(response.status).toBe(409) + const body = await response.json() + expect(body.error.code).toBe('CONFLICT') + expect(body.error.message).toContain('processing') + expect(response.headers.get('retry-after')).toBeNull() + }) + + it('conceals a knowledge base the caller cannot reach as not found', async () => { + const { NoWorkspaceAccessError } = await import('@/lib/core/application') + mockListChunks.mockRejectedValue(new NoWorkspaceAccessError()) + + const response = await GET(listRequest(), context) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Knowledge base not found') + }) +}) + +describe('POST /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', () => { + it('creates a chunk and answers 201', async () => { + const response = await POST( + bodyRequest('POST', { workspaceId: WORKSPACE_ID, content: 'Some text' }), + context + ) + + expect(response.status).toBe(201) + expect((await response.json()).data.id).toBe('chunk-1') + }) + + /** + * A provenance envelope is a trusted in-process trace. The public surface has + * none, and must not accept one from the wire. + */ + it('supplies no secret provenance to the use case', async () => { + await POST(bodyRequest('POST', { workspaceId: WORKSPACE_ID, content: 'Some text' }), context) + + const { input } = mockCreateChunk.mock.calls[0][0] + expect(input.resolveContentProvenance({ userId: 'user-1' })).toBeUndefined() + }) + + it('surfaces a connector-managed refusal with a machine-readable cause', async () => { + mockCreateChunk.mockRejectedValue( + new ForbiddenOperationError( + 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', + 'Chunks from connector-synced documents are read-only' + ) + ) + + const response = await POST( + bodyRequest('POST', { workspaceId: WORKSPACE_ID, content: 'Some text' }), + context + ) + + expect(response.status).toBe(403) + expect((await response.json()).error.details).toEqual({ + code: 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', + }) + }) + + it('rejects empty content at the contract', async () => { + const response = await POST( + bodyRequest('POST', { workspaceId: WORKSPACE_ID, content: '' }), + context + ) + + expect(response.status).toBe(400) + expect(mockCreateChunk).not.toHaveBeenCalled() + }) +}) + +describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', () => { + it('reports the processed count and per-chunk failures', async () => { + const response = await PATCH( + bodyRequest('PATCH', { + workspaceId: WORKSPACE_ID, + operation: 'disable', + chunkIds: ['chunk-1', 'chunk-2'], + }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { operation: 'disable', processed: 2, errors: [] }, + }) + }) + + /** + * The bound is enforced at the contract so the domain's own throw is + * unreachable from the wire — an over-long list is a 400 naming the cap + * rather than a classified failure from inside the use case. + */ + it('caps the identifier list before the use case runs', async () => { + const response = await PATCH( + bodyRequest('PATCH', { + workspaceId: WORKSPACE_ID, + operation: 'delete', + chunkIds: Array.from({ length: 101 }, (_, index) => `chunk-${index}`), + }), + context + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('100') + expect(mockBulkChunks).not.toHaveBeenCalled() + }) +}) + +describe('chunk operation policy', () => { + it('denies workspace API keys on every chunk operation', () => { + for (const operation of [ + knowledgeOperations.listChunks, + knowledgeOperations.readChunk, + knowledgeOperations.createChunk, + knowledgeOperations.updateChunk, + knowledgeOperations.deleteChunk, + knowledgeOperations.bulkChunks, + ]) { + expect(operation.workspaceApiKey).toBe('deny') + expect(operation.principalKinds).not.toContain('workspace_api_key') + } + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.ts new file mode 100644 index 00000000000..ec1d141be6b --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.ts @@ -0,0 +1,142 @@ +import { + v2BulkUpdateKnowledgeChunksContract, + v2CreateKnowledgeChunkContract, + v2ListKnowledgeChunksContract, +} from '@/lib/api/contracts/v2/knowledge-chunks' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { + bulkUpdateKnowledgeChunks, + createKnowledgeChunk, + listKnowledgeChunks, +} from '@/lib/knowledge/application/chunks' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { toV2KnowledgeChunk } from '@/app/api/v2/knowledge/utils' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * The public surface supplies no secret provenance. + * + * A provenance envelope is a trusted in-process trace of which resolved secrets + * a value was built from, minted by the executor and Copilot. An API caller has + * no such trace, and accepting one from the wire would let a caller assert + * provenance the server never observed. `undefined` means "none", which the + * domain treats as content carrying no secret material — distinct from the + * `unknown` status it refuses. + */ +const noPublicContentProvenance = () => undefined + +/** + * Every param that changes which chunks, in which order, this list returns. + * + * `workspaceId` is not one of them. The sequence is one document, named by the + * two path params the route already binds; the query's workspace is asserted + * scope, and any value but the owning workspace is refused by authorization + * before paging. That is the same reading the structurally identical table-row + * lists record, and it is declared alongside them in `list-pagination.test.ts`. + */ +function chunkCursorFilters( + knowledgeBaseId: string, + documentId: string, + query: { enabled: string; search?: string } +) { + return cursorScopeKey( + cursorRoute(v2ListKnowledgeChunksContract, { knowledgeBaseId, documentId }), + { + enabled: query.enabled, + search: query.search, + } + ) +} + +/** + * GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks — List a document's chunks. + * + * Keyset-paginated. Every sort ends in the chunk id, so a page boundary landing + * inside a run of equal `tokenCount` or `enabled` values cannot repeat or drop + * the tied rows. A document still processing answers 409, not an empty page. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListKnowledgeChunksContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.listChunks, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeChunkAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + documentId: params.documentId, + assertedWorkspaceId: query.workspaceId, + search: query.search, + enabled: query.enabled, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + chunkCursorFilters(params.knowledgeBaseId, params.documentId, query) + ), + }), + useCase: listKnowledgeChunks, + present: ({ chunks, nextCursorKeys }, { params, query }) => ({ + data: chunks.map(toV2KnowledgeChunk), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + chunkCursorFilters(params.knowledgeBaseId, params.documentId, query) + ), + }), +}) + +/** + * POST /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks — Append a chunk. + * + * The chunk is embedded before the response returns, so it is searchable + * immediately. Chunks on a connector-synced document are read-only. + */ +export const POST = defineV2JsonRoute({ + contract: v2CreateKnowledgeChunkContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.createChunk, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeChunkAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + documentId: params.documentId, + assertedWorkspaceId: body.workspaceId, + content: body.content, + enabled: body.enabled, + resolveContentProvenance: noPublicContentProvenance, + }), + useCase: createKnowledgeChunk, + present: ({ chunk }) => ({ data: toV2KnowledgeChunk(chunk) }), +}) + +/** + * PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks — Enable, disable, + * or delete many chunks at once. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2BulkUpdateKnowledgeChunksContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.bulkChunks, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeChunkAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + documentId: params.documentId, + assertedWorkspaceId: body.workspaceId, + operation: body.operation, + chunkIds: body.chunkIds, + }), + useCase: bulkUpdateKnowledgeChunks, + present: ({ operation, processed, errors }) => ({ + data: { operation, processed, errors }, + }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.test.ts similarity index 96% rename from apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.test.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.test.ts index 272d20c5f52..2b0c6f1684a 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.test.ts @@ -42,7 +42,7 @@ vi.mock('@/lib/knowledge/application/documents', () => ({ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCapture })) -import { GET, PATCH } from '@/app/api/v2/knowledge/[id]/documents/[documentId]/route' +import { GET, PATCH } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const @@ -95,7 +95,7 @@ const DOCUMENT_ROW = { tag6: 'orphaned-slot-value', } -const context = { params: Promise.resolve({ id: 'kb-1', documentId: 'doc-1' }) } +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1', documentId: 'doc-1' }) } function buildGetRequest() { return new NextRequest( @@ -112,7 +112,7 @@ function buildPatchRequest(body: unknown) { }) } -describe('/api/v2/knowledge/[id]/documents/[documentId]', () => { +describe('/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts similarity index 92% rename from apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts index 3d9d8543e08..1a9b32f4d7b 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts @@ -54,7 +54,7 @@ function toTagSlotUpdates(updates: V2DocumentUpdates): UpdateKnowledgeDocumentUp } } -/** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ +/** GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId] — Get document details. */ export const GET = defineV2JsonRoute({ contract: v2GetKnowledgeDocumentContract, auth: v2ApiKeyAuth, @@ -62,7 +62,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, documentId: params.documentId, assertedWorkspaceId: query.workspaceId, }), @@ -82,7 +82,7 @@ export const GET = defineV2JsonRoute({ }) /** - * PATCH /api/v2/knowledge/[id]/documents/[documentId] — Update a document. + * PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId] — Update a document. * * Renames, enables or disables, retags, or requeues processing. Derived * indexing state is not writable; the contract records why. @@ -100,7 +100,7 @@ export const PATCH = defineV2JsonRoute({ mapInput: ({ params, body }) => { const { workspaceId, retryProcessing, ...updates } = body return { - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, documentId: params.documentId, assertedWorkspaceId: workspaceId, ...(retryProcessing ? { retryProcessing } : { updates: toTagSlotUpdates(updates) }), @@ -121,7 +121,7 @@ export const PATCH = defineV2JsonRoute({ : { data: toV2TaggedDocument(result.document, result.tagDefinitions) }, }) -/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +/** DELETE /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId] — Delete a document. */ export const DELETE = defineV2JsonRoute({ contract: v2DeleteKnowledgeDocumentContract, auth: v2ApiKeyAuth, @@ -129,7 +129,7 @@ export const DELETE = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, documentId: params.documentId, assertedWorkspaceId: query.workspaceId, source: 'api', diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/collection.test.ts similarity index 96% rename from apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/collection.test.ts index 6f018f33d1b..81f35d9cfff 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/collection.test.ts @@ -46,7 +46,7 @@ vi.mock('@/lib/knowledge/application/documents', () => ({ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -import { GET, PATCH } from '@/app/api/v2/knowledge/[id]/documents/route' +import { GET, PATCH } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const @@ -80,7 +80,7 @@ const DOCUMENT = { tag2: null, } -const context = { params: Promise.resolve({ id: 'kb-1' }) } +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) } function buildListRequest(query: string) { return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/documents${query}`, { @@ -109,7 +109,7 @@ function authenticateAsPersonalKey() { }) } -describe('GET /api/v2/knowledge/[id]/documents', () => { +describe('GET /api/v2/knowledge/[knowledgeBaseId]/documents', () => { beforeEach(() => { vi.clearAllMocks() authenticateAsPersonalKey() @@ -206,7 +206,7 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { }) }) -describe('PATCH /api/v2/knowledge/[id]/documents', () => { +describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => { beforeEach(() => { vi.clearAllMocks() authenticateAsPersonalKey() diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route.test.ts new file mode 100644 index 00000000000..78c64879a0b --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAddWorkspaceFiles } = vi.hoisted(() => ({ + mockAddWorkspaceFiles: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/knowledge/application/add-workspace-files', () => ({ + addWorkspaceFilesToKnowledgeBase: { + operation: { id: 'knowledge.documents.add_workspace_files' }, + execute: mockAddWorkspaceFiles, + }, +})) + +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { POST } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) } + +function buildRequest(body: unknown) { + return new NextRequest('http://localhost/api/v2/knowledge/kb-1/documents/from-workspace-files', { + method: 'POST', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockAddWorkspaceFiles.mockResolvedValue({ + knowledgeBaseId: 'kb-1', + knowledgeBaseName: 'Docs', + added: [ + { documentId: 'doc-1', filename: 'handbook.pdf', mimeType: 'application/pdf', fileSize: 42 }, + ], + failed: ['missing.pdf'], + cancelled: false, + }) +}) + +describe('POST /api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files', () => { + /** + * A partial outcome is a 200 with a populated `failed` array. v2 has exactly + * two body shapes and a 207 multi-status is neither. + */ + it('reports partial success as a 200 rather than a multi-status', async () => { + const response = await POST( + buildRequest({ workspaceId: WORKSPACE_ID, fileReferences: ['handbook.pdf', 'missing.pdf'] }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + knowledgeBaseId: 'kb-1', + added: [ + { + documentId: 'doc-1', + filename: 'handbook.pdf', + mimeType: 'application/pdf', + fileSize: 42, + }, + ], + failed: ['missing.pdf'], + }, + }) + }) + + it('does not leak the knowledge base name the use case carries internally', async () => { + const response = await POST( + buildRequest({ workspaceId: WORKSPACE_ID, fileReferences: ['handbook.pdf'] }), + context + ) + + expect(Object.keys((await response.json()).data).sort()).toEqual([ + 'added', + 'failed', + 'knowledgeBaseId', + ]) + }) + + it('rejects an empty reference list', async () => { + const response = await POST( + buildRequest({ workspaceId: WORKSPACE_ID, fileReferences: [] }), + context + ) + + expect(response.status).toBe(400) + expect(mockAddWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('caps the reference list before the use case runs', async () => { + const response = await POST( + buildRequest({ + workspaceId: WORKSPACE_ID, + fileReferences: Array.from({ length: 101 }, (_, index) => `file-${index}.pdf`), + }), + context + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('100') + expect(mockAddWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('projects a usage limit as 402 rather than a generic failure', async () => { + mockAddWorkspaceFiles.mockRejectedValue( + new KnowledgeUsageLimitExceededError('Usage limit exceeded.') + ) + + const response = await POST( + buildRequest({ workspaceId: WORKSPACE_ID, fileReferences: ['handbook.pdf'] }), + context + ) + + expect(response.status).toBe(402) + expect((await response.json()).error.code).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('denies a workspace API key, as the operation policy declares', () => { + expect(knowledgeOperations.addWorkspaceFiles.workspaceApiKey).toBe('deny') + expect(knowledgeOperations.addWorkspaceFiles.principalKinds).not.toContain('workspace_api_key') + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route.ts new file mode 100644 index 00000000000..f4c48be1c4e --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route.ts @@ -0,0 +1,39 @@ +import { v2AddWorkspaceFilesToKnowledgeBaseContract } from '@/lib/api/contracts/v2/knowledge' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files — Index files the + * workspace already stores. + * + * Without it a file the server already holds has to be downloaded and re-uploaded + * byte-for-byte through `POST /api/v2/knowledge/{knowledgeBaseId}/documents` purely to be + * indexed. Each reference is authorized against the file's own canonical context. + * + * The use case's cancellation checkpoint is not wired here: the v2 builder maps + * input from the parsed request alone, and no signal reaches it, so the batch + * always runs to completion and `cancelled` never appears in the response. + * Partial outcomes are reported through `failed` instead. + */ +export const POST = defineV2JsonRoute({ + contract: v2AddWorkspaceFilesToKnowledgeBaseContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.addWorkspaceFiles, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUsageAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: body.workspaceId, + fileReferences: body.fileReferences, + source: 'api' as const, + }), + useCase: addWorkspaceFilesToKnowledgeBase, + present: ({ knowledgeBaseId, added, failed }) => ({ + data: { knowledgeBaseId, added, failed }, + }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.test.ts similarity index 85% rename from apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.test.ts index e85abb1cce8..ddd038540e8 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.test.ts @@ -76,7 +76,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' -import { GET, POST } from '@/app/api/v2/knowledge/[id]/documents/route' +import { GET, POST } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const @@ -88,7 +88,7 @@ function buildRequest() { ) } -describe('POST /api/v2/knowledge/[id]/documents', () => { +describe('POST /api/v2/knowledge/[knowledgeBaseId]/documents', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) @@ -133,7 +133,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { it('admits before buffering and reauthorizes durable registration with code-defined admission', async () => { const request = buildRequest() - const response = await POST(request, { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(request, { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) }) expect(response.status).toBe(201) expect(mockAdmitUpload.mock.invocationCallOrder[0]).toBeLessThan( @@ -183,7 +183,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { it('maps usage admission to the v2 error before multipart buffering', async () => { mockAdmitUpload.mockRejectedValue(new KnowledgeUsageLimitExceededError('Upgrade required')) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(402) expect(await response.json()).toEqual({ @@ -202,7 +204,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { keyType: 'workspace', }) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(201) expect(mockPlatformUploaded).toHaveBeenCalledOnce() @@ -212,7 +216,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { it('preserves the malformed multipart envelope without entering the upload operation', async () => { mockReadFormData.mockRejectedValueOnce(new Error('multipart boundary missing')) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(400) expect(await response.json()).toEqual({ @@ -231,7 +237,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { (candidate: unknown) => candidate === error ) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(400) expect(await response.json()).toEqual({ @@ -245,7 +253,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { mockReadFormData.mockRejectedValueOnce(error) mockIsPayloadSizeLimitError.mockImplementation((candidate: unknown) => candidate === error) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(413) expect(await response.json()).toEqual({ @@ -257,7 +267,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { it('requires a file form field before the upload operation', async () => { mockReadFormData.mockResolvedValueOnce(new FormData()) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(400) expect(await response.json()).toEqual({ @@ -273,7 +285,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { formData.set('file', file) mockReadFormData.mockResolvedValueOnce(formData) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(413) expect(await response.json()).toEqual({ @@ -290,7 +304,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { const expectedMessage = validateFileType('malware.exe', 'application/octet-stream')?.message if (!expectedMessage) throw new Error('Expected unsupported file type validation to fail') - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(415) expect(await response.json()).toEqual({ @@ -303,7 +319,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { it('does not emit effects when the upload operation fails', async () => { mockUploadDocument.mockRejectedValueOnce(new Error('storage unavailable')) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(500) expect(await response.json()).toEqual({ @@ -319,7 +337,9 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { new OrchestrationError('forbidden', 'Insufficient workspace permissions') ) - const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + const response = await POST(buildRequest(), { + params: Promise.resolve({ knowledgeBaseId: 'kb-1' }), + }) expect(response.status).toBe(403) expect(await response.json()).toEqual({ @@ -331,7 +351,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { }) }) -describe('GET /api/v2/knowledge/[id]/documents', () => { +describe('GET /api/v2/knowledge/[knowledgeBaseId]/documents', () => { const document = { id: 'doc-1', knowledgeBaseId: 'kb-1', @@ -354,7 +374,7 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { } function list(query: string) { - return GET(listRequest(query), { params: Promise.resolve({ id: 'kb-1' }) }) + return GET(listRequest(query), { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) }) } beforeEach(() => { @@ -431,6 +451,31 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { expect(mockListDocuments).toHaveBeenCalled() }) + /** + * `workspaceId` is asserted scope rather than a filter, so binding it into the + * fingerprint is redundant on the merits — the sequence is the one knowledge + * base the path names, and a workspace that does not own it is refused by + * authorization long before paging. + * + * It stays bound anyway, because this list shipped with it bound. Unbinding + * changes the fingerprint, and every cursor already in flight would be refused + * with a message telling the caller they changed a filter they never sent. The + * chunks list is new in the same change and starts out unbound, which is where + * the cleaner reading applies without a compatibility cost. + */ + it('refuses a cursor replayed under a different asserted workspace', async () => { + const minted = await list(`workspaceId=${WORKSPACE_ID}&limit=1&search=support`) + const { nextCursor } = await minted.json() + + mockListDocuments.mockClear() + const resumed = await list( + `workspaceId=workspace-2&limit=1&search=support&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(resumed.status).toBe(400) + expect(mockListDocuments).not.toHaveBeenCalled() + }) + it('resumes a cursor replayed under the filters it was minted with', async () => { const minted = await list(`workspaceId=${WORKSPACE_ID}&limit=1&search=support`) const { nextCursor } = await minted.json() diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts similarity index 86% rename from apps/sim/app/api/v2/knowledge/[id]/documents/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts index 436567fe845..e4950940d3d 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts @@ -53,13 +53,24 @@ const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE * filter with the stored definition's type and never reads the caller's, so * stating it or omitting it selects the same documents. A scope part the query * ignores refuses a cursor for a page that did not move. + * + * `workspaceId` is dropped for the same reason as on the sibling chunk list: + * the sequence is one knowledge base, named by the path param this already + * binds, and the query's workspace is asserted scope that authorization refuses + * before paging rather than a filter that can select a different sequence. */ function documentCursorFilters( knowledgeBaseId: string, query: { workspaceId: string; enabledFilter?: string; search?: string; tagFilters?: string } ) { const parsed = parseV2KnowledgeTagFiltersParam(query.tagFilters) - return cursorScopeKey(cursorRoute(v2ListKnowledgeDocumentsContract, { id: knowledgeBaseId }), { + return cursorScopeKey(cursorRoute(v2ListKnowledgeDocumentsContract, { knowledgeBaseId }), { + // Kept in the fingerprint despite being asserted scope rather than a filter. + // Dropping it is defensible in the abstract and free in effect — the value + // is constant for any one sequence — but it changes the fingerprint, so + // every cursor minted before the change is refused with a message telling + // the caller they altered a filter they never sent. A constant costs + // nothing to keep; a compatibility break to remove it buys nothing. workspaceId: query.workspaceId, enabledFilter: query.enabledFilter, search: query.search, @@ -69,7 +80,7 @@ function documentCursorFilters( }) } -/** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ +/** GET /api/v2/knowledge/[knowledgeBaseId]/documents — List documents in a knowledge base. */ export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeDocumentsContract, auth: v2ApiKeyAuth, @@ -82,7 +93,7 @@ export const GET = defineV2JsonRoute({ throw new OrchestrationError('validation', tagFilters.message) } return { - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: query.workspaceId, enabledFilter: query.enabledFilter, search: query.search, @@ -90,7 +101,7 @@ export const GET = defineV2JsonRoute({ offset: decodeOffsetCursor( query.cursor, cursorSortKey(query.sortBy, query.sortOrder), - documentCursorFilters(params.id, query) + documentCursorFilters(params.knowledgeBaseId, query) ), sortBy: query.sortBy, sortOrder: query.sortOrder, @@ -103,7 +114,7 @@ export const GET = defineV2JsonRoute({ nextCursor: pagination.hasMore ? encodeOffsetCursor( cursorSortKey(query.sortBy, query.sortOrder), - documentCursorFilters(params.id, query), + documentCursorFilters(params.knowledgeBaseId, query), pagination.offset + pagination.limit ) : null, @@ -111,7 +122,7 @@ export const GET = defineV2JsonRoute({ }) /** - * PATCH /api/v2/knowledge/[id]/documents — Enable or disable many documents. + * PATCH /api/v2/knowledge/[knowledgeBaseId]/documents — Enable or disable many documents. * * Enable and disable only. Bulk delete is deliberately not offered: the bulk * operation records no semantic audit, so a public bulk delete would empty a @@ -125,7 +136,7 @@ export const PATCH = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, body }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: body.workspaceId, operation: body.operation, documentIds: body.documentIds, @@ -156,7 +167,7 @@ export const PATCH = defineV2JsonRoute({ }, }) -/** POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. */ +/** POST /api/v2/knowledge/[knowledgeBaseId]/documents — Upload a document to a knowledge base. */ export const POST = defineV2BodyLifecycleRoute({ contract: v2UploadKnowledgeDocumentContract, auth: v2ApiKeyAuth, @@ -165,7 +176,7 @@ export const POST = defineV2BodyLifecycleRoute({ errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, admission: { mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: query.workspaceId, }), useCase: admitKnowledgeDocumentUpload, @@ -207,7 +218,7 @@ export const POST = defineV2BodyLifecycleRoute({ return { file: rawFile, buffer, contentType } }, mapInput: ({ parsed, body }) => ({ - knowledgeBaseId: parsed.params.id, + knowledgeBaseId: parsed.params.knowledgeBaseId, assertedWorkspaceId: parsed.query.workspaceId, file: { buffer: body.buffer, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.test.ts similarity index 95% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.test.ts index 96936470e88..24b4d46ad99 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.test.ts @@ -43,7 +43,7 @@ vi.mock('@/lib/core/telemetry', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ +vi.mock('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils', () => ({ toV2KnowledgeDocumentUpload: (_session: unknown, document: { id: string } | null) => ({ id: 'upload-1', knowledgeBaseId: 'kb-1', @@ -71,7 +71,7 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ }), })) -import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' +import { POST } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const DOCUMENT = { @@ -113,7 +113,7 @@ function request() { return { request, response: POST(request, { - params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }), + params: Promise.resolve({ knowledgeBaseId: 'kb-1', uploadId: 'upload-1' }), }), } } diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts similarity index 95% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts index 930e3ec1d73..c95c245fcbf 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts @@ -5,7 +5,7 @@ import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { completeKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { captureServerEvent } from '@/lib/posthog/server' -import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils' export const POST = defineV2JsonRoute({ contract: v2CompleteKnowledgeDocumentUploadContract, @@ -14,7 +14,7 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: query.workspaceId, uploadId: params.uploadId, uploadToken: headers['upload-token'], diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/parts/route.ts similarity index 95% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/parts/route.ts index 8c7121c2af2..acb6400fef9 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/parts/route.ts @@ -11,7 +11,7 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers, body }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: query.workspaceId, uploadId: params.uploadId, uploadToken: headers['upload-token'], diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/route.ts similarity index 92% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/route.ts index 710eae7b8e5..9fbb7346a40 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/route.ts @@ -3,7 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' -import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils' export const DELETE = defineV2JsonRoute({ contract: v2AbortKnowledgeDocumentUploadContract, @@ -12,7 +12,7 @@ export const DELETE = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: query.workspaceId, uploadId: params.uploadId, uploadToken: headers['upload-token'], diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/concealment.test.ts similarity index 90% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/concealment.test.ts index b16d4788c37..4e26774ccb6 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/concealment.test.ts @@ -59,16 +59,16 @@ import { InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, } from '@/lib/core/application' -import { POST as COMPLETE } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' -import { POST as PARTS } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route' -import { DELETE as CANCEL } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route' -import { POST as CREATE } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' +import { POST as COMPLETE } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route' +import { POST as PARTS } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/parts/route' +import { DELETE as CANCEL } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/route' +import { POST as CREATE } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const BASE = `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads` function context() { - return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } + return { params: Promise.resolve({ knowledgeBaseId: 'kb-1', uploadId: 'upload-1' }) } } function controlHeaders() { @@ -145,7 +145,7 @@ const routes = [ * `not_found` when the base is absent *or* lives in another workspace — before * workspace authorization runs. So an unconcealed 403 meant "this base exists in * a workspace you cannot reach" and a 404 meant "it does not exist", while - * `GET /api/v2/knowledge/{id}` answers 404 to both. + * `GET /api/v2/knowledge/{knowledgeBaseId}` answers 404 to both. */ describe('v2 knowledge upload resource concealment', () => { beforeEach(() => { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/control-routes.test.ts similarity index 91% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/control-routes.test.ts index 8a28f5c99c0..802c1a117d3 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/control-routes.test.ts @@ -46,7 +46,7 @@ vi.mock('@/lib/core/rate-limiter', () => ({ })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ +vi.mock('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils', () => ({ toV2KnowledgeDocumentUpload: () => ({ id: 'upload-1', knowledgeBaseId: 'kb-1', @@ -60,8 +60,8 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ }), })) -import { POST as PARTS } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route' -import { DELETE as CANCEL } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route' +import { POST as PARTS } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/parts/route' +import { DELETE as CANCEL } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const PRINCIPAL = { @@ -71,7 +71,7 @@ const PRINCIPAL = { } function context() { - return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } + return { params: Promise.resolve({ knowledgeBaseId: 'kb-1', uploadId: 'upload-1' }) } } function controlUrl(suffix = '') { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route.test.ts similarity index 93% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route.test.ts index b8ab41afce9..25735142280 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route.test.ts @@ -38,7 +38,7 @@ vi.mock('@/lib/core/rate-limiter', () => ({ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ +vi.mock('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils', () => ({ toV2KnowledgeDocumentUpload: (session: Record) => ({ id: session.id, knowledgeBaseId: session.knowledgeBaseId, @@ -52,7 +52,7 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ }), })) -import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' +import { POST } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const PRINCIPAL = { @@ -91,11 +91,11 @@ function request(body: Record) { }) return { request, - response: POST(request, { params: Promise.resolve({ id: 'kb-1' }) }), + response: POST(request, { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) }), } } -describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { +describe('POST /api/v2/knowledge/[knowledgeBaseId]/documents/uploads', () => { beforeEach(() => { vi.clearAllMocks() mocks.authenticateV2ApiKey.mockResolvedValue(AUTH) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route.ts similarity index 92% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route.ts index aaf70318147..27d9e84e5e9 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route.ts @@ -3,7 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { createKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' -import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils' export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeDocumentUploadContract, @@ -14,7 +14,7 @@ export const POST = defineV2JsonRoute({ mapInput: ({ params, body }) => { const { workspaceId, name, contentType, size, ...metadata } = body return { - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: workspaceId, name, contentType, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils.ts similarity index 100% rename from apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils.ts diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/restore/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/restore/route.test.ts new file mode 100644 index 00000000000..33e423d9081 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/restore/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRestore, mockGetUserEmails } = vi.hoisted(() => ({ + mockRestore: vi.fn(), + mockGetUserEmails: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ + restoreKnowledgeBase: { operation: { id: 'knowledge.restore' }, execute: mockRestore }, +})) + +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mockGetUserEmails, + requireResolvedUserEmail: (map: Map, userId: string) => { + const email = map.get(userId) + if (!email) throw new Error(`No email for ${userId}`) + return email + }, +})) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/knowledge/[knowledgeBaseId]/restore/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) } + +const RESTORED = { + id: 'kb-1', + userId: 'user-1', + name: 'Docs', + description: null, + tokenCount: 12, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-02-01T00:00:00Z'), + deletedAt: null, + workspaceId: WORKSPACE_ID, + folderId: null, + docCount: 3, + connectorTypes: [], +} + +function buildRequest(body: unknown = { workspaceId: WORKSPACE_ID }) { + return new NextRequest('http://localhost/api/v2/knowledge/kb-1/restore', { + method: 'POST', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockGetUserEmails.mockResolvedValue(new Map([['user-1', 'owner@example.com']])) + mockRestore.mockResolvedValue({ knowledgeBase: RESTORED, folderPath: '/', restored: true }) +}) + +describe('POST /api/v2/knowledge/[knowledgeBaseId]/restore', () => { + it('returns the knowledge base as it now stands', async () => { + const response = await POST(buildRequest(), context) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.id).toBe('kb-1') + expect(body.data.folderPath).toBe('/') + expect(mockRestore).toHaveBeenCalledWith( + expect.objectContaining({ + input: { knowledgeBaseId: 'kb-1', assertedWorkspaceId: WORKSPACE_ID, source: 'api' }, + }) + ) + }) + + /** + * Restoring twice must not read as a failure: the second call is the honest + * answer to "make this active", and a 409 would make a retry after a dropped + * response look like an error. + */ + it('answers 200 for a knowledge base that is already active', async () => { + mockRestore.mockResolvedValue({ knowledgeBase: RESTORED, folderPath: '/', restored: false }) + + const response = await POST(buildRequest(), context) + + expect(response.status).toBe(200) + expect((await response.json()).data.id).toBe('kb-1') + }) + + it('conceals a knowledge base in another tenant as not found', async () => { + mockRestore.mockRejectedValue(new NoWorkspaceAccessError()) + + const response = await POST(buildRequest(), context) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Knowledge base not found') + }) + + it('reports an archived workspace as a conflict', async () => { + mockRestore.mockRejectedValue( + new OrchestrationError('conflict', 'Cannot restore knowledge base into an archived workspace') + ) + + const response = await POST(buildRequest(), context) + + expect(response.status).toBe(409) + }) + + it('rejects an unknown body key rather than dropping it', async () => { + const response = await POST(buildRequest({ workspaceId: WORKSPACE_ID, force: true }), context) + + expect(response.status).toBe(400) + expect(mockRestore).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/restore/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/restore/route.ts new file mode 100644 index 00000000000..32c1a373d5b --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/restore/route.ts @@ -0,0 +1,34 @@ +import { v2RestoreKnowledgeBaseContract } from '@/lib/api/contracts/v2/knowledge' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { restoreKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { toV2KnowledgeBase } from '@/app/api/v2/knowledge/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/knowledge/[knowledgeBaseId]/restore — Recover a soft-deleted knowledge base. + * + * Idempotent: restoring one that is already active answers 200 with its current + * representation and records no audit entry, so a retry after a dropped + * response cannot read as a failure. Find restorable bases with + * `GET /api/v2/knowledge?scope=archived`. + */ +export const POST = defineV2JsonRoute({ + contract: v2RestoreKnowledgeBaseContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.restore, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: body.workspaceId, + source: 'api' as const, + }), + useCase: restoreKnowledgeBase, + present: async ({ knowledgeBase, folderPath }) => ({ + data: await toV2KnowledgeBase(knowledgeBase, folderPath), + }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/route.ts similarity index 86% rename from apps/sim/app/api/v2/knowledge/[id]/route.ts rename to apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/route.ts index 6e2fdf65ba3..78848097743 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/route.ts @@ -17,7 +17,7 @@ import { toV2KnowledgeBase } from '@/app/api/v2/knowledge/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/knowledge/[id] — Get knowledge base details. */ +/** GET /api/v2/knowledge/[knowledgeBaseId] — Get knowledge base details. */ export const GET = defineV2JsonRoute({ contract: v2GetKnowledgeBaseContract, auth: v2ApiKeyAuth, @@ -25,7 +25,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: query.workspaceId, }), useCase: readKnowledgeBase, @@ -34,7 +34,7 @@ export const GET = defineV2JsonRoute({ }), }) -/** PATCH /api/v2/knowledge/[id] — Partially update a knowledge base. */ +/** PATCH /api/v2/knowledge/[knowledgeBaseId] — Partially update a knowledge base. */ export const PATCH = defineV2JsonRoute({ contract: v2UpdateKnowledgeBaseContract, auth: v2ApiKeyAuth, @@ -42,7 +42,7 @@ export const PATCH = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, body }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: body.workspaceId, name: body.name, description: body.description, @@ -56,7 +56,7 @@ export const PATCH = defineV2JsonRoute({ }), }) -/** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ +/** DELETE /api/v2/knowledge/[knowledgeBaseId] — Delete a knowledge base. */ export const DELETE = defineV2JsonRoute({ contract: v2DeleteKnowledgeBaseContract, auth: v2ApiKeyAuth, @@ -64,7 +64,7 @@ export const DELETE = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ - knowledgeBaseId: params.id, + knowledgeBaseId: params.knowledgeBaseId, assertedWorkspaceId: query.workspaceId, source: 'api', }), diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route.test.ts new file mode 100644 index 00000000000..8ae54060e4a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUpdateTag, mockDeleteTag } = vi.hoisted(() => ({ + mockUpdateTag: vi.fn(), + mockDeleteTag: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/knowledge/application/tags', () => ({ + updateKnowledgeTag: { operation: { id: 'knowledge.tags.update' }, execute: mockUpdateTag }, + deleteKnowledgeTag: { operation: { id: 'knowledge.tags.delete' }, execute: mockDeleteTag }, +})) + +import { DELETE, PATCH } from '@/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1', tagId: 'tag-def-1' }) } +const URL_BASE = 'http://localhost/api/v2/knowledge/kb-1/tags/tag-def-1' + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockUpdateTag.mockResolvedValue({ + knowledgeBaseId: 'kb-1', + tagDefinition: { + id: 'tag-def-1', + tagSlot: 'tag1', + displayName: 'topic', + fieldType: 'text', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + }, + }) + mockDeleteTag.mockResolvedValue({ + tagDefinitionId: 'tag-def-1', + tagSlot: 'tag1', + displayName: 'category', + }) +}) + +describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]', () => { + /** + * `resolveActiveKnowledgeTagContext` only asserts the parent when the input + * names one, so a route that omits it answers 200 for a definition belonging + * to a sibling knowledge base. + */ + it('binds the definition to the knowledge base the path names', async () => { + const response = await PATCH( + new NextRequest(URL_BASE, { + method: 'PATCH', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, displayName: 'topic' }), + }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: 'tag-def-1', displayName: 'topic', tagSlot: 'tag1', fieldType: 'text' }, + }) + expect(mockUpdateTag).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + tagDefinitionId: 'tag-def-1', + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + updates: { displayName: 'topic', fieldType: undefined }, + source: 'api', + }, + }) + ) + }) + + it('rejects a body that names no field to change', async () => { + const response = await PATCH( + new NextRequest(URL_BASE, { + method: 'PATCH', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID }), + }), + context + ) + + expect(response.status).toBe(400) + expect(mockUpdateTag).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]', () => { + it('reports the freed slot alongside the deleted identifier', async () => { + const response = await DELETE( + new NextRequest(`${URL_BASE}?workspaceId=${WORKSPACE_ID}`, { + method: 'DELETE', + headers: { 'x-api-key': 'secret' }, + }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: 'tag-def-1', tagSlot: 'tag1', displayName: 'category', deleted: true }, + }) + expect(mockDeleteTag).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ knowledgeBaseId: 'kb-1' }), + }) + ) + }) + + it('requires the workspace scope', async () => { + const response = await DELETE( + new NextRequest(URL_BASE, { method: 'DELETE', headers: { 'x-api-key': 'secret' } }), + context + ) + + expect(response.status).toBe(400) + expect(mockDeleteTag).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route.ts new file mode 100644 index 00000000000..2d2cfe20187 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route.ts @@ -0,0 +1,66 @@ +import { + v2DeleteKnowledgeTagContract, + v2UpdateKnowledgeTagContract, +} from '@/lib/api/contracts/v2/knowledge-tags' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { deleteKnowledgeTag, updateKnowledgeTag } from '@/lib/knowledge/application/tags' +import { toV2KnowledgeTag } from '@/app/api/v2/knowledge/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * PATCH /api/v2/knowledge/[knowledgeBaseId]/tags/[tagId] — Rename a tag or change its type. + * + * `knowledgeBaseId` is passed so the definition is resolved through the base the + * path names; without it a definition belonging to a sibling knowledge base + * would answer from this path. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateKnowledgeTagContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.updateTag, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + tagDefinitionId: params.tagId, + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: body.workspaceId, + updates: { displayName: body.displayName, fieldType: body.fieldType }, + source: 'api' as const, + }), + useCase: updateKnowledgeTag, + present: ({ tagDefinition }) => ({ data: toV2KnowledgeTag(tagDefinition) }), +}) + +/** + * DELETE /api/v2/knowledge/[knowledgeBaseId]/tags/[tagId] — Remove a tag definition. + * + * The slot's values are cleared across every document and chunk in the + * knowledge base: without a definition the slot has no meaning, so leaving the + * values would strand them under a raw slot name. + */ +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteKnowledgeTagContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.deleteTag, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + tagDefinitionId: params.tagId, + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: query.workspaceId, + source: 'api' as const, + }), + useCase: deleteKnowledgeTag, + present: (deleted) => ({ + data: { + id: deleted.tagDefinitionId, + tagSlot: deleted.tagSlot, + displayName: deleted.displayName, + deleted: true as const, + }, + }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route.test.ts new file mode 100644 index 00000000000..67afb351a8b --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReadNextSlot } = vi.hoisted(() => ({ + mockReadNextSlot: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/knowledge/application/tags', () => ({ + readNextKnowledgeTagSlot: { + operation: { id: 'knowledge.tags.read_next_slot' }, + execute: mockReadNextSlot, + }, +})) + +import { GET } from '@/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) } + +function request(query: string) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/tags/next-slot${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockReadNextSlot.mockResolvedValue({ + nextAvailableSlot: 'tag2', + fieldType: 'text', + usedSlots: ['tag1'], + totalSlots: 7, + availableSlots: 6, + }) +}) + +describe('GET /api/v2/knowledge/[knowledgeBaseId]/tags/next-slot', () => { + it('reports the slot a create would take for the field type', async () => { + const response = await GET(request(`?workspaceId=${WORKSPACE_ID}&fieldType=text`), context) + + expect(response.status).toBe(200) + expect((await response.json()).data.nextAvailableSlot).toBe('tag2') + }) + + it('requires the field type the slots are counted for', async () => { + const response = await GET(request(`?workspaceId=${WORKSPACE_ID}`), context) + + expect(response.status).toBe(400) + expect(mockReadNextSlot).not.toHaveBeenCalled() + }) + + it('rejects a field type outside the supported set', async () => { + const response = await GET(request(`?workspaceId=${WORKSPACE_ID}&fieldType=uuid`), context) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('fieldType') + expect(mockReadNextSlot).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route.ts new file mode 100644 index 00000000000..94e45121f6b --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route.ts @@ -0,0 +1,30 @@ +import { v2GetNextKnowledgeTagSlotContract } from '@/lib/api/contracts/v2/knowledge-tags' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { readNextKnowledgeTagSlot } from '@/lib/knowledge/application/tags' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/knowledge/[knowledgeBaseId]/tags/next-slot — Slot availability for a field type. + * + * Lets a caller decide whether a create will succeed before attempting it, and + * which slot it would take. `POST /api/v2/knowledge/{knowledgeBaseId}/tags` assigns the same + * slot when `tagSlot` is omitted, so this is advisory rather than a claim. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetNextKnowledgeTagSlotContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.readNextTagSlot, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: query.workspaceId, + fieldType: query.fieldType, + }), + useCase: readNextKnowledgeTagSlot, + present: (data) => ({ data }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/route.test.ts new file mode 100644 index 00000000000..e0b11be0fc3 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/route.test.ts @@ -0,0 +1,406 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListTags, mockCreateTag, mockBulkSave, mockDeleteDefinitions } = vi.hoisted(() => ({ + mockListTags: vi.fn(), + mockCreateTag: vi.fn(), + mockBulkSave: vi.fn(), + mockDeleteDefinitions: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/knowledge/application/tags', () => ({ + listKnowledgeTags: { operation: { id: 'knowledge.tags.list' }, execute: mockListTags }, + createKnowledgeTag: { operation: { id: 'knowledge.tags.create' }, execute: mockCreateTag }, + saveKnowledgeDocumentTagDefinitions: { + operation: { id: 'knowledge.tags.bulk_save' }, + execute: mockBulkSave, + }, + deleteKnowledgeDocumentTagDefinitions: { + operation: { id: 'knowledge.tags.cleanup' }, + execute: mockDeleteDefinitions, + }, +})) + +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { DELETE, GET, POST, PUT } from '@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' } as const + +function buildRequest(query = `?workspaceId=${WORKSPACE_ID}`) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/tags${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +function buildCreateRequest(body: unknown) { + return new NextRequest('http://localhost/api/v2/knowledge/kb-1/tags', { + method: 'POST', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) } + +describe('GET /api/v2/knowledge/[knowledgeBaseId]/tags', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: PRINCIPAL, + rolloutUserId: 'billing-owner', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace', + }) + mockListTags.mockResolvedValue({ + tagDefinitions: [ + { + id: 'tag-def-1', + knowledgeBaseId: 'kb-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + createdAt: new Date('2025-01-10T09:00:00Z'), + updatedAt: new Date('2025-01-10T09:00:00Z'), + }, + ], + }) + }) + + it('returns the tag vocabulary as a full-set list', async () => { + const response = await GET(buildRequest(), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [{ id: 'tag-def-1', displayName: 'category', tagSlot: 'tag1', fieldType: 'text' }], + nextCursor: null, + }) + expect(mockListTags).toHaveBeenCalledWith( + expect.objectContaining({ + principal: PRINCIPAL, + input: { knowledgeBaseId: 'kb-1', assertedWorkspaceId: WORKSPACE_ID }, + }) + ) + expect(response.headers.get('cache-control')).toBe('private, no-store') + }) + + /** + * The identifier is published; the row's own timestamps and its redundant + * `knowledgeBaseId` are not. `PATCH` and `DELETE` address a definition by id, + * so withholding it left both unreachable. + */ + it('publishes the tag definition identifier but not its timestamps', async () => { + const response = await GET(buildRequest(), context) + + const [tag] = (await response.json()).data + expect(Object.keys(tag).sort()).toEqual(['displayName', 'fieldType', 'id', 'tagSlot']) + }) + + it('requires the workspace scope', async () => { + const response = await GET(buildRequest(''), context) + + expect(response.status).toBe(400) + expect(mockListTags).not.toHaveBeenCalled() + }) + + it('is reachable by a workspace API key, like its sibling knowledge reads', () => { + expect(knowledgeOperations.listTags.workspaceApiKey).toBe('allow') + expect(knowledgeOperations.listTags.principalKinds).toContain('workspace_api_key') + expect(knowledgeOperations.listTags.workspaceApiKey).toBe( + knowledgeOperations.listDocuments.workspaceApiKey + ) + }) +}) + +describe('POST /api/v2/knowledge/[knowledgeBaseId]/tags', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockCreateTag.mockResolvedValue({ + knowledgeBaseId: 'kb-1', + tagDefinition: { + id: 'tag-def-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + createdAt: new Date('2025-01-10T09:00:00Z'), + updatedAt: new Date('2025-01-10T09:00:00Z'), + }, + }) + }) + + it('creates a tag definition and answers 201 with its identifier', async () => { + const response = await POST( + buildCreateRequest({ workspaceId: WORKSPACE_ID, displayName: 'category' }), + context + ) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ + data: { id: 'tag-def-1', displayName: 'category', tagSlot: 'tag1', fieldType: 'text' }, + }) + expect(mockCreateTag).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + displayName: 'category', + fieldType: 'text', + tagSlot: undefined, + source: 'api', + }, + }) + ) + }) + + it('rejects a field type outside the supported set and names the valid ones', async () => { + const response = await POST( + buildCreateRequest({ workspaceId: WORKSPACE_ID, displayName: 'category', fieldType: 'uuid' }), + context + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('fieldType') + expect(mockCreateTag).not.toHaveBeenCalled() + }) + + it('rejects a slot that is not a real tag slot', async () => { + const response = await POST( + buildCreateRequest({ workspaceId: WORKSPACE_ID, displayName: 'category', tagSlot: 'tag99' }), + context + ) + + expect(response.status).toBe(400) + expect(mockCreateTag).not.toHaveBeenCalled() + }) + + it('rejects an unknown body key rather than dropping it', async () => { + const response = await POST( + buildCreateRequest({ workspaceId: WORKSPACE_ID, displayName: 'category', slot: 'tag1' }), + context + ) + + expect(response.status).toBe(400) + expect(mockCreateTag).not.toHaveBeenCalled() + }) + + /** + * Reads share the vocabulary with document filtering, which a workspace key + * may already perform; defining the vocabulary is a write and does not. + */ + it('denies a workspace API key, unlike the sibling read', () => { + expect(knowledgeOperations.createTag.workspaceApiKey).toBe('deny') + expect(knowledgeOperations.createTag.principalKinds).not.toContain('workspace_api_key') + }) +}) + +/** + * The vocabulary writes moved here from + * `/knowledge/{knowledgeBaseId}/documents/{documentId}/tags`, which named a document neither + * of them ever read: both write `knowledge_base_tag_definitions`, keyed by + * knowledge base and slot. + */ +describe('PUT /api/v2/knowledge/[knowledgeBaseId]/tags', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockBulkSave.mockResolvedValue({ + created: [ + { + id: 'tag-def-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + createdAt: new Date('2025-01-10T09:00:00Z'), + updatedAt: new Date('2025-01-10T09:00:00Z'), + }, + ], + updated: [], + errors: [], + }) + }) + + function buildPutRequest(body: unknown) { + return new NextRequest('http://localhost/api/v2/knowledge/kb-1/tags', { + method: 'PUT', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('upserts definitions against the knowledge base, with no document in the input', async () => { + const response = await PUT( + buildPutRequest({ + workspaceId: WORKSPACE_ID, + definitions: [{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }], + }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + created: [{ id: 'tag-def-1', displayName: 'category', tagSlot: 'tag1', fieldType: 'text' }], + updated: [], + errors: [], + }, + }) + expect(mockBulkSave).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + definitions: [{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }], + }, + }) + ) + }) + + it('rejects an empty definition list rather than writing nothing', async () => { + const response = await PUT( + buildPutRequest({ workspaceId: WORKSPACE_ID, definitions: [] }), + context + ) + + expect(response.status).toBe(400) + expect(mockBulkSave).not.toHaveBeenCalled() + }) + + it('rejects a query param on a body-only write', async () => { + const request = new NextRequest( + 'http://localhost/api/v2/knowledge/kb-1/tags?documentId=doc-1', + { + method: 'PUT', + headers: { 'x-api-key': 'secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + definitions: [{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }], + }), + } + ) + + expect((await PUT(request, context)).status).toBe(400) + expect(mockBulkSave).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v2/knowledge/[knowledgeBaseId]/tags', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockDeleteDefinitions.mockResolvedValue({ action: 'cleanup', count: 2 }) + }) + + function buildDeleteRequest(query: string) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/tags${query}`, { + method: 'DELETE', + headers: { 'x-api-key': 'secret' }, + }) + } + + it('removes only the unused definitions by default', async () => { + const response = await DELETE(buildDeleteRequest(`?workspaceId=${WORKSPACE_ID}`), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { unused: true, count: 2 } }) + expect(mockDeleteDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + action: 'cleanup', + }, + }) + ) + }) + + it('deletes the whole vocabulary only when asked in so many words', async () => { + mockDeleteDefinitions.mockResolvedValue({ action: 'all', count: 7 }) + + const response = await DELETE( + buildDeleteRequest(`?workspaceId=${WORKSPACE_ID}&unused=false`), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { unused: false, count: 7 } }) + expect(mockDeleteDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ action: 'all' }) }) + ) + }) + + /** + * `unused` is a real boolean via `booleanQueryFlagSchema`, not a string enum, + * so an unrecognised spelling is a 400 rather than a silent truthy read that + * would delete the wrong half of the vocabulary. + */ + it('rejects a spelling of unused it does not accept', async () => { + const response = await DELETE( + buildDeleteRequest(`?workspaceId=${WORKSPACE_ID}&unused=maybe`), + context + ) + + expect(response.status).toBe(400) + expect(mockDeleteDefinitions).not.toHaveBeenCalled() + }) + + it('requires the workspace scope', async () => { + expect((await DELETE(buildDeleteRequest(''), context)).status).toBe(400) + expect(mockDeleteDefinitions).not.toHaveBeenCalled() + }) + + /** + * Both vocabulary writes report themselves against the knowledge base, which + * is what they act on — the ids no longer name a document. + */ + it('binds the knowledge-base-scoped semantic operations', () => { + expect(knowledgeOperations.saveDocumentTagDefinitions.id).toBe('knowledge.tags.bulk_save') + expect(knowledgeOperations.deleteDocumentTagDefinitions.id).toBe('knowledge.tags.cleanup') + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/route.ts new file mode 100644 index 00000000000..7c63b5f1332 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/route.ts @@ -0,0 +1,127 @@ +import { v2ListKnowledgeTagsContract } from '@/lib/api/contracts/v2/knowledge' +import { + v2BulkSaveKnowledgeTagDefinitionsContract, + v2CreateKnowledgeTagContract, + v2DeleteKnowledgeTagDefinitionsContract, +} from '@/lib/api/contracts/v2/knowledge-tags' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + createKnowledgeTag, + deleteKnowledgeDocumentTagDefinitions, + listKnowledgeTags, + saveKnowledgeDocumentTagDefinitions, +} from '@/lib/knowledge/application/tags' +import { toV2KnowledgeTag } from '@/app/api/v2/knowledge/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/knowledge/[knowledgeBaseId]/tags — List the knowledge base's tag vocabulary. + * + * Full-set list: a knowledge base has a fixed number of tag slots, so the whole + * vocabulary is one page and `nextCursor` is always null. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListKnowledgeTagsContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.listTags, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: listKnowledgeTags, + present: ({ tagDefinitions }) => ({ + data: tagDefinitions.map(toV2KnowledgeTag), + nextCursor: null, + }), +}) + +/** + * POST /api/v2/knowledge/[knowledgeBaseId]/tags — Define a tag on the knowledge base. + * + * Omitting `tagSlot` takes the next free slot for the field type; exhausting + * the type's slots is a 400 naming it, because the remedy is a different field + * type or a deleted definition rather than a retry. + */ +export const POST = defineV2JsonRoute({ + contract: v2CreateKnowledgeTagContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.createTag, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: body.workspaceId, + displayName: body.displayName, + fieldType: body.fieldType, + tagSlot: body.tagSlot, + source: 'api' as const, + }), + useCase: createKnowledgeTag, + present: ({ tagDefinition }) => ({ data: toV2KnowledgeTag(tagDefinition) }), +}) + +/** + * PUT /api/v2/knowledge/[knowledgeBaseId]/tags — Upsert the tag vocabulary in bulk. + * + * The knowledge-base counterpart of `POST`, which defines exactly one tag. Every + * slot the body names is written to the declaration it carries; slots it does + * not name are untouched. + * + * This write used to sit at `PUT /knowledge/{knowledgeBaseId}/documents/{documentId}/tags`, + * where the document id was read only to find the knowledge base behind it. Tag + * *values* on one document are still written by + * `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}` through its tag slots. + */ +export const PUT = defineV2JsonRoute({ + contract: v2BulkSaveKnowledgeTagDefinitionsContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.saveDocumentTagDefinitions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: body.workspaceId, + definitions: body.definitions, + }), + useCase: saveKnowledgeDocumentTagDefinitions, + present: ({ created, updated, errors }) => ({ + data: { + created: created.map(toV2KnowledgeTag), + updated: updated.map(toV2KnowledgeTag), + errors, + }, + }), +}) + +/** + * DELETE /api/v2/knowledge/[knowledgeBaseId]/tags — Remove tag definitions from the base. + * + * `unused` defaults to `true`, removing only definitions no document still + * carries a value for. `unused=false` deletes the whole vocabulary and clears + * every slot it defined, so it has to be asked for. + */ +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteKnowledgeTagDefinitionsContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.deleteDocumentTagDefinitions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: query.workspaceId, + action: query.unused ? ('cleanup' as const) : ('all' as const), + }), + useCase: deleteKnowledgeDocumentTagDefinitions, + /** + * `unused` is read back from the parsed request rather than re-derived from + * the domain's `action`, so the two spellings cannot drift: the route decides + * the branch and reports the same decision it made. + */ + present: ({ count }, { query }) => ({ data: { unused: query.unused, count } }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route.test.ts new file mode 100644 index 00000000000..e71791fc731 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReadUsage } = vi.hoisted(() => ({ + mockReadUsage: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/knowledge/application/tags', () => ({ + readKnowledgeTagUsage: { + operation: { id: 'knowledge.tags.read_usage' }, + execute: mockReadUsage, + }, +})) + +import { GET } from '@/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ knowledgeBaseId: 'kb-1' }) } + +function request(query: string) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/tags/usage${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockReadUsage.mockResolvedValue({ + usage: [ + { + id: 'tag-def-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + documentCount: 4, + chunkCount: 40, + }, + ], + }) +}) + +describe('GET /api/v2/knowledge/[knowledgeBaseId]/tags/usage', () => { + it('returns every defined tag as one bounded page', async () => { + const response = await GET(request(`?workspaceId=${WORKSPACE_ID}`), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: 'tag-def-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + documentCount: 4, + chunkCount: 40, + }, + ], + nextCursor: null, + }) + }) + + /** + * Without the definition id a usage row is a dead end: acting on it — renaming + * or deleting the tag — needs `PATCH`/`DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}`, + * which would otherwise cost a second read of the vocabulary and a join on the + * slot to recover. + */ + it('publishes the definition id each usage row belongs to', async () => { + const response = await GET(request(`?workspaceId=${WORKSPACE_ID}`), context) + + expect((await response.json()).data[0].id).toBe('tag-def-1') + }) + + it('requires the workspace scope', async () => { + const response = await GET(request(''), context) + + expect(response.status).toBe(400) + expect(mockReadUsage).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route.ts new file mode 100644 index 00000000000..60cb59b2889 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route.ts @@ -0,0 +1,28 @@ +import { v2ListKnowledgeTagUsageContract } from '@/lib/api/contracts/v2/knowledge-tags' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { readKnowledgeTagUsage } from '@/lib/knowledge/application/tags' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/knowledge/[knowledgeBaseId]/tags/usage — How widely each tag is populated. + * + * Full-set list, for the same reason the vocabulary is: one row per definition, + * and the fixed slot table bounds how many definitions can exist. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListKnowledgeTagUsageContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.readTagUsage, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.knowledgeBaseId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readKnowledgeTagUsage, + present: ({ usage }) => ({ data: usage, nextCursor: null }), +}) diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts index 8e2abe97bcb..b6e87c12986 100644 --- a/apps/sim/app/api/v2/knowledge/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -59,9 +59,11 @@ vi.mock('@/lib/users/queries', () => ({ requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { v2ListKnowledgeBasesContract } from '@/lib/api/contracts/v2/knowledge' import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' -import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { cursorRoute, cursorScopeKey, REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET, POST } from '@/app/api/v2/knowledge/route' +import { writeSortedCursor } from '@/app/api/v2/lib/response' const WORKSPACE_ID = 'workspace-1' const RATE_LIMIT_OK = { @@ -126,6 +128,7 @@ describe('/api/v2/knowledge route composition', () => { principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, input: { workspaceId: WORKSPACE_ID, + scope: 'active', folderPath: '/', search: 'support', sortBy: 'name', @@ -149,11 +152,129 @@ describe('/api/v2/knowledge route composition', () => { }) }) + /** + * The archived set is this list under `scope=archived`, not a sibling path: + * one semantic operation over the same rows with a different `deleted_at` + * predicate, matching files, tables, and workflows. + */ + it('lists the archived set through the same operation and reports when each was archived', async () => { + mockList.mockResolvedValue({ + knowledgeBases: [ + { + knowledgeBase: { + ...buildKnowledgeBase(), + deletedAt: new Date('2024-02-02T00:00:00Z'), + }, + folderPath: '/', + }, + ], + nextCursorKeys: null, + sortBy: 'createdAt', + sortOrder: 'asc', + }) + + const response = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&scope=archived`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(response.status).toBe(200) + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ scope: 'archived' }) }) + ) + const [item] = (await response.json()).data + expect(item.deletedAt).toBe('2024-02-02T00:00:00.000Z') + expect(item.folderPath).toBe('/') + }) + + it('reports a null archive instant for an active knowledge base', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}`, { + headers: { 'x-api-key': 'secret' }, + }) + ) + + expect((await response.json()).data[0].deletedAt).toBeNull() + }) + + it('rejects a scope outside the published set', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&scope=all`, { + headers: { 'x-api-key': 'secret' }, + }) + ) + + expect(response.status).toBe(400) + expect(mockList).not.toHaveBeenCalled() + }) + /** * Pins the binding end-to-end — the mint in `present` and the read in * `mapInput` — because the contract-level sweep only checks a hand-maintained * map of param names and stays green when a route drops the stamp entirely. */ + it('refuses a cursor minted under one scope and replayed under the other', async () => { + mockList.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }], + nextCursorKeys: ['Support docs', 'kb-1'], + sortBy: 'name', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest(`http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}`, { + headers: { 'x-api-key': 'secret' }, + }) + ) + const { nextCursor } = await minted.json() + + mockList.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&scope=archived&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mockList).not.toHaveBeenCalled() + }) + + /** + * `scope` carries `.default('active')`, so it is present on every parsed + * query — and it is new on this list. Stamping it unconditionally would put a + * constant in every fingerprint and refuse every cursor the deployed build + * handed out, reporting {@link REFILTERED_CURSOR_MESSAGE} to a caller that + * changed nothing. The default must contribute nothing to the scope. + */ + it('resumes a cursor minted before scope entered the binding', async () => { + mockList.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }], + nextCursorKeys: undefined, + sortBy: 'name', + sortOrder: 'desc', + }) + const legacyCursor = writeSortedCursor( + ['Support docs', 'kb-1'], + 'name', + 'desc', + cursorScopeKey(cursorRoute(v2ListKnowledgeBasesContract), { workspaceId: WORKSPACE_ID }) + ) as string + + const response = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&sortBy=name&sortOrder=desc&cursor=${encodeURIComponent(legacyCursor)}`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(response.status).toBe(200) + expect(mockList).toHaveBeenCalled() + }) + it('refuses a cursor minted under a different filter', async () => { mockList.mockResolvedValue({ knowledgeBases: [{ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }], diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 8bfaabf2fcd..bfa26e6ccdd 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -25,17 +25,31 @@ export const revalidate = 0 /** Every param that changes which knowledge bases, in which order, this list returns. */ function knowledgeCursorFilters(query: { workspaceId: string + scope?: string folderPath?: string search?: string }) { return cursorScopeKey(cursorRoute(v2ListKnowledgeBasesContract), { workspaceId: query.workspaceId, + // Stamped only when it is not the default. `scope` carries + // `.default('active')`, so it is always present on the parsed query; + // binding it unconditionally would put a constant in every fingerprint and + // reject every cursor minted before the field existed — which is every + // cursor the deployed build handed out, since `scope` is new here. + scope: query.scope === 'active' ? undefined : query.scope, folderPath: query.folderPath, search: query.search, }) } -/** GET /api/v2/knowledge — List knowledge bases in a workspace. */ +/** + * GET /api/v2/knowledge — List knowledge bases in a workspace. + * + * `scope=archived` lists the soft-deleted set a `POST /api/v2/knowledge/{knowledgeBaseId}/restore` + * can bring back. It is the same operation as the active list — the same rows under + * a different `deleted_at` predicate — so it is a filter here rather than a sibling + * path, matching files, tables, and workflows. + */ export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeBasesContract, auth: v2ApiKeyAuth, @@ -44,6 +58,7 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ query }) => ({ workspaceId: query.workspaceId, + scope: query.scope, folderPath: query.folderPath, search: query.search, sortBy: query.sortBy, diff --git a/apps/sim/app/api/v2/knowledge/utils.ts b/apps/sim/app/api/v2/knowledge/utils.ts index 6d8da179662..2a3cbd7c76d 100644 --- a/apps/sim/app/api/v2/knowledge/utils.ts +++ b/apps/sim/app/api/v2/knowledge/utils.ts @@ -1,8 +1,11 @@ import type { V2KnowledgeBase, V2KnowledgeDocumentSummary, + V2KnowledgeTag, V2KnowledgeTaggedDocument, } from '@/lib/api/contracts/v2/knowledge' +import type { V2KnowledgeChunk } from '@/lib/api/contracts/v2/knowledge-chunks' +import type { ChunkData } from '@/lib/knowledge/chunks/types' import { ALL_TAG_SLOTS, type AllTagSlot } from '@/lib/knowledge/constants' import { DOCUMENT_PROCESSING_STATUSES, @@ -116,6 +119,12 @@ interface KnowledgeBaseWithFolder { folderPath: string } +/** + * Public knowledge-base projection. + * + * `deletedAt` is null for an active knowledge base and the archive instant for one + * `GET /knowledge?scope=archived` returned, so one projection serves both scopes. + */ function serializeV2KnowledgeBase( knowledgeBase: KnowledgeBaseWithCounts, folderPath: string, @@ -148,6 +157,7 @@ function serializeV2KnowledgeBase( createdAt: knowledgeBase.createdAt.toISOString(), updatedAt: knowledgeBase.updatedAt.toISOString(), folderPath, + deletedAt: knowledgeBase.deletedAt?.toISOString() ?? null, } } @@ -179,3 +189,47 @@ export async function toV2KnowledgeBases( ) ) } + +/** + * Serializes one chunk. Tag slots are projected as slots, and an absent slot is + * reported as `null` so every chunk carries the same key set. + */ +export function toV2KnowledgeChunk(chunk: ChunkData): V2KnowledgeChunk { + return { + id: chunk.id, + chunkIndex: chunk.chunkIndex, + content: chunk.content, + contentLength: chunk.contentLength, + tokenCount: chunk.tokenCount, + enabled: chunk.enabled, + startOffset: chunk.startOffset, + endOffset: chunk.endOffset, + tag1: chunk.tag1 ?? null, + tag2: chunk.tag2 ?? null, + tag3: chunk.tag3 ?? null, + tag4: chunk.tag4 ?? null, + tag5: chunk.tag5 ?? null, + tag6: chunk.tag6 ?? null, + tag7: chunk.tag7 ?? null, + createdAt: chunk.createdAt.toISOString(), + updatedAt: chunk.updatedAt.toISOString(), + } +} + +/** + * The single v2 tag-definition projection, shared by the vocabulary list and + * every tag write so an added field cannot reach one and miss the others. + */ +export function toV2KnowledgeTag(definition: { + id: string + displayName: string + tagSlot: string + fieldType: string +}): V2KnowledgeTag { + return { + id: definition.id, + displayName: definition.displayName, + tagSlot: definition.tagSlot, + fieldType: definition.fieldType, + } +} diff --git a/apps/sim/app/api/v2/lib/catalog.ts b/apps/sim/app/api/v2/lib/catalog.ts new file mode 100644 index 00000000000..9ff186b130d --- /dev/null +++ b/apps/sim/app/api/v2/lib/catalog.ts @@ -0,0 +1,12 @@ +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' + +/** + * The error policy every catalog route shares. + * + * A workspace the caller cannot reach is concealed as absent, so the catalog + * routes cannot be used to probe which workspace ids exist. Each detail route + * additionally raises its own not-found for an unknown or gated resource. + */ +export const catalogErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) diff --git a/apps/sim/app/api/v2/lib/workflow-lint.ts b/apps/sim/app/api/v2/lib/workflow-lint.ts new file mode 100644 index 00000000000..d00be819475 --- /dev/null +++ b/apps/sim/app/api/v2/lib/workflow-lint.ts @@ -0,0 +1,62 @@ +import type { WorkflowLintBlockRef, WorkflowLintReport } from '@/lib/workflows/editing/lint' + +/** Projects the shared block reference every lint finding carries onto the wire shape. */ +function blockRef(ref: WorkflowLintBlockRef) { + return { + blockId: ref.blockId, + blockName: ref.blockName ?? null, + blockType: ref.blockType ?? null, + } +} + +/** + * Projects a lint report onto the wire. + * + * Shared by the two graph writes so the report is byte-identical whichever one + * produced it. The domain leaves an absent block name `undefined`; the contract + * declares it `nullable`, because `undefined` is not a JSON value and a key that + * simply vanishes is indistinguishable from one the server forgot to send. The + * mapping is therefore load-bearing, not ceremony. + */ +export function presentWorkflowLint(lint: WorkflowLintReport) { + return { + sources: lint.sources.map(blockRef), + sinks: lint.sinks.map(blockRef), + orphanBlocks: lint.orphanBlocks.map(blockRef), + emptyOutgoingPorts: lint.emptyOutgoingPorts.map((port) => ({ + ...blockRef(port), + handle: port.handle, + label: port.label, + })), + invalidBranchPorts: lint.invalidBranchPorts.map((port) => ({ + ...blockRef(port), + sourceHandle: port.sourceHandle, + reason: port.reason, + })), + invalidConnectionTargets: lint.invalidConnectionTargets.map((target) => ({ + sourceBlockId: target.sourceBlockId, + sourceBlockName: target.sourceBlockName ?? null, + sourceHandle: target.sourceHandle ?? null, + targetBlockId: target.targetBlockId, + reason: target.reason, + })), + fieldIssues: lint.fieldIssues.map((issue) => ({ + ...blockRef(issue), + missingRequiredFields: issue.missingRequiredFields, + inactiveModeValues: issue.inactiveModeValues.map((value) => ({ + canonicalId: value.canonicalId, + activeMemberId: value.activeMemberId ?? null, + inactiveMemberId: value.inactiveMemberId, + kind: value.kind, + })), + })), + unresolvedReferences: lint.unresolvedReferences.map((reference) => ({ + ...blockRef(reference), + field: reference.field, + value: reference.value, + kind: reference.kind, + reason: reference.reason, + })), + notes: lint.notes, + } +} diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index 27d0226bda7..18b56303e18 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -41,6 +41,7 @@ const auth = { const log = { executionId: 'run-1', workflowId: 'workflow-1', + workspaceId: 'workspace-1', deploymentVersionId: 'deployment-1', status: 'completed', level: 'info', @@ -71,7 +72,15 @@ describe('GET /api/v2/logs/[runId]', () => { mocks.execute.mockResolvedValue({ log, workflowFolderPath: '/agents', - executionData: { traceSpans: [], finalOutput: { ok: true } }, + executionData: { + traceSpans: [], + finalOutput: { ok: true }, + workflowInput: { ticketId: 'T-1' }, + }, + costLedger: { + total: 0.01, + items: [{ category: 'model', description: 'gpt-5', cost: 0.01 }], + }, }) }) @@ -109,6 +118,105 @@ describe('GET /api/v2/logs/[runId]', () => { expect((await response.json()).data).toMatchObject({ runId: 'run-1', status: 'paused' }) }) + it('itemizes the run cost alongside its total', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + + expect((await response.json()).data.cost).toEqual({ + total: 0.01, + items: [{ category: 'model', description: 'gpt-5', cost: 0.01 }], + }) + }) + + /** + * `null` and `[]` are different answers: `null` means no ledger exists for the + * run at all, where `[]` would claim a ledger that itemizes to nothing. + */ + it('reports a missing ledger as null rather than as an empty item list', async () => { + mocks.execute.mockResolvedValueOnce({ + log, + workflowFolderPath: '/agents', + executionData: { traceSpans: [], finalOutput: null }, + costLedger: null, + }) + + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + + expect((await response.json()).data.cost).toEqual({ total: 0.01, items: null }) + }) + + /** + * `cost_total` is a backfilled projection, so a run that predates the backfill + * has a real `usage_log` ledger and no projected total. Keying `cost` on the + * projection reported `cost: null` for exactly those runs — the contract's + * spelling for "no cost information at all" — and made `items` unreachable + * for the runs the ledger exists to explain. + */ + it('falls back to the ledger total when the projected total is missing', async () => { + mocks.execute.mockResolvedValueOnce({ + log: { ...log, costTotal: null }, + workflowFolderPath: '/agents', + executionData: { traceSpans: [], finalOutput: null }, + costLedger: { + total: 0.03, + items: [{ category: 'model', description: 'gpt-5', cost: 0.03 }], + }, + }) + + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + + expect((await response.json()).data.cost).toEqual({ + total: 0.03, + items: [{ category: 'model', description: 'gpt-5', cost: 0.03 }], + }) + }) + + it('reports null cost only when neither the projection nor a ledger exists', async () => { + mocks.execute.mockResolvedValueOnce({ + log: { ...log, costTotal: null }, + workflowFolderPath: '/agents', + executionData: { traceSpans: [], finalOutput: null }, + costLedger: null, + }) + + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + + expect((await response.json()).data.cost).toBeNull() + }) + + it('returns the input the run was triggered with', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + + expect((await response.json()).data.workflowInput).toEqual({ ticketId: 'T-1' }) + }) + + it('reports a run that recorded no input as null rather than omitting the field', async () => { + mocks.execute.mockResolvedValueOnce({ + log, + workflowFolderPath: '/agents', + executionData: { traceSpans: [], finalOutput: null }, + costLedger: null, + }) + + const body = await ( + await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + ).json() + + expect(body.data).toHaveProperty('workflowInput') + expect(body.data.workflowInput).toBeNull() + }) + it('conceals canonical workspace authorization as log not-found', async () => { mocks.execute.mockRejectedValueOnce(new NoWorkspaceAccessError()) @@ -122,6 +230,59 @@ describe('GET /api/v2/logs/[runId]', () => { }) }) + /** + * The detail read passed `workflow_execution_logs.files` straight through, + * publishing the storage key and a `/api/files/serve/…` URL that authenticates + * by session and refuses an API key. Only files under this run's own execution + * prefix survive, and they are addressed through the run resource instead. + */ + it("publishes only the run's own output files, never a recorded storage key", async () => { + mocks.execute.mockResolvedValueOnce({ + log: { + ...log, + files: [ + { + id: 'file-own', + name: 'report.pdf', + size: 1024, + type: 'application/pdf', + url: '/api/files/serve/execution/x', + key: 'execution/workspace-1/workflow-1/run-1/report.pdf', + }, + { + id: 'file-forged', + name: 'stolen.pdf', + size: 1, + type: 'application/pdf', + key: 'execution/other-workspace/other-workflow/other-run/stolen.pdf', + }, + ], + }, + workflowFolderPath: '/agents', + executionData: { traceSpans: [], finalOutput: null, workflowInput: null }, + costLedger: null, + }) + + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + const raw = await response.text() + + expect(response.status).toBe(200) + expect(JSON.parse(raw).data.files).toEqual([ + { + id: 'file-own', + name: 'report.pdf', + size: 1024, + type: 'application/pdf', + downloadPath: '/api/v2/workflows/workflow-1/runs/run-1/files/file-own', + }, + ]) + expect(raw).not.toContain('"key"') + expect(raw).not.toContain('/api/files/serve/') + expect(raw).not.toContain('stolen.pdf') + }) + it('hides unexpected materialization errors', async () => { mocks.execute.mockRejectedValueOnce(new Error('storage key details')) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index 1902a4b44b0..5433825e6ae 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -1,12 +1,30 @@ -import { traceSpansSchema } from '@/lib/api/contracts/logs' +import { type CostLedger, traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogDetail, v2GetLogContract, v2LogStatusSchema } from '@/lib/api/contracts/v2/logs' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' import { getPublicLog } from '@/lib/logs/application/get-public-log' import { logOperations } from '@/lib/logs/application/operations' +import { projectLogFiles } from '@/lib/logs/log-files' export const revalidate = 0 +/** + * The run's cost as the contract defines it, from the projected total and the + * itemized ledger. + * + * The projection wins when both exist: it is what every other surface reports + * for the run, and the ledger folds its lines, so a rounding difference between + * the two must not make one endpoint disagree with the rest. + */ +function buildCostProjection( + costTotal: string | null, + costLedger: CostLedger | null +): V2LogDetail['cost'] { + if (costTotal != null) return { total: Number(costTotal), items: costLedger?.items ?? null } + if (costLedger) return { total: costLedger.total, items: costLedger.items } + return null +} + /** * Returns the diagnostic representation of a run. The run ID is the sole * public identity; canonical workflow and workspace scope come from the run. @@ -19,7 +37,7 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2LogErrorPolicies.concealDetailAuthorization, mapInput: ({ params }) => ({ runId: params.runId }), useCase: getPublicLog, - present: ({ log, workflowFolderPath, executionData }) => { + present: ({ log, workflowFolderPath, executionData, costLedger }) => { const detail: V2LogDetail = { runId: log.executionId, workflowId: log.workflowId, @@ -30,7 +48,7 @@ export const GET = defineV2JsonRoute({ startedAt: log.startedAt.toISOString(), endedAt: log.endedAt ? log.endedAt.toISOString() : null, totalDurationMs: log.totalDurationMs, - files: (log.files as unknown[] | null) ?? null, + files: projectLogFiles(log), workflow: { id: log.workflowId, name: log.workflowName || 'Deleted Workflow', @@ -45,7 +63,17 @@ export const GET = defineV2JsonRoute({ workflowState: log.workflowState, traceSpans: traceSpansSchema.parse(executionData.traceSpans ?? []), finalOutput: executionData.finalOutput ?? null, - cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + /** + * `cost_total` is a backfilled projection of the ledger, so it is null on + * runs that predate the backfill even when `usage_log` holds real billed + * lines for them. Keying `cost` on the projection alone reported + * `cost: null` for those runs — which the contract defines as "no cost + * information", not "no itemization" — and made `items` unreachable for + * exactly the runs the ledger exists to explain. The ledger's own total + * is the fallback; `null` now means neither source has anything. + */ + cost: buildCostProjection(log.costTotal, costLedger), + workflowInput: executionData.workflowInput ?? null, createdAt: log.createdAt.toISOString(), } return { data: detail } diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 36917bedbab..99957620063 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -27,7 +27,7 @@ vi.mock('@/lib/logs/application/list-public-logs', () => ({ import { v2ListLogsContract } from '@/lib/api/contracts/v2/logs' import { cursorRoute, cursorScopeKey, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { encodeScopedCursor } from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor } from '@/app/api/v2/lib/response' import { GET } from '@/app/api/v2/logs/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -69,7 +69,7 @@ describe('GET /api/v2/logs', () => { v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ items: [{ log, executionData: { finalOutput: false, traceSpans: [] } }], - nextCursor: null, + nextCursorKeys: null, includeFullDetails: true, includeFinalOutput: true, includeTraceSpans: true, @@ -104,7 +104,7 @@ describe('GET /api/v2/logs', () => { it('serves a run whose persisted status is paused', async () => { mocks.execute.mockResolvedValue({ items: [{ log: { ...log, status: 'paused' }, executionData: null }], - nextCursor: null, + nextCursorKeys: null, includeFullDetails: false, includeFinalOutput: false, includeTraceSpans: false, @@ -120,17 +120,14 @@ describe('GET /api/v2/logs', () => { }) /** - * The run-log cursor is minted by the domain codec, so it carries only its own - * `(startedAt, id)` position and the requested order. Binding it to the filters - * is what stops a cursor taken from an unfiltered walk from resuming inside a - * `level=error` read at an unrelated point in that shorter sequence. + * The keyset carries only a `(startedAt, id)` position. Binding it to the + * filters is what stops a cursor taken from an unfiltered walk from resuming + * inside a `level=error` read at an unrelated point in that shorter sequence. */ it('refuses a cursor replayed under a different filter', async () => { mocks.execute.mockResolvedValueOnce({ items: [{ log, executionData: null }], - nextCursor: Buffer.from( - JSON.stringify({ startedAt: log.startedAt.toISOString(), id: 'run-1', order: 'desc' }) - ).toString('base64'), + nextCursorKeys: [log.startedAt.toISOString(), 'run-1'], includeFullDetails: false, includeFinalOutput: false, includeTraceSpans: false, @@ -163,9 +160,7 @@ describe('GET /api/v2/logs', () => { async (param) => { mocks.execute.mockResolvedValueOnce({ items: [{ log, executionData: null }], - nextCursor: Buffer.from( - JSON.stringify({ startedAt: log.startedAt.toISOString(), id: 'run-1', order: 'desc' }) - ).toString('base64'), + nextCursorKeys: [log.startedAt.toISOString(), 'run-1'], includeFullDetails: false, includeFinalOutput: false, includeTraceSpans: false, @@ -184,6 +179,31 @@ describe('GET /api/v2/logs', () => { } ) + /** + * `includeJobRuns` carries `.default(false)`, so it is present on every parsed + * query. Stamping it unconditionally would put a constant in every + * fingerprint and refuse every cursor minted before the param existed, with + * the misleading "does not match the requested filters" 400 — a caller that changed + * nothing would be told it changed a filter. The default must therefore + * contribute nothing to the scope. + */ + it('resumes a cursor minted before includeJobRuns entered the binding', async () => { + const legacyCursor = encodeSortedCursor( + cursorSortKey('startedAt', 'desc'), + [log.startedAt.toISOString(), 'run-1'], + cursorScopeKey(cursorRoute(v2ListLogsContract), { workspaceId: WORKSPACE_ID }) + ) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&cursor=${encodeURIComponent(legacyCursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalled() + }) + it('rejects malformed cursors after admission and before protected reads', async () => { const response = await GET( new NextRequest( @@ -197,33 +217,37 @@ describe('GET /api/v2/logs', () => { }) /** - * An empty inner token reads as falsy in the domain codec, so no cursor - * condition is applied and the caller silently gets page one back, with a - * `nextCursor` inviting it to do the same thing forever. + * The keys in a `startedAt` cursor are a timestamp and an id; replayed under + * `sortBy=cost` they would be compared against a `numeric` column, which is a + * different sequence entirely rather than a later position in this one. */ - it('rejects a cursor whose inner token is empty instead of restarting at page one', async () => { - const cursor = encodeScopedCursor( - cursorScopeKey(cursorRoute(v2ListLogsContract), { workspaceId: WORKSPACE_ID, order: 'desc' }), - '' + it('refuses a cursor replayed under a different sort', async () => { + const cursor = encodeSortedCursor( + cursorSortKey('startedAt', 'desc'), + [log.startedAt.toISOString(), 'run-1'], + cursorScopeKey(cursorRoute(v2ListLogsContract), { workspaceId: WORKSPACE_ID }) ) const response = await GET( new NextRequest( - `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&limit=1&cursor=${encodeURIComponent(cursor)}` + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&sortBy=cost&cursor=${encodeURIComponent(cursor)}` ) ) expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('sortBy') }, + }) expect(mocks.execute).not.toHaveBeenCalled() }) /** - * An undecodable token says nothing about which param changed, and this - * operation declares neither `sortBy` nor `sortOrder` under a `.strict()` - * query schema — so the sort-mismatch message would answer one 400 with - * advice that earns a second. The message is asserted exactly rather than by - * absence: "does not say sortBy" is satisfied by almost any wording, including - * one that tells the caller nothing at all. + * An undecodable token says nothing about which param changed — it did not + * decode far enough to compare a sort or a filter — so answering it with the + * sort-mismatch message would send the caller after a param it may not have + * touched. The message is asserted exactly rather than by absence: "does not + * say sortBy" is satisfied by almost any wording, including one that tells the + * caller nothing at all. */ it('names the params a rejected cursor is actually bound to', async () => { const response = await GET( @@ -411,6 +435,358 @@ describe('GET /api/v2/logs', () => { expect(mocks.execute).not.toHaveBeenCalled() }) + /** + * Each param must be a value the contract ACCEPTS, or the 400 comes from + * schema validation and the case proves nothing about cursor binding — which + * is why the assertion below pins the reason as well as the status. `error` + * sat here once: it is a `level`, not a `status`, so it never reached the + * cursor check at all. + */ + it.each([['status=failed'], ['workflowName=support'], ['includeJobRuns=true']])( + 'refuses a cursor replayed under a changed %s', + async (param) => { + mocks.execute.mockResolvedValueOnce({ + items: [{ log, executionData: null }], + nextCursorKeys: [log.startedAt.toISOString(), 'run-1'], + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`)) + ).json() + mocks.execute.mockClear() + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toMatch(/cursor/i) + expect(mocks.execute).not.toHaveBeenCalled() + } + ) + + it('rejects a status outside the persisted vocabulary and echoes the valid set', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&status=done`) + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error.message).toContain('"completed"') + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('forwards a status list as the persisted statuses the filter matches on', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&status=failed,completed` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + filters: expect.objectContaining({ statuses: ['completed', 'failed'] }), + }), + }) + ) + }) + + it('rejects a workflowName past the search bound before it reaches an unindexed scan', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&workflowName=${'a'.repeat(201)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** + * A job run and a workflow run whose workflow was deleted both report + * `workflowId: null`, so the discriminator is the only thing separating them. + */ + it('projects a job run under its own kind with a derived status', async () => { + mocks.execute.mockResolvedValueOnce({ + items: [ + { + log: { + kind: 'job', + id: 'job-row-1', + executionId: 'job-1', + workspaceId: WORKSPACE_ID, + level: 'error', + trigger: 'mothership', + startedAt: new Date('2026-08-06T00:00:00Z'), + endedAt: new Date('2026-08-06T00:00:02Z'), + totalDurationMs: 2000, + cost: { total: 0.5 }, + }, + }, + ], + nextCursor: null, + includeFullDetails: true, + includeFinalOutput: false, + includeTraceSpans: false, + }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&includeJobRuns=true&details=full` + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).data[0]).toEqual({ + kind: 'job', + runId: 'job-1', + workflowId: null, + deploymentVersionId: null, + status: 'failed', + level: 'error', + trigger: 'mothership', + startedAt: '2026-08-06T00:00:00.000Z', + endedAt: '2026-08-06T00:00:02.000Z', + totalDurationMs: 2000, + cost: { total: 0.5 }, + files: null, + }) + }) + + /** + * `workflow_execution_logs.files` is a recording, not a manifest: the start + * block copies every caller-supplied input field verbatim into its output, so + * a caller can get a `UserFile` naming ANY storage key recorded against its + * own run. Publishing the blob as stored handed back that key — and a + * `/api/files/serve/…` URL an API key cannot follow — so the projection keeps + * only keys under this run's own execution prefix. + */ + it("publishes only the run's own output files, never a recorded storage key", async () => { + mocks.execute.mockResolvedValueOnce({ + items: [ + { + log: { + ...log, + files: [ + { + id: 'file-own', + name: 'report.pdf', + size: 1024, + type: 'application/pdf', + url: '/api/files/serve/execution/x', + key: `execution/${WORKSPACE_ID}/workflow-1/run-1/report.pdf`, + }, + { + id: 'file-other-workspace', + name: 'stolen.pdf', + size: 1, + type: 'application/pdf', + url: '/api/files/serve/execution/y', + key: 'execution/other-workspace/workflow-1/run-1/stolen.pdf', + }, + { + id: 'file-other-run', + name: 'neighbour.pdf', + size: 1, + type: 'application/pdf', + key: `execution/${WORKSPACE_ID}/workflow-1/run-2/neighbour.pdf`, + }, + { + id: 'file-input', + name: 'upload.csv', + size: 12, + type: 'text/csv', + key: `workspace/${WORKSPACE_ID}/upload.csv`, + }, + ], + }, + executionData: null, + }, + ], + nextCursorKeys: null, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`) + ) + const raw = await response.text() + + expect(response.status).toBe(200) + expect(JSON.parse(raw).data[0].files).toEqual([ + { + id: 'file-own', + name: 'report.pdf', + size: 1024, + type: 'application/pdf', + downloadPath: '/api/v2/workflows/workflow-1/runs/run-1/files/file-own', + }, + ]) + expect(raw).not.toContain('"key"') + expect(raw).not.toContain('/api/files/serve/') + expect(raw).not.toContain('stolen.pdf') + expect(raw).not.toContain('neighbour.pdf') + expect(raw).not.toContain('upload.csv') + }) + + /** + * The response schema is `.parse`d on the way out, so a row whose recorded + * entries are all out of scope has to project to an empty array. A 500 here + * would be caller-reachable through nothing more than attaching a file. + */ + it('answers a row whose recorded files are all out of scope with an empty array', async () => { + mocks.execute.mockResolvedValueOnce({ + items: [ + { + log: { + ...log, + files: [ + { id: 'f', name: 'x', size: 1, type: 'text/plain', key: 'workspace/other/x' }, + { nonsense: true }, + null, + 'not-an-object', + ], + }, + executionData: null, + }, + ], + nextCursorKeys: null, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`) + ) + + expect(response.status).toBe(200) + expect((await response.json()).data[0].files).toEqual([]) + }) + + /** A deleted workflow leaves no run resource to address the bytes through. */ + it('drops recorded files when the run has no workflow to address them under', async () => { + mocks.execute.mockResolvedValueOnce({ + items: [ + { + log: { + ...log, + workflowId: null, + files: [ + { + id: 'file-own', + name: 'report.pdf', + size: 1, + type: 'application/pdf', + key: `execution/${WORKSPACE_ID}/workflow-1/run-1/report.pdf`, + }, + ], + }, + executionData: null, + }, + ], + nextCursorKeys: null, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`) + ) + + expect(response.status).toBe(200) + expect((await response.json()).data[0].files).toEqual([]) + }) + + it('forwards the requested sort to the application operation', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&sortBy=cost&sortOrder=asc` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ sortBy: 'cost', sortOrder: 'asc' }), + }) + ) + }) + + it('defaults to the newest runs first', async () => { + await GET(new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`)) + + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ sortBy: 'startedAt', sortOrder: 'desc' }), + }) + ) + }) + + /** `order` was retired in favour of the surface-wide pair; a strict query rejects it. */ + it('rejects the retired order param', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&order=asc`) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** + * Job runs record cost as a document and no comparable status, so they cannot + * participate in those orderings. Dropping the branch silently would answer a + * request with a sequence the caller did not ask for. + */ + it.each([['durationMs'], ['cost'], ['status']])( + 'refuses includeJobRuns together with sortBy=%s', + async (sortBy) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&includeJobRuns=true&sortBy=${sortBy}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('startedAt') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + } + ) + + /** + * An id list compiles to `IN (...)`, so an unbounded one lets the caller + * choose the query plan's cost. These are the ceilings the retired + * `POST /logs/query` already enforced on the same filters. + */ + it.each([ + ['workflowIds', Array.from({ length: 201 }, (_, i) => `w${i}`).join(',')], + ['triggers', Array.from({ length: 101 }, (_, i) => `t${i}`).join(',')], + ['folderPaths', Array.from({ length: 101 }, (_, i) => `/f${i}`).join(',')], + ])('caps the %s list', async (field, value) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${encodeURIComponent(value)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining(field) }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('projects typed folder errors', async () => { mocks.execute.mockRejectedValueOnce(new OrchestrationError('not_found', 'Folder not found')) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 74123a88266..0ef0a706aad 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -9,26 +9,42 @@ import { cursorScopeKey, instantScopePart, parseUnorderedList, - UNREADABLE_CURSOR_MESSAGE, unorderedScopePart, } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' import { listPublicLogs } from '@/lib/logs/application/list-public-logs' import { logOperations } from '@/lib/logs/application/operations' -import { decodePublicLogCursor } from '@/lib/logs/public-queries' -import { encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' +import { jobCostTotal } from '@/lib/logs/fetch-log-detail' +import { LOG_FOLDER_SCOPE_VERSION } from '@/lib/logs/folder-scope' +import { projectLogFiles } from '@/lib/logs/log-files' +import { isPersistedWorkflowExecutionStatus } from '@/lib/logs/types' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * Every param that changes which logs, in which order, this list returns. + * Every param that changes WHICH logs this list returns. * - * `details`, `includeFinalOutput`, and `includeTraceSpans` are deliberately - * absent: they decide how much of each row is rendered, not which rows are in - * the sequence, so a caller may turn them on mid-walk. + * The ordering is stamped separately, by `cursorSortKey` inside the shared + * keyset codec, so `sortBy`/`sortOrder` are deliberately absent here rather than + * unbound. + * + * `details`, `includeFinalOutput`, and `includeTraceSpans` are absent for a + * different reason: they decide how much of each row is rendered, not which rows + * are in the sequence, so a caller may turn them on mid-walk. `includeJobRuns` + * is NOT one of those — it decides whether the job-run branch is in the sequence + * at all, so it is bound. + * + * `folderScopeVersion` is stamped only when a folder filter is active, and it is + * deliberately not a contract param, so it never appears in `CURSOR_BINDINGS` — + * that sweep checks declarations against what the contract accepts, and a + * route-side constant is invisible to it. It exists because a folder path now + * selects its whole subtree: the path strings a caller sends did not change, so + * without a version bump a token minted under the old rule would decode cleanly + * and resume inside a larger sequence, silently skipping rows. Do not "fix" its + * absence from the sweep by declaring it. */ function logCursorFilters(query: { workspaceId: string @@ -44,7 +60,9 @@ function logCursorFilters(query: { maxCost?: number model?: string folderPaths?: string - order?: string + status?: string + workflowName?: string + includeJobRuns?: boolean }) { return cursorScopeKey(cursorRoute(v2ListLogsContract), { workspaceId: query.workspaceId, @@ -60,7 +78,15 @@ function logCursorFilters(query: { maxCost: query.maxCost, model: query.model, folderPaths: unorderedScopePart(query.folderPaths), - order: query.order, + folderScopeVersion: query.folderPaths ? LOG_FOLDER_SCOPE_VERSION : undefined, + status: unorderedScopePart(query.status), + workflowName: query.workflowName, + // Stamped only when it is on. `includeJobRuns` carries `.default(false)`, so + // it is always present on the parsed query; binding it unconditionally would + // put a constant in every fingerprint and reject every cursor minted before + // the field existed — including on unfiltered walks, which is precisely what + // `folderScopeVersion` above is careful not to do. + includeJobRuns: query.includeJobRuns || undefined, }) } @@ -70,44 +96,73 @@ export const GET = defineV2JsonRoute({ operation: logOperations.list, rateLimit: v2RateLimits.publicApi, errorPolicy: v2LogErrorPolicies.default, - mapInput: ({ query }) => { - const inner = readScopedCursor(query.cursor, logCursorFilters(query)) - const decodedCursor = inner ? decodePublicLogCursor(inner, query.order ?? 'desc') : null - if (inner && !decodedCursor) { - throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) - } - return { - workspaceId: query.workspaceId, - filters: { - workflowIds: parseUnorderedList(query.workflowIds), - triggers: parseUnorderedList(query.triggers), - level: query.level, - startDate: query.startDate ? new Date(query.startDate) : undefined, - endDate: query.endDate ? new Date(query.endDate) : undefined, - executionId: query.runId, - minDurationMs: query.minDurationMs, - maxDurationMs: query.maxDurationMs, - minCost: query.minCost, - maxCost: query.maxCost, - model: query.model, - cursor: decodedCursor ?? undefined, - order: query.order, - }, - folderPaths: parseUnorderedList(query.folderPaths), - limit: query.limit, - includeFullDetails: - query.details === 'full' || query.includeFinalOutput || query.includeTraceSpans, - includeFinalOutput: query.includeFinalOutput, - includeTraceSpans: query.includeTraceSpans, - } - }, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + filters: { + workflowIds: parseUnorderedList(query.workflowIds), + triggers: parseUnorderedList(query.triggers), + level: query.level, + statuses: parseUnorderedList(query.status)?.filter(isPersistedWorkflowExecutionStatus), + workflowName: query.workflowName, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + executionId: query.runId, + minDurationMs: query.minDurationMs, + maxDurationMs: query.maxDurationMs, + minCost: query.minCost, + maxCost: query.maxCost, + model: query.model, + }, + folderPaths: parseUnorderedList(query.folderPaths), + sortBy: query.sortBy, + sortOrder: query.sortOrder, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + logCursorFilters(query) + ), + limit: query.limit, + includeFullDetails: + query.details === 'full' || query.includeFinalOutput || query.includeTraceSpans, + includeFinalOutput: query.includeFinalOutput, + includeTraceSpans: query.includeTraceSpans, + includeJobRuns: query.includeJobRuns, + }), useCase: listPublicLogs, present: ( - { items, nextCursor, includeFullDetails, includeFinalOutput, includeTraceSpans }, + { items, nextCursorKeys, includeFullDetails, includeFinalOutput, includeTraceSpans }, { query } ) => ({ data: items.map(({ log, executionData }): V2LogListItem => { + if (log.kind === 'job') { + /** + * A job run's status is derived rather than passed through. + * + * `job_execution_logs.status` is an unconstrained text column written by + * the job runtime, and this field is `.parse`d on the way out, so + * publishing it verbatim would make one unrecognized value a 500 for the + * whole page. Level and completion are the two facts this surface owns, + * and they answer the question the field is for. + */ + return { + kind: 'job', + runId: log.executionId, + workflowId: null, + deploymentVersionId: null, + status: log.level === 'error' ? 'failed' : log.endedAt ? 'completed' : 'running', + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + cost: jobCostTotal(log.cost), + files: null, + } + } + const item: V2LogListItem = { + kind: 'workflow', runId: log.executionId, workflowId: log.workflowId, deploymentVersionId: log.deploymentVersionId, @@ -118,7 +173,7 @@ export const GET = defineV2JsonRoute({ endedAt: log.endedAt ? log.endedAt.toISOString() : null, totalDurationMs: log.totalDurationMs, cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, - files: (log.files as unknown[] | null) ?? null, + files: projectLogFiles(log), } if (includeFullDetails) { item.workflow = { @@ -138,6 +193,11 @@ export const GET = defineV2JsonRoute({ } return item }), - nextCursor: nextCursor ? encodeScopedCursor(logCursorFilters(query), nextCursor) : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + logCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/logs/stats/route.test.ts b/apps/sim/app/api/v2/logs/stats/route.test.ts new file mode 100644 index 00000000000..d0ec44ae416 --- /dev/null +++ b/apps/sim/app/api/v2/logs/stats/route.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/logs/application/get-log-stats', () => ({ + getLogStats: { operation: { id: 'logs.read_stats' }, execute: mocks.execute }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/v2/logs/stats/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const segment = { + timestamp: '2026-08-06T00:00:00.000Z', + totalExecutions: 2, + successfulExecutions: 1, + avgDurationMs: 500, +} + +const stats = { + workflows: [ + { + workflowId: 'workflow-1', + workflowName: 'Support Agent', + segments: [segment], + totalExecutions: 2, + totalSuccessful: 1, + overallSuccessRate: 50, + }, + ], + aggregateSegments: [segment], + totalRuns: 2, + totalErrors: 1, + avgLatency: 500, + timeBounds: { start: '2026-08-06T00:00:00.000Z', end: '2026-08-06T01:00:00.000Z' }, + segmentMs: 3_600_000, +} + +function request(query = ''): NextRequest { + return new NextRequest( + `http://localhost:3000/api/v2/logs/stats?workspaceId=${WORKSPACE_ID}${query}` + ) +} + +describe('GET /api/v2/logs/stats', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ stats, workflowsTruncated: false }) + }) + + it('returns the aggregate under the v2 envelope with the truncation flag', async () => { + const response = await GET(request()) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { ...stats, workflowsTruncated: false } }) + }) + + it('defaults the bucket count without the caller naming one', async () => { + await GET(request()) + + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ segmentCount: 72 }) }) + ) + }) + + /** + * Each of these produced a 500 before the bounds landed: `0` divided by zero, + * `1e9` allocated two billion-element arrays, and a fraction indexed between + * buckets. A caller-supplied value must never reach the aggregator unbounded. + */ + it.each([['0'], ['1.5'], ['1e9'], ['-1'], ['501']])( + 'rejects segmentCount=%s before any protected read', + async (value) => { + const response = await GET(request(`&segmentCount=${encodeURIComponent(value)}`)) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('segmentCount') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + } + ) + + it('rejects an unknown query param instead of silently ignoring it', async () => { + const response = await GET(request('&bogus=1')) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('parses filter lists once, into the values the query filters on', async () => { + await GET(request('&workflowIds=b,a,a&triggers=api&folderPaths=/prod')) + + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + filters: expect.objectContaining({ workflowIds: ['a', 'b'], triggers: ['api'] }), + folderPaths: ['/prod'], + }), + }) + ) + }) + + it('conceals a workspace the caller cannot reach', async () => { + mocks.execute.mockRejectedValueOnce(new OrchestrationError('not_found', 'Workspace not found')) + + const response = await GET(request()) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: { code: 'NOT_FOUND' } }) + }) + + it('carries the per-caller cache directive every v2 response needs', async () => { + const response = await GET(request()) + + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) +}) diff --git a/apps/sim/app/api/v2/logs/stats/route.ts b/apps/sim/app/api/v2/logs/stats/route.ts new file mode 100644 index 00000000000..feb4ede5921 --- /dev/null +++ b/apps/sim/app/api/v2/logs/stats/route.ts @@ -0,0 +1,38 @@ +import { v2GetLogStatsContract } from '@/lib/api/contracts/v2/logs-stats' +import { parseUnorderedList } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' +import { getLogStats } from '@/lib/logs/application/get-log-stats' +import { logOperations } from '@/lib/logs/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Bucketed success rate, error count, and latency for a workspace's runs. + * + * The aggregate a caller would otherwise have to page every run to compute. Not + * a list — the response carries no `nextCursor` — so it is bounded instead by + * the segment ceiling on the request and the workflow ceiling on the response. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetLogStatsContract, + auth: v2ApiKeyAuth, + operation: logOperations.readStats, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2LogErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + filters: { + workflowIds: parseUnorderedList(query.workflowIds), + triggers: parseUnorderedList(query.triggers), + level: query.level, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + }, + folderPaths: parseUnorderedList(query.folderPaths), + segmentCount: query.segmentCount, + }), + useCase: getLogStats, + present: ({ stats, workflowsTruncated }) => ({ data: { ...stats, workflowsTruncated } }), +}) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.test.ts similarity index 97% rename from apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts rename to apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.test.ts index 536f5b0adb4..525c3b7c6ef 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.test.ts @@ -43,7 +43,7 @@ vi.mock('@/lib/mcp/application/use-cases', () => ({ deleteMcpServerUseCase: { operation: { id: 'mcp_servers.delete' }, execute: mocks.remove }, })) -import { DELETE, GET, PATCH } from '@/app/api/v2/mcp-servers/[id]/route' +import { DELETE, GET, PATCH } from '@/app/api/v2/mcp-servers/[mcpServerId]/route' type McpServerRow = typeof mcpServers.$inferSelect const WORKSPACE_ID = 'workspace-1' @@ -82,7 +82,7 @@ const server = { createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-02T00:00:00Z'), } as McpServerRow -const context = { params: Promise.resolve({ id: server.id }) } +const context = { params: Promise.resolve({ mcpServerId: server.id }) } /** * The read and delete verbs scope themselves with `?workspaceId=`; the write @@ -102,7 +102,7 @@ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { }) } -describe('/api/v2/mcp-servers/[id]', () => { +describe('/api/v2/mcp-servers/[mcpServerId]', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(AUTH) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts similarity index 79% rename from apps/sim/app/api/v2/mcp-servers/[id]/route.ts rename to apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts index 1612b2269b0..e2cb7d09c6f 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts @@ -16,31 +16,38 @@ import { mcpServerResourceErrorPolicy, toV2McpServer } from '@/app/api/v2/mcp-se export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ +/** GET /api/v2/mcp-servers/[mcpServerId] — Fetch a single MCP server. */ export const GET = defineV2JsonRoute({ contract: v2GetMcpServerContract, operation: mcpServerOperations.read, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: mcpServerResourceErrorPolicy, - mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, serverId: params.id }), + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + serverId: params.mcpServerId, + }), useCase: getMcpServerUseCase, present: ({ server }) => ({ data: toV2McpServer(server) }), }) -/** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */ +/** PATCH /api/v2/mcp-servers/[mcpServerId] — Update an MCP server's configuration. */ export const PATCH = defineV2JsonRoute({ contract: v2UpdateMcpServerContract, operation: mcpServerOperations.update, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: mcpServerResourceErrorPolicy, - mapInput: ({ params, body }) => ({ ...body, serverId: params.id, source: 'api' as const }), + mapInput: ({ params, body }) => ({ + ...body, + serverId: params.mcpServerId, + source: 'api' as const, + }), useCase: updateMcpServerUseCase, present: ({ server }) => ({ data: toV2McpServer(server) }), }) -/** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */ +/** DELETE /api/v2/mcp-servers/[mcpServerId] — Remove an MCP server from the workspace. */ export const DELETE = defineV2JsonRoute({ contract: v2DeleteMcpServerContract, operation: mcpServerOperations.delete, @@ -49,7 +56,7 @@ export const DELETE = defineV2JsonRoute({ errorPolicy: mcpServerResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, - serverId: params.id, + serverId: params.mcpServerId, source: 'api' as const, }), useCase: deleteMcpServerUseCase, diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts similarity index 98% rename from apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts rename to apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts index e577d2428f5..f28ea485b98 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts @@ -44,7 +44,7 @@ import { McpOauthAuthorizationRequiredError, McpServerCooldownError, } from '@/lib/mcp/types' -import { GET } from '@/app/api/v2/mcp-servers/[id]/tools/route' +import { GET } from '@/app/api/v2/mcp-servers/[mcpServerId]/tools/route' const WORKSPACE_ID = 'workspace-1' const SERVER_ID = 'mcp-3f7a9c21' @@ -75,9 +75,9 @@ function request(query: string, method = 'GET') { }) } -const context = { params: Promise.resolve({ id: SERVER_ID }) } +const context = { params: Promise.resolve({ mcpServerId: SERVER_ID }) } -describe('/api/v2/mcp-servers/[id]/tools', () => { +describe('/api/v2/mcp-servers/[mcpServerId]/tools', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(AUTH) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.ts similarity index 90% rename from apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts rename to apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.ts index 11343abb137..4265ea60b5a 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.ts @@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * GET /api/v2/mcp-servers/[id]/tools — List the tools a registered MCP server exposes. + * GET /api/v2/mcp-servers/[mcpServerId]/tools — List the tools a registered MCP server exposes. * * The path segment is static, so it can never shadow a server id: ids are minted * as `mcp-` from the workspace and endpoint URL, and the registration @@ -26,7 +26,7 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2McpToolDiscoveryErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, - serverId: params.id, + serverId: params.mcpServerId, refresh: query.refresh, }), useCase: discoverMcpServerToolsUseCase, diff --git a/apps/sim/app/api/v2/meta/route.test.ts b/apps/sim/app/api/v2/meta/route.test.ts new file mode 100644 index 00000000000..8feb1c25f9f --- /dev/null +++ b/apps/sim/app/api/v2/meta/route.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + read: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +vi.mock('@/lib/api/application/read-v2-api-capabilities', () => ({ + readV2ApiCapabilities: { + operation: { id: 'meta.capabilities.read' }, + execute: mocks.read, + }, +})) + +import { GET } from '@/app/api/v2/meta/route' + +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, + keyExpiresAt: new Date('2027-01-01T00:00:00.000Z'), +} + +function request(query = ''): NextRequest { + return new NextRequest(`http://localhost:3000/api/v2/meta${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +describe('GET /api/v2/meta', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.read.mockResolvedValue({ + v2Enabled: true, + keyType: 'personal', + expiresAt: new Date('2027-01-01T00:00:00.000Z'), + }) + }) + + it('returns the caller capabilities in the v2 envelope', async () => { + const response = await GET(request()) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + await expect(response.json()).resolves.toEqual({ + data: { + v2Enabled: true, + keyType: 'personal', + expiresAt: '2027-01-01T00:00:00.000Z', + }, + }) + }) + + it('reports a key with no expiry as null', async () => { + mocks.read.mockResolvedValue({ v2Enabled: false, keyType: 'workspace', expiresAt: null }) + + const response = await GET(request()) + + await expect(response.json()).resolves.toEqual({ + data: { v2Enabled: false, keyType: 'workspace', expiresAt: null }, + }) + }) + + /** + * The whole point of the endpoint: a caller outside the rollout cohort gets an + * answer here rather than the same 404 every other v2 route hands it. A change + * that routes this through the default gated path fails here. + */ + it('answers while the rollout gate would refuse, without consulting it', async () => { + v2RouteMocks.gate.mockResolvedValue( + NextResponse.json({ error: { code: 'NOT_FOUND', message: 'Not found' } }, { status: 404 }) + ) + mocks.read.mockResolvedValue({ v2Enabled: false, keyType: 'personal', expiresAt: null }) + + const response = await GET(request()) + + expect(response.status).toBe(200) + expect(v2RouteMocks.gate).not.toHaveBeenCalled() + await expect(response.json()).resolves.toMatchObject({ data: { v2Enabled: false } }) + }) + + it('is exempt from the gate but never from authentication', async () => { + v2RouteMocks.authenticate.mockRejectedValue( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) + + const response = await GET(new NextRequest('http://localhost:3000/api/v2/meta')) + + expect(response.status).toBe(401) + expect(mocks.read).not.toHaveBeenCalled() + }) + + it('rejects an undeclared query parameter', async () => { + const response = await GET(request('?foo=1')) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: { code: 'BAD_REQUEST' } }) + expect(mocks.read).not.toHaveBeenCalled() + }) + + /** + * The rollout subject and the key's expiry both come from the row + * `authenticateV2ApiKey` already read and validated. Passing them as input is + * what keeps the application layer out of the `api_key` table and off a + * second billing-owner lookup. + */ + it('passes the credential facts the authenticator resolved, not a re-read', async () => { + const authenticated = request() + await GET(authenticated) + + expect(mocks.read).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + rolloutUserId: 'user-1', + keyType: 'personal', + expiresAt: new Date('2027-01-01T00:00:00.000Z'), + }, + request: authenticated, + }) + }) +}) diff --git a/apps/sim/app/api/v2/meta/route.ts b/apps/sim/app/api/v2/meta/route.ts new file mode 100644 index 00000000000..0c6f3328bce --- /dev/null +++ b/apps/sim/app/api/v2/meta/route.ts @@ -0,0 +1,47 @@ +import { v2MetaOperations } from '@/lib/api/application/operations' +import { readV2ApiCapabilities } from '@/lib/api/application/read-v2-api-capabilities' +import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/meta — Report the calling key's rollout cohort and lifecycle. + * + * The only route in `/api/v2` that is exempt from the rollout gate, and the + * exemption is the point: every other endpoint answers a 404 that is + * byte-identical to the unknown-path catch-all's, so a caller outside the cohort + * cannot tell "not in the rollout" from "no such endpoint" and has nothing to + * act on. Gating this endpoint too would give it the same ambiguity and leave + * the question permanently unanswerable. + * + * Exempting it does not weaken that concealment. Authentication still runs + * first, so the only fact disclosed is one about the caller's own credential, + * disclosed to a caller who has already proved it holds that credential. It is + * not a cross-tenant signal, not a resource-existence signal, and says nothing + * about any other account. A request with no key, or a key for another account, + * learns nothing it did not already hold. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetMetaContract, + auth: v2ApiKeyAuth, + operation: v2MetaOperations.read, + gate: 'exempt', + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: (_request, credential) => ({ + rolloutUserId: credential.rolloutUserId, + keyType: credential.keyType, + expiresAt: credential.keyExpiresAt, + }), + useCase: readV2ApiCapabilities, + present: ({ v2Enabled, keyType, expiresAt }) => ({ + data: { v2Enabled, keyType, expiresAt: expiresAt?.toISOString() ?? null }, + }), +}) diff --git a/apps/sim/app/api/v2/skills/[id]/editors/route.test.ts b/apps/sim/app/api/v2/skills/[skillId]/editors/route.test.ts similarity index 96% rename from apps/sim/app/api/v2/skills/[id]/editors/route.test.ts rename to apps/sim/app/api/v2/skills/[skillId]/editors/route.test.ts index 1f975c06269..181867c7c88 100644 --- a/apps/sim/app/api/v2/skills/[id]/editors/route.test.ts +++ b/apps/sim/app/api/v2/skills/[skillId]/editors/route.test.ts @@ -38,7 +38,7 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ }, })) -import { DELETE, GET, POST } from '@/app/api/v2/skills/[id]/editors/route' +import { DELETE, GET, POST } from '@/app/api/v2/skills/[skillId]/editors/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const SKILL_ID = 'skill-1' @@ -50,7 +50,7 @@ const AUTH = { rateLimitSubscription: null, keyType: 'personal' as const, } -const context = { params: Promise.resolve({ id: SKILL_ID }) } +const context = { params: Promise.resolve({ skillId: SKILL_ID }) } const editor = { id: 'membership-1', userId: 'user-2', @@ -77,7 +77,7 @@ function request(method: 'GET' | 'POST' | 'DELETE', body?: unknown) { }) } -describe('/api/v2/skills/[id]/editors', () => { +describe('/api/v2/skills/[skillId]/editors', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(AUTH) diff --git a/apps/sim/app/api/v2/skills/[id]/editors/route.ts b/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts similarity index 94% rename from apps/sim/app/api/v2/skills/[id]/editors/route.ts rename to apps/sim/app/api/v2/skills/[skillId]/editors/route.ts index 3b4a4791c2f..dbd76d080b3 100644 --- a/apps/sim/app/api/v2/skills/[id]/editors/route.ts +++ b/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts @@ -39,7 +39,7 @@ function toV2SkillEditor(editor: SkillEditor): V2SkillEditor { } function skillEditorCursorScope(skillId: string, query: { workspaceId: string }) { - return cursorScopeKey(cursorRoute(v2ListSkillEditorsContract, { id: skillId }), { + return cursorScopeKey(cursorRoute(v2ListSkillEditorsContract, { skillId }), { workspaceId: query.workspaceId, }) } @@ -51,7 +51,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: skillResourceErrorPolicy, mapInput: ({ params, query }) => ({ - skillId: params.id, + skillId: params.skillId, workspaceId: query.workspaceId, sortBy: query.sortBy, sortOrder: query.sortOrder, @@ -59,7 +59,7 @@ export const GET = defineV2JsonRoute({ offset: decodeOffsetCursor( query.cursor, cursorSortKey(query.sortBy, query.sortOrder), - skillEditorCursorScope(params.id, query) + skillEditorCursorScope(params.skillId, query) ), }), useCase: listSkillEditorsUseCase, @@ -68,7 +68,7 @@ export const GET = defineV2JsonRoute({ nextCursor: hasMore ? encodeOffsetCursor( cursorSortKey(query.sortBy, query.sortOrder), - skillEditorCursorScope(params.id, query), + skillEditorCursorScope(params.skillId, query), offset + limit ) : null, @@ -82,7 +82,7 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: skillResourceErrorPolicy, mapInput: ({ params, body }) => ({ - skillId: params.id, + skillId: params.skillId, workspaceId: body.workspaceId, target: { kind: 'email' as const, email: body.email }, }), @@ -107,7 +107,7 @@ export const DELETE = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: skillResourceErrorPolicy, mapInput: ({ params, query }) => ({ - skillId: params.id, + skillId: params.skillId, workspaceId: query.workspaceId, target: { kind: 'email' as const, email: query.email }, }), diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[skillId]/route.test.ts similarity index 97% rename from apps/sim/app/api/v2/skills/[id]/route.test.ts rename to apps/sim/app/api/v2/skills/[skillId]/route.test.ts index 734d4b7da28..6ea33fed68d 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.test.ts +++ b/apps/sim/app/api/v2/skills/[skillId]/route.test.ts @@ -56,7 +56,7 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ deleteSkillUseCase: { operation: { id: 'skills.delete' }, execute: mocks.remove }, })) -import { DELETE, GET, PATCH } from '@/app/api/v2/skills/[id]/route' +import { DELETE, GET, PATCH } from '@/app/api/v2/skills/[skillId]/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-personal' } @@ -84,7 +84,7 @@ const skill = { createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-02T00:00:00Z'), } -const context = { params: Promise.resolve({ id: skill.id }) } +const context = { params: Promise.resolve({ skillId: skill.id }) } /** * The read and delete verbs scope themselves with `?workspaceId=`; the write @@ -104,7 +104,7 @@ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { }) } -describe('/api/v2/skills/[id]', () => { +describe('/api/v2/skills/[skillId]', () => { beforeEach(() => { vi.clearAllMocks() mocks.authenticate.mockResolvedValue(AUTH) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[skillId]/route.ts similarity index 90% rename from apps/sim/app/api/v2/skills/[id]/route.ts rename to apps/sim/app/api/v2/skills/[skillId]/route.ts index dbf84000680..6414280f4c4 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.ts +++ b/apps/sim/app/api/v2/skills/[skillId]/route.ts @@ -25,19 +25,19 @@ const skillResourceErrorPolicy = createV2ResourceConcealmentPolicy({ notFoundMessage: 'Skill not found', }) -/** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ +/** GET /api/v2/skills/[skillId] — Fetch a single skill, including its body. */ export const GET = defineV2JsonRoute({ contract: v2GetSkillContract, operation: skillOperations.read, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: skillResourceErrorPolicy, - mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, skillId: params.id }), + mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, skillId: params.skillId }), useCase: getSkillUseCase, present: ({ skill }) => ({ data: toV2Skill(skill) }), }) -/** PATCH /api/v2/skills/[id] — Update a skill. */ +/** PATCH /api/v2/skills/[skillId] — Update a skill. */ export const PATCH = defineV2JsonRoute({ contract: v2UpdateSkillContract, operation: skillOperations.update, @@ -46,7 +46,7 @@ export const PATCH = defineV2JsonRoute({ errorPolicy: skillResourceErrorPolicy, mapInput: ({ params, body }) => ({ ...body, - skillId: params.id, + skillId: params.skillId, source: 'api' as const, }), useCase: updateSkillUseCase, @@ -67,7 +67,7 @@ export const PATCH = defineV2JsonRoute({ present: ({ skill }) => ({ data: toV2Skill(skill) }), }) -/** DELETE /api/v2/skills/[id] — Delete a skill. */ +/** DELETE /api/v2/skills/[skillId] — Delete a skill. */ export const DELETE = defineV2JsonRoute({ contract: v2DeleteSkillContract, operation: skillOperations.delete, @@ -76,7 +76,7 @@ export const DELETE = defineV2JsonRoute({ errorPolicy: skillResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, - skillId: params.id, + skillId: params.skillId, source: 'api' as const, }), useCase: deleteSkillUseCase, diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts deleted file mode 100644 index 48644f53b56..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' -import { tableOperations } from '@/lib/table/application/operations' -import { startTableRun } from '@/lib/table/application/runs' - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -export const POST = defineV2JsonRoute({ - contract: v2RunTableColumnContract, - operation: tableOperations.startRun, - auth: v2ApiKeyAuth, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableRowsErrorPolicy, - mapInput: ({ params, body }) => ({ - kind: 'selection' as const, - tableId: params.tableId, - assertedWorkspaceId: body.workspaceId, - groupIds: body.groupIds, - mode: body.runMode, - rowIds: body.rowIds, - predicate: body.filter, - excludeRowIds: body.excludeRowIds, - limit: body.limit, - }), - useCase: startTableRun, - present: ({ dispatchId }) => ({ data: { dispatchId } }), -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route.test.ts new file mode 100644 index 00000000000..be4a89a3db9 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + */ + +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ readDispatch: vi.fn(), cancelDispatch: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/table/application/runs', () => ({ + readTableDispatch: { operation: { id: 'tables.runs.read' }, execute: mocks.readDispatch }, + cancelTableDispatch: { operation: { id: 'tables.runs.cancel' }, execute: mocks.cancelDispatch }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET } from '@/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function dispatch(status: 'pending' | 'dispatching' | 'complete' | 'cancelled') { + return { + id: 'dispatch-1', + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + requestId: 'request-1', + mode: 'all' as const, + scope: { groupIds: ['group-1'] }, + status, + cursor: 42, + limit: null, + processedCount: 3, + isManualRun: true, + triggeredByUserId: 'user-1', + requestedAt: new Date('2026-01-01T00:00:00Z'), + completedAt: status === 'complete' ? new Date('2026-01-01T00:01:00Z') : null, + cancelledAt: status === 'cancelled' ? new Date('2026-01-01T00:02:00Z') : null, + } +} + +const PATH = 'http://localhost/api/v2/tables/table-1/dispatches/dispatch-1' +const PARAMS = { tableId: 'table-1', dispatchId: 'dispatch-1' } + +function read(query = `?workspaceId=${WORKSPACE_ID}`) { + const request = new NextRequest(`${PATH}${query}`, { + method: 'GET', + headers: { 'x-api-key': 'secret' }, + }) + return { + request, + response: GET(request, { params: Promise.resolve(PARAMS) }), + } +} + +function cancel(query = `?workspaceId=${WORKSPACE_ID}`) { + const request = new NextRequest(`${PATH}${query}`, { + method: 'DELETE', + headers: { 'x-api-key': 'secret' }, + }) + return { + request, + response: DELETE(request, { params: Promise.resolve(PARAMS) }), + } +} + +describe('GET /api/v2/tables/[tableId]/dispatches/[dispatchId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.readDispatch.mockResolvedValue({ dispatch: dispatch('dispatching') }) + mocks.cancelDispatch.mockResolvedValue({ dispatch: dispatch('cancelled') }) + }) + + it('delegates the parent table, dispatch id, and asserted workspace', async () => { + const invocation = read() + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.readDispatch).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { tableId: 'table-1', dispatchId: 'dispatch-1', workspaceId: WORKSPACE_ID }, + request: invocation.request, + }) + }) + + /** + * The regression this route exists to avoid: the first-party active-dispatch + * schema stops at the in-flight states, and v2 parses responses outbound, so + * a poller reaching the state it was waiting for would have got a 500. + */ + it.each([ + ['pending', 'pending'], + ['dispatching', 'dispatching'], + ['complete', 'complete'], + /** Stored with two `l`s; published with one, like every other table status. */ + ['cancelled', 'canceled'], + ] as const)('answers 200 for a %s dispatch', async (stored, published) => { + mocks.readDispatch.mockResolvedValue({ dispatch: dispatch(stored) }) + + const response = await read().response + + expect(response.status).toBe(200) + expect((await response.json()).data.status).toBe(published) + }) + + it('never publishes the scheduler cursor', async () => { + const response = await read().response + + expect((await response.json()).data).not.toHaveProperty('cursor') + }) + + it('conceals a dispatch the caller may not reach as a 404', async () => { + mocks.readDispatch.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Table run dispatch not found') + ) + + const response = await read().response + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects a read that names no workspace', async () => { + const response = await read('').response + + expect(response.status).toBe(400) + expect(mocks.readDispatch).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated read before delegation', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await read().response + + expect(response.status).toBe(401) + expect(mocks.readDispatch).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v2/tables/[tableId]/dispatches/[dispatchId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.cancelDispatch.mockResolvedValue({ dispatch: dispatch('cancelled') }) + }) + + it('cancels the dispatch the path names and returns its settled state', async () => { + const invocation = cancel() + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.cancelDispatch).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { tableId: 'table-1', dispatchId: 'dispatch-1', workspaceId: WORKSPACE_ID }, + request: invocation.request, + }) + expect((await response.json()).data.status).toBe('canceled') + }) + + /** + * Cross-table concealment: the dispatch exists, but not under the table in the path, so + * the answer must be indistinguishable from an id that never existed. + */ + it('conceals a dispatch belonging to another table as a 404', async () => { + mocks.cancelDispatch.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Table run dispatch not found') + ) + + const response = await cancel().response + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects a cancel that names no workspace', async () => { + const response = await cancel('').response + + expect(response.status).toBe(400) + expect(mocks.cancelDispatch).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated cancel before delegation', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await cancel().response + + expect(response.status).toBe(401) + expect(mocks.cancelDispatch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route.ts new file mode 100644 index 00000000000..c57a1790175 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route.ts @@ -0,0 +1,52 @@ +import { + v2CancelTableDispatchContract, + v2GetTableDispatchContract, +} from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { tableOperations } from '@/lib/table/application/operations' +import { cancelTableDispatch, readTableDispatch } from '@/lib/table/application/runs' +import { presentV2TableDispatch } from '@/app/api/v2/tables/presenters' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Polls the dispatch `POST /tables/{tableId}/dispatches` returned an id for. + * Answers in every lifecycle state, terminal ones included — a poller that + * cannot read the state it is waiting for would never stop. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetTableDispatchContract, + operation: tableOperations.readRun, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + dispatchId: params.dispatchId, + workspaceId: query.workspaceId, + }), + useCase: readTableDispatch, + present: ({ dispatch }) => ({ data: presentV2TableDispatch(dispatch) }), +}) + +/** + * Cancels one dispatch by id. `POST /tables/{tableId}/cancel-runs` cancels by predicate + * scope and cannot name a single dispatch, so a caller holding only the `dispatchId` the + * run-creating endpoint handed back had no way to stop it. + */ +export const DELETE = defineV2JsonRoute({ + contract: v2CancelTableDispatchContract, + operation: tableOperations.cancelRuns, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + dispatchId: params.dispatchId, + workspaceId: query.workspaceId, + }), + useCase: cancelTableDispatch, + present: ({ dispatch }) => ({ data: presentV2TableDispatch(dispatch) }), +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.test.ts similarity index 57% rename from apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts rename to apps/sim/app/api/v2/tables/[tableId]/dispatches/route.test.ts index e9257648ab5..bd01e45352c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.test.ts @@ -17,9 +17,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { - mocks: { - startRun: vi.fn(), - }, + mocks: { listDispatches: vi.fn(), startRun: vi.fn() }, MockTableRowsValidationError, } }) @@ -31,10 +29,11 @@ vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, })) vi.mock('@/lib/table/application/runs', () => ({ + listTableDispatches: { operation: { id: 'tables.runs.read' }, execute: mocks.listDispatches }, startTableRun: { operation: { id: 'tables.runs.start' }, execute: mocks.startRun }, })) -import { POST } from '@/app/api/v2/tables/[tableId]/columns/run/route' +import { GET, POST } from '@/app/api/v2/tables/[tableId]/dispatches/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { @@ -49,9 +48,72 @@ const AUTH = { rateLimitSubscription: null, keyType: 'workspace' as const, } +const DISPATCH = { + id: 'dispatch-1', + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + requestId: 'request-1', + mode: 'incomplete' as const, + scope: { groupIds: ['group-1'], rowIds: ['row-1'] }, + status: 'pending' as const, + cursor: 0, + limit: { type: 'rows' as const, max: 100 }, + processedCount: 0, + isManualRun: false, + triggeredByUserId: null, + requestedAt: new Date('2026-01-01T00:00:00Z'), + completedAt: null, + cancelledAt: null, +} + +function list(query = `?workspaceId=${WORKSPACE_ID}`) { + const request = new NextRequest(`http://localhost/api/v2/tables/table-1/dispatches${query}`, { + method: 'GET', + headers: { 'x-api-key': 'secret' }, + }) + return { + request, + response: GET(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } +} + +describe('GET /api/v2/tables/[tableId]/dispatches', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.listDispatches.mockResolvedValue({ table: { id: 'table-1' }, dispatches: [DISPATCH] }) + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' }) + }) + + it('delegates the canonical table scope and returns the full set', async () => { + const invocation = list() + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.listDispatches).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { tableId: 'table-1', assertedWorkspaceId: WORKSPACE_ID }, + request: invocation.request, + }) + const body = await response.json() + expect(body.data).toHaveLength(1) + expect(body.nextCursor).toBeNull() + }) + + /** The set is dispatcher-bounded, so there is no page for a limit to select. */ + it('rejects pagination parameters this list does not implement', async () => { + const response = await list(`?workspaceId=${WORKSPACE_ID}&limit=10`).response + + expect(response.status).toBe(400) + expect(mocks.listDispatches).not.toHaveBeenCalled() + }) +}) -function call(body: unknown) { - const request = new NextRequest('http://localhost/api/v2/tables/table-1/columns/run', { +function create(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/dispatches', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), @@ -62,7 +124,11 @@ function call(body: unknown) { } } -describe('POST /api/v2/tables/[tableId]/columns/run', () => { +/** + * The create moved here from `POST /columns/run`: it mints the resource this path already + * lists, gets, and cancels, so all four verbs now name the same thing. + */ +describe('POST /api/v2/tables/[tableId]/dispatches', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(AUTH) @@ -74,7 +140,7 @@ describe('POST /api/v2/tables/[tableId]/columns/run', () => { it('delegates the bounded run selection and presents the dispatch id', async () => { const predicate = { all: [{ field: 'status', op: 'eq', value: 'ready' }] } - const invocation = call({ + const invocation = create({ workspaceId: WORKSPACE_ID, groupIds: ['group-1'], runMode: 'incomplete', @@ -106,7 +172,7 @@ describe('POST /api/v2/tables/[tableId]/columns/run', () => { it('preserves an authoritative null dispatch as a no-op', async () => { mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: null }) - const response = await call({ + const response = await create({ workspaceId: WORKSPACE_ID, groupIds: ['group-1'], }).response @@ -116,7 +182,7 @@ describe('POST /api/v2/tables/[tableId]/columns/run', () => { }) it('rejects mutually exclusive row and filter scopes before delegation', async () => { - const response = await call({ + const response = await create({ workspaceId: WORKSPACE_ID, groupIds: ['group-1'], rowIds: ['row-1'], @@ -128,7 +194,7 @@ describe('POST /api/v2/tables/[tableId]/columns/run', () => { }) it('rejects an empty group selection before delegation', async () => { - const response = await call({ workspaceId: WORKSPACE_ID, groupIds: [] }).response + const response = await create({ workspaceId: WORKSPACE_ID, groupIds: [] }).response expect(response.status).toBe(400) expect(mocks.startRun).not.toHaveBeenCalled() @@ -137,7 +203,7 @@ describe('POST /api/v2/tables/[tableId]/columns/run', () => { it('rejects an unauthenticated request', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - const response = await call({ workspaceId: WORKSPACE_ID, groupIds: ['group-1'] }).response + const response = await create({ workspaceId: WORKSPACE_ID, groupIds: ['group-1'] }).response expect(response.status).toBe(401) expect((await response.json()).error.code).toBe('UNAUTHORIZED') diff --git a/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.ts b/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.ts new file mode 100644 index 00000000000..def9553efea --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.ts @@ -0,0 +1,61 @@ +import { + v2CreateTableDispatchContract, + v2ListTableDispatchesContract, +} from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { listTableDispatches, startTableRun } from '@/lib/table/application/runs' +import { presentV2TableDispatch } from '@/app/api/v2/tables/presenters' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Every dispatch still in flight on one table. Unpaged: the dispatcher bounds + * how many dispatches a table can have active, so `nextCursor` is always null + * and there is no page for a `limit` to select. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListTableDispatchesContract, + operation: tableOperations.readRun, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: listTableDispatches, + present: ({ dispatches }) => ({ + data: dispatches.map(presentV2TableDispatch), + nextCursor: null, + }), +}) + +/** + * Starts a run and returns the `dispatchId` naming it, so create, list, get, and cancel are + * one resource on one path. A `null` `dispatchId` means the run settled inline and there is + * nothing to poll. + */ +export const POST = defineV2JsonRoute({ + contract: v2CreateTableDispatchContract, + operation: tableOperations.startRun, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + kind: 'selection' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + groupIds: body.groupIds, + mode: body.runMode, + rowIds: body.rowIds, + predicate: body.filter, + excludeRowIds: body.excludeRowIds, + limit: body.limit, + }), + useCase: startTableRun, + present: ({ dispatchId }) => ({ data: { dispatchId } }), +}) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/[exportId]/download/route.ts similarity index 96% rename from apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts rename to apps/sim/app/api/v2/tables/[tableId]/exports/[exportId]/download/route.ts index 88c00c6a9e1..56f7dfa2be4 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/[exportId]/download/route.ts @@ -14,6 +14,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ + tableId: params.tableId, exportId: params.exportId, workspaceId: query.workspaceId, }), diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/[exportId]/route.ts similarity index 96% rename from apps/sim/app/api/v2/tables/exports/[exportId]/route.ts rename to apps/sim/app/api/v2/tables/[tableId]/exports/[exportId]/route.ts index d962454a3ad..8fd25f7a493 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/[exportId]/route.ts @@ -18,6 +18,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ + tableId: params.tableId, exportId: params.exportId, workspaceId: query.workspaceId, }), @@ -32,6 +33,7 @@ export const DELETE = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ + tableId: params.tableId, exportId: params.exportId, workspaceId: query.workspaceId, }), diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts index b2c700871c9..d295961871b 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts @@ -36,9 +36,10 @@ vi.mock('@/lib/table/application/exports', () => ({ }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET as DOWNLOAD } from '@/app/api/v2/tables/[tableId]/exports/[exportId]/download/route' +import { GET as STATUS } from '@/app/api/v2/tables/[tableId]/exports/[exportId]/route' import { POST } from '@/app/api/v2/tables/[tableId]/exports/route' -import { GET as DOWNLOAD } from '@/app/api/v2/tables/exports/[exportId]/download/route' -import { GET as STATUS } from '@/app/api/v2/tables/exports/[exportId]/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const principal = { @@ -102,17 +103,17 @@ describe('v2 table exports', () => { expiresAt: '2026-01-01T01:00:00.000Z', }) const statusRequest = new NextRequest( - `http://localhost:3000/api/v2/tables/exports/export-1?workspaceId=${WORKSPACE_ID}` + `http://localhost:3000/api/v2/tables/table-1/exports/export-1?workspaceId=${WORKSPACE_ID}` ) const downloadRequest = new NextRequest( - `http://localhost:3000/api/v2/tables/exports/export-1/download?workspaceId=${WORKSPACE_ID}` + `http://localhost:3000/api/v2/tables/table-1/exports/export-1/download?workspaceId=${WORKSPACE_ID}` ) const status = await STATUS(statusRequest, { - params: Promise.resolve({ exportId: 'export-1' }), + params: Promise.resolve({ tableId: 'table-1', exportId: 'export-1' }), }) const download = await DOWNLOAD(downloadRequest, { - params: Promise.resolve({ exportId: 'export-1' }), + params: Promise.resolve({ tableId: 'table-1', exportId: 'export-1' }), }) expect(status.status).toBe(200) @@ -121,12 +122,12 @@ describe('v2 table exports', () => { expect((await download.json()).data.fileName).toBe('Contacts.csv') expect(mocks.read).toHaveBeenCalledWith({ principal, - input: { exportId: 'export-1', workspaceId: WORKSPACE_ID }, + input: { tableId: 'table-1', exportId: 'export-1', workspaceId: WORKSPACE_ID }, request: statusRequest, }) expect(mocks.download).toHaveBeenCalledWith({ principal, - input: { exportId: 'export-1', workspaceId: WORKSPACE_ID }, + input: { tableId: 'table-1', exportId: 'export-1', workspaceId: WORKSPACE_ID }, request: downloadRequest, }) }) @@ -145,3 +146,24 @@ describe('v2 table exports', () => { expect((await response.json()).error.code).toBe('UNAUTHORIZED') }) }) + +/** + * Cross-table concealment. Nesting the export reads under their parent puts the table in the + * path, so an `exportId` belonging to a DIFFERENT table must answer the same not-found an id + * that never existed does — otherwise the id space leaks which table owns which export. + */ +describe('nested export addressing', () => { + it('conceals an export belonging to another table as a 404', async () => { + mocks.read.mockRejectedValueOnce(new OrchestrationError('not_found', 'Table export not found')) + const request = new NextRequest( + `http://localhost:3000/api/v2/tables/table-other/exports/export-1?workspaceId=${WORKSPACE_ID}` + ) + + const response = await STATUS(request, { + params: Promise.resolve({ tableId: 'table-other', exportId: 'export-1' }), + }) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts index 93db3bb653e..0342e38f554 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts @@ -123,6 +123,7 @@ describe('POST /api/v2/tables/[tableId]/query', () => { cursor: undefined, limit: 100, includeTotal: false, + includeRunState: false, }, request: invocation.request, }) @@ -156,6 +157,7 @@ describe('POST /api/v2/tables/[tableId]/query', () => { cursor: undefined, limit: 100, includeTotal: false, + includeRunState: false, }, request: invocation.request, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index 5b9d0516056..79695721060 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -40,12 +40,15 @@ export const POST = defineV2JsonRoute({ limit: body.limit === undefined ? V2_DEFAULT_ROW_LIMIT : body.limit === 0 ? undefined : body.limit, includeTotal: false, + includeRunState: body.includeRunState, }), useCase: queryTableRows, - present: ({ table, rows, nextCursor }, { params }) => { + present: ({ table, rows, nextCursor }, { params, body }) => { const toNamedRow = namedRowMapper(table.schema.columns) return { - data: rows.map((row) => toApiRow(row, toNamedRow)), + data: rows.map((row) => + toApiRow(row, toNamedRow, body.includeRunState ? row.executions : undefined) + ), nextCursor: nextCursor ? encodeScopedCursor(queryRowCursorScope(params.tableId), nextCursor) : null, diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts new file mode 100644 index 00000000000..3af5f618ff4 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ + +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + restore: vi.fn(), + getUserEmailsByIds: vi.fn(), + getMaxRowsPerTable: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/table/application/tables', () => ({ + restoreTableUseCase: { operation: { id: 'tables.restore' }, execute: mocks.restore }, +})) +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) +vi.mock('@/lib/table/billing', () => ({ getMaxRowsPerTable: mocks.getMaxRowsPerTable })) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/tables/[tableId]/restore/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + createdBy: 'owner-1', + name: 'Contacts (restored 4f2a)', + description: null, + schema: { columns: [] }, + rowCount: 0, + maxRows: 100, + folderId: null, + metadata: null, + locks: { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/restore', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } +} + +describe('POST /api/v2/tables/[tableId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['owner-1', 'owner@example.com']])) + mocks.getMaxRowsPerTable.mockResolvedValue(5000) + mocks.restore.mockResolvedValue({ table: TABLE, folderPath: '/' }) + }) + + it('delegates the asserted workspace and returns the restored table', async () => { + const invocation = call({ workspaceId: WORKSPACE_ID }) + const response = await invocation.response + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.id).toBe('table-1') + expect(body.data.name).toBe(TABLE.name) + expect(mocks.restore).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, + request: invocation.request, + }) + }) + + it('answers 409 for a table that is not archived', async () => { + mocks.restore.mockRejectedValueOnce(new OrchestrationError('conflict', 'Table is not archived')) + + const response = await call({ workspaceId: WORKSPACE_ID }).response + + expect(response.status).toBe(409) + expect((await response.json()).error.code).toBe('CONFLICT') + }) + + it('conceals a table in another workspace as a 404', async () => { + mocks.restore.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await call({ workspaceId: WORKSPACE_ID }).response + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects a body that names no workspace', async () => { + const response = await call({}).response + + expect(response.status).toBe(400) + expect(mocks.restore).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated restore before parsing', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call({ workspaceId: WORKSPACE_ID }).response + + expect(response.status).toBe(401) + expect(mocks.restore).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts new file mode 100644 index 00000000000..3e282be5b5a --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts @@ -0,0 +1,29 @@ +import { v2RestoreTableContract } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { tableOperations } from '@/lib/table/application/operations' +import { restoreTableUseCase } from '@/lib/table/application/tables' +import { toApiTable } from '@/app/api/v2/tables/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Un-archives a table `DELETE /tables/{tableId}` archived, with the rows, + * views, and groups archived alongside it. Without this a headless delete was + * unrecoverable: `scope=archived` on the list can find the table, but nothing + * could bring it back. + */ +export const POST = defineV2JsonRoute({ + contract: v2RestoreTableContract, + operation: tableOperations.restore, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + workspaceId: body.workspaceId, + }), + useCase: restoreTableUseCase, + present: async ({ table, folderPath }) => ({ data: await toApiTable(table, folderPath) }), +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts index 31671eb4fa4..81b0ce0ea56 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts @@ -19,6 +19,7 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { return { mocks: { startRun: vi.fn(), + readEnrichment: vi.fn(), }, MockTableRowsValidationError, } @@ -29,13 +30,17 @@ vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, + readTableRowEnrichmentDetail: { + operation: { id: 'tables.rows.read' }, + execute: mocks.readEnrichment, + }, })) vi.mock('@/lib/table/application/runs', () => ({ startTableRun: { operation: { id: 'tables.runs.start' }, execute: mocks.startRun }, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { POST } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route' +import { GET, POST } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { @@ -131,3 +136,84 @@ describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () = expect((await response.json()).error.code).toBe('NOT_FOUND') }) }) + +describe('GET /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => { + const DETAIL = { + startedAt: '2026-01-01T00:00:00.000Z', + completedAt: '2026-01-01T00:00:02.000Z', + durationMs: 2000, + totalCost: 0.02, + matchedProvider: 'hunter', + aborted: false, + providers: [ + { + id: 'hunter', + label: 'Hunter', + toolId: 'hunter_find_email', + status: 'matched' as const, + cost: 0.02, + durationMs: 2000, + error: null, + }, + ], + } + + function read() { + const request = new NextRequest( + `http://localhost/api/v2/tables/table-1/rows/row-1/enrichment/group-1?workspaceId=${WORKSPACE_ID}`, + { method: 'GET', headers: { 'x-api-key': 'secret' } } + ) + return { + request, + response: GET(request, { + params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }), + }), + } + } + + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.readEnrichment.mockResolvedValue({ table: { id: 'table-1' }, detail: DETAIL }) + }) + + it('delegates the canonical row and group scope and publishes the cascade', async () => { + const invocation = read() + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: DETAIL }) + expect(mocks.readEnrichment).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + rowId: 'row-1', + groupId: 'group-1', + assertedWorkspaceId: WORKSPACE_ID, + }, + request: invocation.request, + }) + }) + + /** A cell that never ran is a real answer, not a missing resource. */ + it('answers null for a cell with no recorded run', async () => { + mocks.readEnrichment.mockResolvedValue({ table: { id: 'table-1' }, detail: null }) + + const response = await read().response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: null }) + }) + + it('rejects an unauthenticated read', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await read().response + + expect(response.status).toBe(401) + expect(mocks.readEnrichment).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts index bc229a9579b..d93d236c7d9 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -1,12 +1,46 @@ -import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables' +import { + v2GetRowEnrichmentContract, + v2RunRowEnrichmentContract, +} from '@/lib/api/contracts/v2/tables' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' import { tableOperations } from '@/lib/table/application/operations' +import { readTableRowEnrichmentDetail } from '@/lib/table/application/rows' import { startTableRun } from '@/lib/table/application/runs' +import { toApiEnrichmentDetail } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * The provider cascade behind one enrichment cell: which providers ran, what + * each cost and took, and which produced the match. + * + * Deliberately its own read rather than a field on the row: the breakdown can + * carry a dozen provider outcomes per cell, which is why the storage layer + * keeps it off the grid read and why `includeRunState` on the row surfaces + * reports the cell's status without it. + * + * A pure read, so the default `headSafe` stands. The `POST` on this same path + * starts a run — if a future `GET` here ever acquires a side effect, that flag + * must flip with it. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetRowEnrichmentContract, + operation: tableOperations.readRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + rowId: params.rowId, + groupId: params.groupId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readTableRowEnrichmentDetail, + present: ({ detail }) => ({ data: toApiEnrichmentDetail(detail) }), +}) + export const POST = defineV2JsonRoute({ contract: v2RunRowEnrichmentContract, operation: tableOperations.startRun, diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts index 08e8a510620..c48e7353271 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts @@ -105,7 +105,12 @@ describe('/api/v2/tables/[tableId]/rows/[rowId]', () => { }) expect(mocks.readRow).toHaveBeenCalledWith({ principal: PRINCIPAL, - input: { tableId: 'table-1', rowId: 'row-1', assertedWorkspaceId: WORKSPACE_ID }, + input: { + tableId: 'table-1', + rowId: 'row-1', + assertedWorkspaceId: WORKSPACE_ID, + includeRunState: false, + }, request: req, }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts index fe3ce419375..15e292ffd2f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -23,10 +23,11 @@ export const GET = defineV2JsonRoute({ tableId: params.tableId, rowId: params.rowId, assertedWorkspaceId: query.workspaceId, + includeRunState: query.includeRunState, }), useCase: readTableRow, - present: ({ table, row }) => ({ - data: toApiRow(row, namedRowMapper(table.schema.columns)), + present: ({ table, row, runState }) => ({ + data: toApiRow(row, namedRowMapper(table.schema.columns), runState), }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/bulk-update/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/bulk-update/route.test.ts new file mode 100644 index 00000000000..d24ddffa5fe --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/bulk-update/route.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ + +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { mocks: { batchUpdate: vi.fn() }, MockTableRowsValidationError } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + batchUpdateTableRows: { + operation: { id: 'tables.rows.update_many' }, + execute: mocks.batchUpdate, + }, +})) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/bulk-update/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/bulk-update', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } +} + +describe('POST /api/v2/tables/[tableId]/rows/bulk-update', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.batchUpdate.mockResolvedValue({ + table: { id: 'table-1' }, + affectedCount: 2, + affectedRowIds: ['row-1', 'row-2'], + }) + }) + + it('delegates every distinct patch under the strict public write policy', async () => { + const updates = [ + { rowId: 'row-1', data: { status: 'active' } }, + { rowId: 'row-2', data: { status: 'churned' } }, + ] + const invocation = call({ workspaceId: WORKSPACE_ID, updates }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { updatedCount: 2, updatedRowIds: ['row-1', 'row-2'] }, + }) + expect(mocks.batchUpdate).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + updates, + strictWrite: true, + dataKeying: 'names', + }, + request: invocation.request, + }) + }) + + it('rejects an empty bulk update before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, updates: [] }).response + + expect(response.status).toBe(400) + expect(mocks.batchUpdate).not.toHaveBeenCalled() + }) + + it('rejects a bulk update naming the same row twice', async () => { + const response = await call({ + workspaceId: WORKSPACE_ID, + updates: [ + { rowId: 'row-1', data: { status: 'active' } }, + { rowId: 'row-1', data: { status: 'churned' } }, + ], + }).response + + expect(response.status).toBe(400) + expect(mocks.batchUpdate).not.toHaveBeenCalled() + }) + + it('answers 400 when the bulk update names a row this table does not have', async () => { + mocks.batchUpdate.mockRejectedValueOnce( + new MockTableRowsValidationError('Rows not found: row-9') + ) + + const response = await call({ + workspaceId: WORKSPACE_ID, + updates: [{ rowId: 'row-9', data: { status: 'active' } }], + }).response + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('row-9') + }) + + it('rejects an unauthenticated write before parsing', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call({ workspaceId: WORKSPACE_ID, updates: [] }).response + + expect(response.status).toBe(401) + expect(mocks.batchUpdate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/bulk-update/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/bulk-update/route.ts new file mode 100644 index 00000000000..d8d2d423b6c --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/bulk-update/route.ts @@ -0,0 +1,38 @@ +import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' +import { v2BulkUpdateTableRowsContract } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { batchUpdateTableRows } from '@/lib/table/application/rows' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * One distinct patch per row, in one request. The sibling + * `PATCH /tables/{tableId}/rows` applies one patch to everything a predicate + * matches, so N different writes are N calls through it. + * + * An explicit body ceiling because a full bulk update is up to a thousand rows of + * arbitrary cell data; past it the caller gets a 413 rather than the 50 MB + * default every JSON body is otherwise held to. + */ +export const POST = defineV2JsonRoute({ + contract: v2BulkUpdateTableRowsContract, + operation: tableOperations.updateRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + parseOptions: { maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + updates: body.updates, + strictWrite: true, + dataKeying: 'names' as const, + }), + useCase: batchUpdateTableRows, + present: ({ affectedCount, affectedRowIds }) => ({ + data: { updatedCount: affectedCount, updatedRowIds: affectedRowIds }, + }), +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts index bb5ccea0a76..5e81c34fecc 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts @@ -40,6 +40,7 @@ vi.mock('@/lib/table/application/rows', () => ({ import { v2ListTableRowsContract } from '@/lib/api/contracts/v2/tables' import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { encodeScopedCursor } from '@/app/api/v2/lib/response' import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/[tableId]/rows/route' @@ -134,12 +135,78 @@ describe('/api/v2/tables/[tableId]/rows', () => { assertedWorkspaceId: WORKSPACE_ID, limit: 25, cursor: 'native-row-cursor', + includeRunState: false, }, request: req, }) expect((await response.json()).nextCursor).toBe(rowCursor('table-1', 'next-native-cursor')) }) + /** + * Run state is opt-in, and the default page must stay byte-identical to what + * shipped — the strip was original design, and only its rationale for lumping + * `executions` in with `position`/`orderKey` was wrong. + */ + it('omits run state from a page that did not request it', async () => { + mocks.listRows.mockResolvedValue({ + table: TABLE, + rows: [{ ...ROW, executions: { 'group-1': { status: 'error' } } }], + nextCursor: null, + }) + + const response = await GET(request('GET', undefined, `?workspaceId=${WORKSPACE_ID}`), CONTEXT) + + expect((await response.json()).data[0]).not.toHaveProperty('runState') + }) + + it('attaches run state to every row of a page that asked for it', async () => { + mocks.listRows.mockResolvedValue({ + table: TABLE, + rows: [ + { + ...ROW, + executions: { + 'group-1': { + status: 'error', + executionId: 'execution-1', + jobId: 'job-1', + workflowId: 'workflow-1', + error: 'boom', + }, + }, + }, + ], + nextCursor: null, + }) + + const response = await GET( + request('GET', undefined, `?workspaceId=${WORKSPACE_ID}&includeRunState=true`), + CONTEXT + ) + + expect(mocks.listRows).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ includeRunState: true }) }) + ) + expect((await response.json()).data[0].runState['group-1']).toMatchObject({ + status: 'error', + error: 'boom', + }) + }) + + it('answers 413 when a requested page of run state outgrows the ceiling', async () => { + mocks.listRows.mockRejectedValueOnce( + new OrchestrationError('payload_too_large', 'Run state for this page exceeds the limit') + ) + + const response = await GET( + request('GET', undefined, `?workspaceId=${WORKSPACE_ID}&includeRunState=true`), + CONTEXT + ) + + expect(response.status).toBe(413) + expect((await response.json()).error.code).toBe('PAYLOAD_TOO_LARGE') + }) + /** * The row codec binds the sort and predicate a page was produced under but * carries no table identity, so an unfiltered token from one table decoded diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts index 1c5382064d8..ec1e4362e7e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -44,12 +44,15 @@ export const GET = defineV2JsonRoute({ assertedWorkspaceId: query.workspaceId, limit: query.limit, cursor: readScopedCursor(query.cursor, tableRowCursorScope(params.tableId)), + includeRunState: query.includeRunState, }), useCase: listTableRows, - present: ({ table, rows, nextCursor }, { params }) => { + present: ({ table, rows, nextCursor }, { params, query }) => { const toNamedRow = namedRowMapper(table.schema.columns) return { - data: rows.map((row) => toApiRow(row, toNamedRow)), + data: rows.map((row) => + toApiRow(row, toNamedRow, query.includeRunState ? row.executions : undefined) + ), nextCursor: nextCursor ? encodeScopedCursor(tableRowCursorScope(params.tableId), nextCursor) : null, diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.test.ts similarity index 89% rename from apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts rename to apps/sim/app/api/v2/tables/[tableId]/rows/search/route.test.ts index 293935428a1..2386a5f4d54 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.test.ts @@ -18,7 +18,7 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { mocks: { - findRows: vi.fn(), + searchRows: vi.fn(), }, MockTableRowsValidationError, } @@ -29,11 +29,11 @@ vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, - findTableRows: { operation: { id: 'tables.rows.find' }, execute: mocks.findRows }, + searchTableRows: { operation: { id: 'tables.rows.search' }, execute: mocks.searchRows }, })) import { v2Error } from '@/app/api/v2/lib/response' -import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route' +import { POST } from '@/app/api/v2/tables/[tableId]/rows/search/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { @@ -55,7 +55,7 @@ const TABLE = { } function call(body: unknown) { - const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/find', { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/search', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), @@ -66,14 +66,14 @@ function call(body: unknown) { } } -describe('POST /api/v2/tables/[tableId]/rows/find', () => { +describe('POST /api/v2/tables/[tableId]/rows/search', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(AUTH) v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.findRows.mockResolvedValue({ + mocks.searchRows.mockResolvedValue({ table: TABLE, matches: [{ ordinal: 3, rowId: 'row-1', column: 'column-name' }], truncated: true, @@ -93,7 +93,7 @@ describe('POST /api/v2/tables/[tableId]/rows/find', () => { truncated: true, }, }) - expect(mocks.findRows).toHaveBeenCalledWith({ + expect(mocks.searchRows).toHaveBeenCalledWith({ principal: PRINCIPAL, input: { tableId: 'table-1', @@ -111,7 +111,7 @@ describe('POST /api/v2/tables/[tableId]/rows/find', () => { expect(response.status).toBe(400) expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() - expect(mocks.findRows).not.toHaveBeenCalled() + expect(mocks.searchRows).not.toHaveBeenCalled() }) it('stops at the rollout gate before the shared use case', async () => { @@ -120,7 +120,7 @@ describe('POST /api/v2/tables/[tableId]/rows/find', () => { const response = await call({ workspaceId: WORKSPACE_ID, q: 'ada' }).response expect(response.status).toBe(404) - expect(mocks.findRows).not.toHaveBeenCalled() + expect(mocks.searchRows).not.toHaveBeenCalled() }) it('rejects an unauthenticated request', async () => { diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts similarity index 80% rename from apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts rename to apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts index 0dfac4b811c..ed0ca3ea5d7 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts @@ -1,16 +1,16 @@ -import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' +import { v2SearchTableRowsContract } from '@/lib/api/contracts/v2/tables' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' import { tableOperations } from '@/lib/table/application/operations' -import { findTableRows } from '@/lib/table/application/rows' +import { searchTableRows } from '@/lib/table/application/rows' import { columnNameById } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 export const POST = defineV2JsonRoute({ - contract: v2FindTableRowsContract, - operation: tableOperations.findRows, + contract: v2SearchTableRowsContract, + operation: tableOperations.searchRows, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableRowsErrorPolicy, @@ -21,7 +21,7 @@ export const POST = defineV2JsonRoute({ predicate: body.predicate, sort: body.sort, }), - useCase: findTableRows, + useCase: searchTableRows, present: ({ table, matches, truncated }) => { const toColumnName = columnNameById(table.schema) return { diff --git a/apps/sim/app/api/v2/tables/bulk-delete/route.ts b/apps/sim/app/api/v2/tables/bulk-delete/route.ts new file mode 100644 index 00000000000..8ad15a08c62 --- /dev/null +++ b/apps/sim/app/api/v2/tables/bulk-delete/route.ts @@ -0,0 +1,31 @@ +import { v2BulkDeleteTablesContract } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { bulkDeleteTables } from '@/lib/table/application/bulk' +import { tableOperations } from '@/lib/table/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Archives a mixed selection of tables and deletes table folders in one + * authorized request. Archived tables stay recoverable through + * `POST /tables/{tableId}/restore`; a deleted folder cascades. + */ +export const POST = defineV2JsonRoute({ + contract: v2BulkDeleteTablesContract, + operation: tableOperations.bulkDelete, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.bulk, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + folderKeying: 'paths' as const, + tableIds: body.tableIds, + folders: body.folderPaths, + }), + useCase: bulkDeleteTables, + present: ({ deleted, skipped, notFound, failed, deletedItems }) => ({ + data: { deleted, skipped, notFound, failed, deletedItems }, + }), +}) diff --git a/apps/sim/app/api/v2/tables/folders/restore/route.test.ts b/apps/sim/app/api/v2/tables/folders/restore/route.test.ts new file mode 100644 index 00000000000..0cc460a3985 --- /dev/null +++ b/apps/sim/app/api/v2/tables/folders/restore/route.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ + +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ restore: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/table/application/folders', () => ({ + restoreTableFolderUseCase: { + operation: { id: 'tables.folders.restore' }, + execute: mocks.restore, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/tables/folders/restore/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function restored(name: string, path: string) { + const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + resourceType: 'table' as const, + name, + parentId: null, + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + return { + folder, + index: { + rowById: new Map([['folder-1', folder]]), + pathById: new Map([['folder-1', path]]), + idByPath: new Map([[path, 'folder-1']]), + }, + requestedPath: '/Reports', + restoredItems: { folders: 2, tables: 5 }, + } +} + +function restore(body: unknown) { + const request = new NextRequest('http://localhost:3000/api/v2/tables/folders/restore', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { request, response: POST(request) } +} + +describe('POST /api/v2/tables/folders/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.restore.mockResolvedValue(restored('Reports', '/Reports')) + }) + + it('delegates the workspace and archived path, and reports what came back', async () => { + const invocation = restore({ workspaceId: WORKSPACE_ID, path: '/Reports' }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + folder: { + name: 'Reports', + path: '/Reports', + parentPath: '/', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + restoredItems: { folders: 2, tables: 5 }, + }, + }) + expect(mocks.restore).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, path: '/Reports' }, + request: invocation.request, + }) + }) + + /** + * A folder whose parent is still archived is re-rooted rather than refused, so the response + * must report where it actually landed instead of echoing the requested path. + */ + it('reports the re-rooted path when the parent is still archived', async () => { + mocks.restore.mockResolvedValue(restored('Reports', '/Reports')) + + const response = await restore({ + workspaceId: WORKSPACE_ID, + path: '/Archive/Reports', + }).response + + expect(response.status).toBe(200) + expect((await response.json()).data.folder.path).toBe('/Reports') + }) + + /** + * A name an active sibling took while the folder was archived is deduplicated, not + * rejected — the caller cannot rename an archived folder, so a taken name would otherwise + * make it permanently unrestorable. + */ + it('reports the deduplicated name when an active sibling holds the original', async () => { + mocks.restore.mockResolvedValue(restored('Reports (1)', '/Reports%20%281%29')) + + const response = await restore({ workspaceId: WORKSPACE_ID, path: '/Reports' }).response + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.folder.name).toBe('Reports (1)') + expect(body.data.folder.path).toBe('/Reports%20%281%29') + }) + + it('answers 404 for a path no archived folder holds', async () => { + mocks.restore.mockRejectedValueOnce(new OrchestrationError('not_found', 'Folder not found')) + + const response = await restore({ workspaceId: WORKSPACE_ID, path: '/Nope' }).response + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects the workspace root before delegation', async () => { + const response = await restore({ workspaceId: WORKSPACE_ID, path: '/' }).response + + expect(response.status).toBe(400) + expect(mocks.restore).not.toHaveBeenCalled() + }) + + it('rejects an unknown body key', async () => { + const response = await restore({ + workspaceId: WORKSPACE_ID, + path: '/Reports', + recursive: true, + }).response + + expect(response.status).toBe(400) + expect(mocks.restore).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request before delegation', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await restore({ workspaceId: WORKSPACE_ID, path: '/Reports' }).response + + expect(response.status).toBe(401) + expect(mocks.restore).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/folders/restore/route.ts b/apps/sim/app/api/v2/tables/folders/restore/route.ts new file mode 100644 index 00000000000..f25e75ebf40 --- /dev/null +++ b/apps/sim/app/api/v2/tables/folders/restore/route.ts @@ -0,0 +1,32 @@ +import { v2RestoreTableFolderContract } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { restoreTableFolderUseCase } from '@/lib/table/application/folders' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2PathFolder } from '@/app/api/v2/lib/folders' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/tables/folders/restore — restore a soft-deleted table folder tree. + * + * `DELETE /api/v2/tables/folders` archives recursively, so without this a recursive delete + * was unrecoverable over the API: the archived tables stayed visible through + * `GET /api/v2/tables?scope=archived`, but nothing could put the folder structure back. + * + * Path-addressed, matching the rest of the v2 folder family. The path is the one the folder + * held when it was deleted; the response reports where it actually landed. + */ +export const POST = defineV2JsonRoute({ + contract: v2RestoreTableFolderContract, + auth: v2ApiKeyAuth, + operation: tableOperations.restoreFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }), + useCase: restoreTableFolderUseCase, + present: ({ folder, index, restoredItems }) => ({ + data: { folder: toV2PathFolder(folder, index, false), restoredItems }, + }), +}) diff --git a/apps/sim/app/api/v2/tables/move/route.test.ts b/apps/sim/app/api/v2/tables/move/route.test.ts new file mode 100644 index 00000000000..da35a80341f --- /dev/null +++ b/apps/sim/app/api/v2/tables/move/route.test.ts @@ -0,0 +1,201 @@ +/** + * @vitest-environment node + */ + +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ move: vi.fn(), remove: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/table/application/bulk', () => ({ + bulkMoveTables: { operation: { id: 'tables.bulk_move' }, execute: mocks.move }, + bulkDeleteTables: { operation: { id: 'tables.bulk_delete' }, execute: mocks.remove }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST as BULK_DELETE } from '@/app/api/v2/tables/bulk-delete/route' +import { POST as BULK_MOVE } from '@/app/api/v2/tables/move/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const EMPTY_OUTCOME = { skipped: [], notFound: [], failed: [] } + +function call(handler: (request: NextRequest) => Promise, path: string, body: unknown) { + const request = new NextRequest(`http://localhost/api/v2/tables/${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { request, response: handler(request) } +} + +describe('POST /api/v2/tables/move', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.move.mockResolvedValue({ + moved: [{ kind: 'table', id: 'table-1', name: 'Contacts' }], + ...EMPTY_OUTCOME, + }) + }) + + it('delegates the selection under path keying without resolving anything itself', async () => { + const invocation = call(BULK_MOVE, 'bulk-move', { + workspaceId: WORKSPACE_ID, + tableIds: ['table-1'], + folderPaths: ['/Sales'], + targetFolderPath: '/Revenue', + }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.move).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + assertedWorkspaceId: WORKSPACE_ID, + folderKeying: 'paths', + tableIds: ['table-1'], + folders: ['/Sales'], + targetFolder: '/Revenue', + }, + request: invocation.request, + }) + }) + + /** + * Omission is the workspace root, matching `POST /api/v2/files/move`. The use + * case still requires an explicit choice, so the route supplies the `null`. + */ + it('treats an omitted destination as the workspace root', async () => { + await call(BULK_MOVE, 'bulk-move', { + workspaceId: WORKSPACE_ID, + tableIds: ['table-1'], + }).response + + expect(mocks.move).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ targetFolder: null }) }) + ) + }) + + it('rejects an explicit null rather than accepting two spellings of the root', async () => { + const response = await call(BULK_MOVE, 'bulk-move', { + workspaceId: WORKSPACE_ID, + tableIds: ['table-1'], + targetFolderPath: null, + }).response + + expect(response.status).toBe(400) + expect(mocks.move).not.toHaveBeenCalled() + }) + + it('rejects an empty selection before delegation', async () => { + const response = await call(BULK_MOVE, 'bulk-move', { + workspaceId: WORKSPACE_ID, + }).response + + expect(response.status).toBe(400) + expect(mocks.move).not.toHaveBeenCalled() + }) + + /** + * These routes name a workspace, not one table, so a refusal has no table + * existence to conceal — an invalid destination is the caller's to fix and + * says so. + */ + it('reports an invalid destination as a 404 naming the folder', async () => { + mocks.move.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Folder not found in this workspace') + ) + + const response = await call(BULK_MOVE, 'bulk-move', { + workspaceId: WORKSPACE_ID, + tableIds: ['table-1'], + targetFolderPath: '/Ghost', + }).response + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Folder not found in this workspace') + }) + + it('rejects an unauthenticated move before parsing', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call(BULK_MOVE, 'bulk-move', { + workspaceId: WORKSPACE_ID, + tableIds: ['table-1'], + targetFolderPath: null, + }).response + + expect(response.status).toBe(401) + expect(mocks.move).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/v2/tables/bulk-delete', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.remove.mockResolvedValue({ + deleted: [{ kind: 'folder', id: '/Sales', name: '/Sales' }], + deletedItems: { tables: 3, folders: 1 }, + ...EMPTY_OUTCOME, + }) + }) + + it('publishes the per-item outcome and the cascade totals', async () => { + const response = await call(BULK_DELETE, 'bulk-delete', { + workspaceId: WORKSPACE_ID, + folderPaths: ['/Sales'], + }).response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + deleted: [{ kind: 'folder', id: '/Sales', name: '/Sales' }], + skipped: [], + notFound: [], + failed: [], + deletedItems: { tables: 3, folders: 1 }, + }, + }) + }) + + it('rejects a selection past the combined cap before delegation', async () => { + const response = await call(BULK_DELETE, 'bulk-delete', { + workspaceId: WORKSPACE_ID, + tableIds: Array.from({ length: 101 }, (_unused, index) => `table-${index}`), + }).response + + expect(response.status).toBe(400) + expect(mocks.remove).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/move/route.ts b/apps/sim/app/api/v2/tables/move/route.ts new file mode 100644 index 00000000000..f51dad51a83 --- /dev/null +++ b/apps/sim/app/api/v2/tables/move/route.ts @@ -0,0 +1,34 @@ +import { v2MoveTablesContract } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { bulkMoveTables } from '@/lib/table/application/bulk' +import { tableOperations } from '@/lib/table/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Moves a mixed selection of tables and table folders in one authorized + * request. Folders travel by canonical path, as everywhere else on v2; the + * path-to-folder lookup is authorization-sensitive and happens inside the use + * case, never here. + */ +export const POST = defineV2JsonRoute({ + contract: v2MoveTablesContract, + operation: tableOperations.bulkMove, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.bulk, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + folderKeying: 'paths' as const, + tableIds: body.tableIds, + folders: body.folderPaths, + // The use case requires an explicit choice; omission is the root. + targetFolder: body.targetFolderPath ?? null, + }), + useCase: bulkMoveTables, + present: ({ moved, skipped, notFound, failed }) => ({ + data: { moved, skipped, notFound, failed }, + }), +}) diff --git a/apps/sim/app/api/v2/tables/presenters.test.ts b/apps/sim/app/api/v2/tables/presenters.test.ts index 7b08ee059ac..49c6726d4c5 100644 --- a/apps/sim/app/api/v2/tables/presenters.test.ts +++ b/apps/sim/app/api/v2/tables/presenters.test.ts @@ -3,13 +3,16 @@ */ import { describe, expect, it } from 'vitest' +import { v2ApiRowSchema, v2EnrichmentRunDetailSchema } from '@/lib/api/contracts/v2/tables' import type { TableSchema, WorkflowGroup } from '@/lib/table/types' import { presentV2CreateTableImport, + presentV2TableDispatch, presentV2TableExport, presentV2TableImport, presentV2WorkflowGroup, } from '@/app/api/v2/tables/presenters' +import { toApiEnrichmentDetail, toApiRow } from '@/app/api/v2/tables/utils' const createdAt = new Date('2026-08-01T00:00:00.000Z') const importRecord = { @@ -138,3 +141,215 @@ describe('presentV2WorkflowGroup', () => { expect(presentV2WorkflowGroup(legacy, schema).outputs[0].columnName).toBe('score') }) }) + +describe('presentV2TableDispatch', () => { + const stored = { + id: 'dispatch-1', + tableId: 'table-1', + workspaceId: 'workspace-1', + requestId: 'request-1', + mode: 'incomplete' as const, + scope: { groupIds: ['group-1'], rowIds: ['row-1'], filter: { all: [] } }, + status: 'complete' as const, + cursor: 512, + limit: { type: 'rows' as const, max: 50 }, + processedCount: 12, + isManualRun: true, + triggeredByUserId: 'user-1', + requestedAt: new Date('2026-01-01T00:00:00.000Z'), + completedAt: new Date('2026-01-01T00:05:00.000Z'), + canceledAt: null, + } + + it('serializes lifecycle timestamps and keeps a terminal status readable', () => { + const presented = presentV2TableDispatch(stored) + + expect(presented.status).toBe('complete') + expect(presented.requestedAt).toBe('2026-01-01T00:00:00.000Z') + expect(presented.completedAt).toBe('2026-01-01T00:05:00.000Z') + expect(presented.canceledAt).toBeNull() + }) + + /** + * A field named `cursor` on a v2 resource reads as a pagination token, and the + * scheduler's internal identities have no public meaning. + */ + it('withholds the scheduler cursor and internal identities', () => { + const presented = presentV2TableDispatch(stored) + + expect(presented).not.toHaveProperty('cursor') + expect(presented).not.toHaveProperty('requestId') + expect(presented).not.toHaveProperty('triggeredByUserId') + }) + + /** The stored scope also carries a compiled filter, which is not public. */ + it('publishes only the addressable half of the run scope', () => { + expect(presentV2TableDispatch(stored).scope).toEqual({ + groupIds: ['group-1'], + rowIds: ['row-1'], + }) + }) +}) + +describe('toApiRow run state', () => { + const row = { + id: 'row-1', + data: { 'col-1': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + } + const identity = (data: Record) => data + + it('omits run state entirely when the read did not ask for it', () => { + expect(toApiRow(row, identity)).not.toHaveProperty('runState') + }) + + /** + * `jobId` is the async scheduler's identity and addresses nothing public; + * `enrichmentDetails` has its own sub-resource and is never hydrated here. + */ + it('drops the scheduler job id and the deep cascade payload', () => { + const presented = toApiRow(row, identity, { + 'group-1': { + status: 'completed', + executionId: 'execution-1', + jobId: 'job-1', + workflowId: 'workflow-1', + error: null, + enrichmentDetails: null, + }, + }) + + expect(presented.runState?.['group-1']).toEqual({ + status: 'completed', + executionId: 'execution-1', + workflowId: 'workflow-1', + error: null, + runningBlockIds: [], + blockErrors: {}, + canceledAt: null, + }) + }) + + it('carries the fields a caller polls a failed cell for', () => { + const presented = toApiRow(row, identity, { + 'group-1': { + status: 'error', + executionId: 'execution-1', + jobId: null, + workflowId: 'workflow-1', + error: 'boom', + runningBlockIds: ['block-1'], + blockErrors: { 'block-1': 'boom' }, + cancelledAt: '2026-01-03T00:00:00.000Z', + }, + }) + + expect(presented.runState?.['group-1']).toMatchObject({ + status: 'error', + error: 'boom', + runningBlockIds: ['block-1'], + blockErrors: { 'block-1': 'boom' }, + canceledAt: '2026-01-03T00:00:00.000Z', + }) + }) + + it('presents a row that has never run as an empty map, not an absent one', () => { + expect(toApiRow(row, identity, {}).runState).toEqual({}) + }) + + /** + * `status` is a `text` column read through a bare `as` cast, and the response + * schema is `.parse`d on the way out — a closed enum there turns one drifted + * row into a 500 on a well-formed read. + */ + it('publishes a stored status the domain union does not name', () => { + const presented = toApiRow(row, identity, { + 'group-1': { + status: 'reconciling' as never, + executionId: null, + jobId: null, + workflowId: 'workflow-1', + error: null, + }, + }) + + expect(() => v2ApiRowSchema.parse(presented)).not.toThrow() + expect(v2ApiRowSchema.parse(presented).runState?.['group-1'].status).toBe('reconciling') + }) +}) + +/** + * `tableRowExecutions.enrichmentDetails` is schemaless jsonb read back through a + * bare `as` cast, so every declared key is a claim about the writer rather than + * a property of the column. + */ +describe('toApiEnrichmentDetail', () => { + it('projects a complete blob unchanged', () => { + const detail = { + startedAt: '2026-01-01T00:00:00.000Z', + completedAt: '2026-01-01T00:00:01.000Z', + durationMs: 1000, + totalCost: 0.25, + matchedProvider: 'hunter', + aborted: false, + providers: [ + { + id: 'hunter', + label: 'Hunter', + toolId: 'hunter_find_email', + status: 'matched' as const, + cost: 0.25, + durationMs: 900, + error: null, + }, + ], + } + + const presented = toApiEnrichmentDetail(detail) + + expect(presented).toEqual(detail) + expect(() => v2EnrichmentRunDetailSchema.parse(presented)).not.toThrow() + }) + + it('answers null for a missing cascade', () => { + expect(toApiEnrichmentDetail(null)).toBeNull() + }) + + /** A blob written before the cascade breakdown settled is a partial answer, not a 500. */ + it('defaults every key a drifted blob is missing', () => { + const presented = toApiEnrichmentDetail({ + startedAt: '2026-01-01 00:00:00+00', + providers: [{ id: 'hunter', status: 'rate_limited' }], + } as never) + + expect(presented).toEqual({ + startedAt: '2026-01-01T00:00:00.000Z', + completedAt: null, + durationMs: 0, + totalCost: 0, + matchedProvider: null, + aborted: false, + providers: [ + { + id: 'hunter', + label: '', + toolId: '', + status: 'rate_limited', + cost: 0, + durationMs: 0, + error: null, + }, + ], + }) + expect(() => v2EnrichmentRunDetailSchema.parse(presented)).not.toThrow() + }) + + it('drops a timestamp no date-time consumer could parse', () => { + const presented = toApiEnrichmentDetail({ startedAt: 'never', completedAt: 7 } as never) + + expect(presented?.startedAt).toBeNull() + expect(presented?.completedAt).toBeNull() + expect(() => v2EnrichmentRunDetailSchema.parse(presented)).not.toThrow() + }) +}) diff --git a/apps/sim/app/api/v2/tables/presenters.ts b/apps/sim/app/api/v2/tables/presenters.ts index 10a624e0980..726959ba83b 100644 --- a/apps/sim/app/api/v2/tables/presenters.ts +++ b/apps/sim/app/api/v2/tables/presenters.ts @@ -1,4 +1,6 @@ +import type { V2TableRunDispatch } from '@/lib/api/contracts/v2/tables' import { buildNameById, remapGroupColumnRefs } from '@/lib/table/column-keys' +import type { DispatchRow } from '@/lib/table/dispatcher' import { type TableExportRecord, toV2TableExport } from '@/lib/table/orchestration/export-resource' import { type CreateTableImportResult, @@ -36,3 +38,38 @@ export function presentV2TableExport(record: TableExportRecord, queued = false) export function presentV2WorkflowGroup(group: WorkflowGroup, schema: TableSchema): WorkflowGroup { return remapGroupColumnRefs(group, buildNameById(schema)) } + +/** + * Projects one stored dispatch onto the public resource. + * + * The stored `cursor` (highest row position already enqueued), `requestId`, and + * `triggeredByUserId` stay internal: the first is a scheduler position that a + * field of that name on a v2 resource would be mistaken for a pagination token, + * and the other two name internal identities. Every published status is + * reachable, including the two terminal ones — this resource exists to be + * polled until a run settles. + */ +export function presentV2TableDispatch(dispatch: DispatchRow): V2TableRunDispatch { + return { + id: dispatch.id, + tableId: dispatch.tableId, + workspaceId: dispatch.workspaceId, + /** + * The column stores `cancelled`; the surface publishes `canceled`, which is + * how table imports, exports, and job state already spell it. Mapping here + * keeps one spelling on the wire without renaming a stored value. + */ + status: dispatch.status === 'cancelled' ? 'canceled' : dispatch.status, + mode: dispatch.mode, + scope: { + groupIds: dispatch.scope.groupIds, + ...(dispatch.scope.rowIds ? { rowIds: dispatch.scope.rowIds } : {}), + }, + limit: dispatch.limit, + processedCount: dispatch.processedCount, + isManualRun: dispatch.isManualRun, + requestedAt: dispatch.requestedAt.toISOString(), + completedAt: dispatch.completedAt?.toISOString() ?? null, + canceledAt: dispatch.cancelledAt?.toISOString() ?? null, + } +} diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 89c64f6b4bd..83753a211f1 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -36,8 +36,10 @@ vi.mock('@/lib/table/billing', () => ({ getMaxRowsPerTable: mocks.getMaxRowsPerTable, })) -import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { v2ListTablesContract } from '@/lib/api/contracts/v2/tables' +import { cursorRoute, cursorScopeKey, REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { writeSortedCursor } from '@/app/api/v2/lib/response' import { GET, POST } from '@/app/api/v2/tables/route' const WORKSPACE_ID = 'workspace-1' @@ -162,6 +164,42 @@ describe('/api/v2/tables', () => { }) }) + /** + * `scope` carries `.default('active')`, so it is present on every parsed + * query. Stamping it unconditionally would put a constant in every + * fingerprint and refuse every cursor minted before the param existed, with + * the misleading {@link REFILTERED_CURSOR_MESSAGE} — a caller that changed + * nothing would be told it changed a filter. The default must therefore + * contribute nothing to the scope. + */ + it('resumes a cursor minted before scope entered the binding', async () => { + mocks.list.mockResolvedValue({ + tables: [{ table, folderPath: '/' }], + nextKeys: undefined, + sortBy: 'createdAt', + sortOrder: 'asc', + }) + const legacyCursor = writeSortedCursor( + ['2026-08-01T00:00:00.000Z', 'table-1'], + 'createdAt', + 'asc', + cursorScopeKey(cursorRoute(v2ListTablesContract), { workspaceId: WORKSPACE_ID }) + ) as string + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&cursor=${encodeURIComponent(legacyCursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.list).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ after: ['2026-08-01T00:00:00.000Z', 'table-1'] }), + }) + ) + }) + it('lists through the semantic use case and preserves the cursor envelope', async () => { const request = new NextRequest( `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25` diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index a91b9c4ef61..60870623aa0 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -11,9 +11,20 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 /** Every param that changes which tables, in which order, this list returns. */ -function tableCursorFilters(query: { workspaceId: string; folderPath?: string; search?: string }) { +function tableCursorFilters(query: { + workspaceId: string + scope: string + folderPath?: string + search?: string +}) { return cursorScopeKey(cursorRoute(v2ListTablesContract), { workspaceId: query.workspaceId, + // Stamped only when it is not the default. `scope` carries + // `.default('active')`, so it is always present on the parsed query; + // binding it unconditionally would put a constant in every fingerprint and + // reject every cursor minted before the field existed — including on + // callers who never sent it. + scope: query.scope === 'active' ? undefined : query.scope, folderPath: query.folderPath, search: query.search, }) @@ -28,6 +39,7 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2TableErrorPolicies.default, mapInput: ({ query }) => ({ workspaceId: query.workspaceId, + scope: query.scope, folderPath: query.folderPath, search: query.search, sortBy: query.sortBy, diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 8a480854ca7..32b681cbd5c 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,8 +1,13 @@ -import type { V2ApiTable } from '@/lib/api/contracts/v2/tables' +import type { + V2ApiTable, + V2EnrichmentProviderOutcome, + V2EnrichmentRunDetail, + V2RowRunState, +} from '@/lib/api/contracts/v2/tables' import type { RowData, TableDefinition, TableSchema } from '@/lib/table' import { getMaxRowsPerTable } from '@/lib/table/billing' import { buildColumnNameById, remapViewConfigColumnRefs } from '@/lib/table/column-keys' -import type { ColumnDefinition } from '@/lib/table/types' +import type { ColumnDefinition, EnrichmentRunDetail, RowExecutions } from '@/lib/table/types' import type { TableView } from '@/lib/table/views/service' import { normalizeColumn } from '@/lib/table/wire' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' @@ -167,16 +172,116 @@ interface ApiRowInput { } /** - * Normalized public row shape: `{ id, data, createdAt, updatedAt }`, no storage - * internals (`position`/`orderKey`/`executions`). Callers pass a - * `namedRowMapper(schema.columns)` so `data` is keyed by column NAME and select - * cells surface their option NAME rather than the stored option id. + * Projects the stored per-`(row, group)` execution sidecar onto the public + * `runState` map. + * + * `jobId` is dropped — it is the async scheduler's own identity and addresses + * nothing a caller can reach — and so is `enrichmentDetails`, which is never + * hydrated on this path and has its own sub-resource. The two optional storage + * fields are defaulted so the published schema can declare them required, which + * is what lets a client read `blockErrors` without a presence check. + */ +function toApiRunState(executions: RowExecutions): Record { + const runState: Record = {} + for (const [groupId, execution] of Object.entries(executions)) { + runState[groupId] = { + /** Stored as `cancelled`; published as `canceled`. See presentV2TableDispatch. */ + status: execution.status === 'cancelled' ? 'canceled' : execution.status, + executionId: execution.executionId, + workflowId: execution.workflowId, + error: execution.error, + runningBlockIds: execution.runningBlockIds ?? [], + blockErrors: execution.blockErrors ?? {}, + canceledAt: execution.cancelledAt ?? null, + } + } + return runState +} + +/** + * Normalized public row shape: `{ id, data, createdAt, updatedAt }`, plus + * `runState` when — and only when — the caller opted in. + * + * Storage internals stay off the wire: `position` and `orderKey` are a + * fractional index that is nullable mid-backfill and that a caller cannot mint. + * The per-cell execution sidecar is NOT one of them — it holds run outcomes of + * runs this same API starts, which is why it is reachable through + * `includeRunState` rather than stripped. Do not "restore" the strip. + * + * Callers pass a `namedRowMapper(schema.columns)` so `data` is keyed by column + * NAME and select cells surface their option NAME rather than the stored option + * id. */ -export function toApiRow(row: ApiRowInput, toNamedRow: (data: RowData) => RowData) { +export function toApiRow( + row: ApiRowInput, + toNamedRow: (data: RowData) => RowData, + runState?: RowExecutions +) { return { id: row.id, data: toNamedRow(row.data), + ...(runState ? { runState: toApiRunState(runState) } : {}), createdAt: toIso(row.createdAt), updatedAt: toIso(row.updatedAt), } } + +/** Reads a stored field that the published shape declares as a plain number. */ +function storedNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +/** Reads a stored field that the published shape declares as a nullable string. */ +function storedNullableString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +/** + * Reads a stored timestamp, keeping only a value the published `date-time` + * format will accept. A Postgres literal or a half-written blob becomes `null` + * rather than failing response validation. + */ +function storedTimestamp(value: unknown): string | null { + if (typeof value !== 'string') return null + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : new Date(parsed).toISOString() +} + +function toApiEnrichmentProvider(value: unknown): V2EnrichmentProviderOutcome { + const provider = (value ?? {}) as Record + return { + id: storedNullableString(provider.id) ?? '', + label: storedNullableString(provider.label) ?? '', + toolId: storedNullableString(provider.toolId) ?? '', + status: storedNullableString(provider.status) ?? 'not_run', + cost: storedNumber(provider.cost), + durationMs: storedNumber(provider.durationMs), + error: storedNullableString(provider.error), + } +} + +/** + * Projects the stored enrichment cascade blob onto the published detail shape. + * + * `tableRowExecutions.enrichmentDetails` is schemaless JSONB read back through + * a bare `as` cast, so the domain type is the writer's intent, not a property of + * the column. Every declared key is projected with a default here so a blob + * written by an older runner — or one whose shape drifts — degrades to a partial + * answer instead of turning a well-formed `GET` into a `500` at response + * validation. + */ +export function toApiEnrichmentDetail( + detail: EnrichmentRunDetail | null +): V2EnrichmentRunDetail | null { + if (!detail || typeof detail !== 'object') return null + const stored: Record = { ...detail } + return { + startedAt: storedTimestamp(stored.startedAt), + completedAt: storedTimestamp(stored.completedAt), + durationMs: storedNumber(stored.durationMs), + totalCost: storedNumber(stored.totalCost), + matchedProvider: storedNullableString(stored.matchedProvider), + aborted: stored.aborted === true, + providers: Array.isArray(stored.providers) ? stored.providers.map(toApiEnrichmentProvider) : [], + } +} diff --git a/apps/sim/app/api/v2/tools/[toolId]/route.ts b/apps/sim/app/api/v2/tools/[toolId]/route.ts new file mode 100644 index 00000000000..450a624c38f --- /dev/null +++ b/apps/sim/app/api/v2/tools/[toolId]/route.ts @@ -0,0 +1,23 @@ +import { v2GetToolContract } from '@/lib/api/contracts/v2/catalog' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { getCatalogTool } from '@/lib/catalog/application/get-tool' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/tools/{toolId} — Read one built-in tool's parameters and outputs. */ +export const GET = defineV2JsonRoute({ + contract: v2GetToolContract, + operation: catalogOperations.readTool, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + toolId: params.toolId, + }), + useCase: getCatalogTool, + present: ({ tool }) => ({ data: tool }), +}) diff --git a/apps/sim/app/api/v2/tools/route.test.ts b/apps/sim/app/api/v2/tools/route.test.ts new file mode 100644 index 00000000000..651100ef7c6 --- /dev/null +++ b/apps/sim/app/api/v2/tools/route.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ list: vi.fn(), read: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/catalog/application/list-tools', () => ({ + listCatalogTools: { operation: { id: 'catalog.tools.list' }, execute: mocks.list }, +})) +vi.mock('@/lib/catalog/application/get-tool', () => ({ + getCatalogTool: { operation: { id: 'catalog.tools.read' }, execute: mocks.read }, +})) + +import { v2ListToolsContract } from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' +import { GET as GET_TOOL } from '@/app/api/v2/tools/[toolId]/route' +import { GET } from '@/app/api/v2/tools/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const summary = { + id: 'slack_message', + name: 'Slack Send Message', + description: 'Send a message.', + version: '1.0.0', + hostedApiKey: 'none' as const, +} + +const detail = { ...summary, params: {}, outputs: {} } + +function toolCursor({ + offset, + search, + hostedApiKey, + oauthProvider, + sortBy = 'id', + sortOrder = 'asc', +}: { + offset: number + search?: string + hostedApiKey?: string + oauthProvider?: string + sortBy?: string + sortOrder?: string +}): string { + return encodeOffsetCursor( + cursorSortKey(sortBy, sortOrder), + cursorScopeKey(cursorRoute(v2ListToolsContract), { + workspaceId: WORKSPACE_ID, + search, + hostedApiKey, + oauthProvider, + }), + offset + ) +} + +function request(url: string) { + return new NextRequest(`http://localhost:3000${url}`, { headers: { 'x-api-key': 'key' } }) +} + +describe('/api/v2/tools', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.list.mockResolvedValue({ entries: [summary], hasMore: false, offset: 0, limit: 50 }) + mocks.read.mockResolvedValue({ tool: detail }) + }) + + it('returns tool summaries without params or outputs', async () => { + const response = await GET(request(`/api/v2/tools?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + const body = await response.json() + expect(body.data[0]).not.toHaveProperty('params') + expect(body.nextCursor).toBeNull() + }) + + it('resumes from the offset cursor and mints the next one while pages remain', async () => { + mocks.list.mockResolvedValue({ entries: [summary], hasMore: true, offset: 100, limit: 100 }) + const cursor = toolCursor({ offset: 100 }) + + const response = await GET( + request( + `/api/v2/tools?workspaceId=${WORKSPACE_ID}&limit=100&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect((await response.json()).nextCursor).toBe(toolCursor({ offset: 200 })) + }) + + it('rejects a cursor replayed after the hosted-key filter changes', async () => { + const cursor = toolCursor({ offset: 100 }) + + const response = await GET( + request( + `/api/v2/tools?workspaceId=${WORKSPACE_ID}&hostedApiKey=always&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it.each([ + ['an unknown param', 'bogus=1'], + ['a fractional limit', 'limit=2.5'], + ['an unknown hosted-key value', 'hostedApiKey=maybe'], + ['an empty oauth provider', 'oauthProvider='], + ])('rejects %s instead of ignoring it', async (_label, query) => { + const response = await GET(request(`/api/v2/tools?workspaceId=${WORKSPACE_ID}&${query}`)) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) +}) + +describe('/api/v2/tools/[toolId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.read.mockResolvedValue({ tool: detail }) + }) + + it('returns one tool with its params and outputs', async () => { + const response = await GET_TOOL( + request(`/api/v2/tools/slack_message?workspaceId=${WORKSPACE_ID}`), + { params: Promise.resolve({ toolId: 'slack_message' }) } + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: detail }) + expect(mocks.read).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, toolId: 'slack_message' }, + request: expect.anything(), + }) + }) + + it('answers not found for a tool this caller cannot run', async () => { + mocks.read.mockRejectedValue(new OrchestrationError('not_found', 'Tool not found')) + + const response = await GET_TOOL( + request(`/api/v2/tools/secret_tool?workspaceId=${WORKSPACE_ID}`), + { params: Promise.resolve({ toolId: 'secret_tool' }) } + ) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Tool not found') + }) +}) diff --git a/apps/sim/app/api/v2/tools/route.ts b/apps/sim/app/api/v2/tools/route.ts new file mode 100644 index 00000000000..fdf4b1f4757 --- /dev/null +++ b/apps/sim/app/api/v2/tools/route.ts @@ -0,0 +1,49 @@ +import { type V2ListToolsQuery, v2ListToolsContract } from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { listCatalogTools } from '@/lib/catalog/application/list-tools' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Every param that changes which tools, in which order, this list returns. */ +function toolCursorFilters(query: V2ListToolsQuery) { + return cursorScopeKey(cursorRoute(v2ListToolsContract), { + workspaceId: query.workspaceId, + search: query.search, + hostedApiKey: query.hostedApiKey, + oauthProvider: query.oauthProvider, + }) +} + +/** GET /api/v2/tools — List the built-in tools available in a workspace. */ +export const GET = defineV2JsonRoute({ + contract: v2ListToolsContract, + operation: catalogOperations.listTools, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + /** An offset cursor for the same reason as `GET /api/v2/blocks`. */ + mapInput: ({ query }) => ({ + ...query, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + toolCursorFilters(query) + ), + }), + useCase: listCatalogTools, + present: ({ entries, hasMore, offset, limit }, { query }) => ({ + data: entries, + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + toolCursorFilters(query), + offset + limit + ) + : null, + }), +}) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/route.test.ts b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/route.test.ts new file mode 100644 index 00000000000..f5cbf65abde --- /dev/null +++ b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/route.test.ts @@ -0,0 +1,271 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + resetDbChainMock, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + loadWorkspaceContext: vi.fn(), + getServer: vi.fn(), + updateServer: vi.fn(), + deleteServer: vi.fn(), + audit: vi.fn(), + publishToolsChanged: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + MCP_SERVER_ADDED: 'mcp_server.added', + MCP_SERVER_UPDATED: 'mcp_server.updated', + MCP_SERVER_REMOVED: 'mcp_server.removed', + }, + AuditResourceType: { MCP_SERVER: 'mcp_server' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspaceContext, +})) +vi.mock('@/lib/mcp/queries', async (importOriginal) => ({ + ...(await importOriginal()), + getWorkflowMcpServerById: mocks.getServer, +})) +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpServer: vi.fn(), + performUpdateWorkflowMcpServer: mocks.updateServer, + performDeleteWorkflowMcpServer: mocks.deleteServer, + performCreateWorkflowMcpTool: vi.fn(), + performUpdateWorkflowMcpTool: vi.fn(), + performDeleteWorkflowMcpTool: vi.fn(), +})) +vi.mock('@/lib/mcp/pubsub', () => ({ + mcpPubSub: { publishWorkflowToolsChanged: mocks.publishToolsChanged }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { DELETE, GET, PATCH } from '@/app/api/v2/workflow-mcp-servers/[serverId]/route' + +const WORKSPACE_ID = 'workspace-1' +const SERVER_ID = 'wfmcp-1' + +const personalKeyAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const serverRow = { + id: SERVER_ID, + workspaceId: WORKSPACE_ID, + createdBy: 'user-1', + name: 'Support agents', + description: 'Ticket triage', + isPublic: false, + deletedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:35:00.000Z'), +} + +/** The canonical server lookup the use case performs before authorizing. */ +function queueServerLookup(row: unknown = serverRow) { + mocks.getServer.mockResolvedValue(row) +} + +async function patch(body: unknown) { + const request = new NextRequest(`http://localhost/api/v2/workflow-mcp-servers/${SERVER_ID}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return PATCH(request, { params: Promise.resolve({ serverId: SERVER_ID }) }) +} + +async function get() { + const request = new NextRequest(`http://localhost/api/v2/workflow-mcp-servers/${SERVER_ID}`) + return GET(request, { params: Promise.resolve({ serverId: SERVER_ID }) }) +} + +async function del() { + const request = new NextRequest(`http://localhost/api/v2/workflow-mcp-servers/${SERVER_ID}`, { + method: 'DELETE', + }) + return DELETE(request, { params: Promise.resolve({ serverId: SERVER_ID }) }) +} + +describe('/api/v2/workflow-mcp-servers/[serverId]', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + v2RouteMocks.authenticate.mockResolvedValue(personalKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.updateServer.mockResolvedValue({ + success: true, + server: { ...serverRow, isPublic: true }, + updatedFields: ['isPublic'], + }) + mocks.deleteServer.mockResolvedValue({ success: true, server: serverRow }) + }) + + describe('PATCH', () => { + it('updates the server and records one semantic audit entry', async () => { + queueServerLookup() + + const response = await patch({ isPublic: true }) + + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ id: SERVER_ID, isPublic: true }) + expect(mocks.updateServer).toHaveBeenCalledWith( + expect.objectContaining({ serverId: SERVER_ID, isPublic: true }) + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + }) + + it('rejects a body that would change nothing', async () => { + const response = await patch({}) + + expect(response.status).toBe(400) + expect(JSON.stringify(await response.json())).toContain( + 'At least one of name, description, or isPublic must be provided' + ) + expect(mocks.updateServer).not.toHaveBeenCalled() + }) + + it('conceals a server from another workspace as 404', async () => { + queueServerLookup(null) + + const response = await patch({ isPublic: true }) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('MCP server not found') + expect(mocks.updateServer).not.toHaveBeenCalled() + }) + + it('refuses a caller below workspace write with 403', async () => { + queueServerLookup() + mocks.resolvePermission.mockResolvedValue('read') + + const response = await patch({ isPublic: true }) + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('INSUFFICIENT_WORKSPACE_ROLE') + expect(mocks.updateServer).not.toHaveBeenCalled() + }) + }) + + describe('DELETE', () => { + it('unpublishes the server and notifies connected clients', async () => { + queueServerLookup() + + const response = await del() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: SERVER_ID, deleted: true } }) + expect(mocks.publishToolsChanged).toHaveBeenCalledWith({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }) + expect(mocks.audit).toHaveBeenCalledTimes(1) + }) + + it('refuses a caller below workspace admin with 403', async () => { + queueServerLookup() + mocks.resolvePermission.mockResolvedValue('write') + + const response = await del() + + expect(response.status).toBe(403) + expect(mocks.deleteServer).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await del() + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + }) + + /** + * Without this read a caller holding a server id had to page the whole + * collection and filter client-side — the server could be renamed and deleted + * through this same path, but never simply read. + */ + describe('GET', () => { + it('returns the server', async () => { + queueServerLookup() + + const response = await get() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: expect.objectContaining({ id: SERVER_ID, name: 'Support agents', isPublic: false }), + }) + }) + + it('conceals a server in another workspace as not found', async () => { + queueServerLookup(null) + + const response = await get() + + expect(response.status).toBe(404) + }) + + it('records no audit entry for a read', async () => { + queueServerLookup() + + await get() + + expect(mocks.audit).not.toHaveBeenCalled() + }) + + /** The family denies workspace API keys throughout; a read must not be the wide door. */ + it('refuses a workspace API key', async () => { + queueServerLookup() + v2RouteMocks.authenticate.mockResolvedValue({ + ...personalKeyAuth, + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'ws-key-1' }, + keyType: 'workspace', + }) + + const response = await get() + + expect(response.status).toBe(403) + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/route.ts b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/route.ts new file mode 100644 index 00000000000..9548a5605d8 --- /dev/null +++ b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/route.ts @@ -0,0 +1,74 @@ +import { + v2DeleteWorkflowMcpServerContract, + v2GetWorkflowMcpServerContract, + v2UpdateWorkflowMcpServerContract, +} from '@/lib/api/contracts/v2/workflow-mcp-servers' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { + deleteWorkflowMcpDeploymentServer, + readWorkflowMcpDeploymentServer, + updateWorkflowMcpDeploymentServer, +} from '@/lib/mcp/application/workflow-deployments' +import { + toV2WorkflowMcpServer, + workflowMcpServerErrorPolicy, +} from '@/app/api/v2/workflow-mcp-servers/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflow-mcp-servers/[serverId] — Read one published server. + * + * The list is the only other place this state is published, so a caller holding + * a server id had to page the collection and filter client-side to see one. + * Mirrors `GET /api/v2/mcp-servers/{mcpServerId}` beside it. + * + * Head-safe: nothing is written and no audit is projected. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowMcpServerContract, + auth: v2ApiKeyAuth, + operation: mcpServerOperations.readWorkflowDeploymentServer, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workflowMcpServerErrorPolicy, + mapInput: ({ params }) => ({ serverId: params.serverId }), + useCase: readWorkflowMcpDeploymentServer, + present: ({ server }) => ({ data: toV2WorkflowMcpServer(server) }), +}) + +/** PATCH /api/v2/workflow-mcp-servers/[serverId] — Rename or re-scope a published server. */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateWorkflowMcpServerContract, + auth: v2ApiKeyAuth, + operation: mcpServerOperations.updateWorkflowDeploymentServer, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workflowMcpServerErrorPolicy, + mapInput: ({ params, body }) => ({ + serverId: params.serverId, + name: body.name, + description: body.description, + isPublic: body.isPublic, + }), + useCase: updateWorkflowMcpDeploymentServer, + present: ({ server }) => ({ data: toV2WorkflowMcpServer(server) }), +}) + +/** + * DELETE /api/v2/workflow-mcp-servers/[serverId] — Unpublish a server. + * + * Every tool it published stops answering, and connected MCP clients lose the + * endpoint. The workflows themselves are untouched — their own deployments stay + * live and executable through the workflow API. + */ +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteWorkflowMcpServerContract, + auth: v2ApiKeyAuth, + operation: mcpServerOperations.deleteWorkflowDeploymentServer, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workflowMcpServerErrorPolicy, + mapInput: ({ params }) => ({ serverId: params.serverId }), + useCase: deleteWorkflowMcpDeploymentServer, + present: ({ server }) => ({ data: { id: server.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId]/route.ts b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId]/route.ts new file mode 100644 index 00000000000..62ef64953ea --- /dev/null +++ b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId]/route.ts @@ -0,0 +1,33 @@ +import { v2UndeployWorkflowMcpToolContract } from '@/lib/api/contracts/v2/workflow-mcp-servers' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { undeployWorkflowMcpTool } from '@/lib/mcp/application/workflow-deployments' +import { workflowMcpServerErrorPolicy } from '@/app/api/v2/workflow-mcp-servers/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * DELETE /api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId] — Unpublish a tool. + * + * Addressed by workflow rather than by tool id: a server carries at most one + * live tool per workflow, and the workflow is the identifier the caller already + * holds. The workflow's own deployment is untouched. + */ +export const DELETE = defineV2JsonRoute({ + contract: v2UndeployWorkflowMcpToolContract, + auth: v2ApiKeyAuth, + operation: mcpServerOperations.undeployWorkflowTool, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workflowMcpServerErrorPolicy, + mapInput: ({ params }) => ({ serverId: params.serverId, workflowId: params.workflowId }), + useCase: undeployWorkflowMcpTool, + present: ({ tool }) => ({ + data: { + id: tool.id, + serverId: tool.serverId, + workflowId: tool.workflowId, + deleted: true as const, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.test.ts b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.test.ts new file mode 100644 index 00000000000..10cb51fadaa --- /dev/null +++ b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.test.ts @@ -0,0 +1,357 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + resetDbChainMock, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + loadWorkspaceContext: vi.fn(), + getServer: vi.fn(), + listTools: vi.fn(), + getLiveTool: vi.fn(), + getWorkflow: vi.fn(), + createTool: vi.fn(), + updateTool: vi.fn(), + deleteTool: vi.fn(), + audit: vi.fn(), + publishToolsChanged: vi.fn(), + inputFormat: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { MCP_SERVER_UPDATED: 'mcp_server.updated' }, + AuditResourceType: { MCP_SERVER: 'mcp_server' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspaceContext, +})) +vi.mock('@/lib/mcp/queries', async (importOriginal) => ({ + ...(await importOriginal()), + getWorkflowMcpServerById: mocks.getServer, + getLiveWorkflowMcpTool: mocks.getLiveTool, + listLiveWorkflowMcpTools: mocks.listTools, + getWorkflowMcpPublishableWorkflow: mocks.getWorkflow, +})) +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpServer: vi.fn(), + performUpdateWorkflowMcpServer: vi.fn(), + performDeleteWorkflowMcpServer: vi.fn(), + performCreateWorkflowMcpTool: mocks.createTool, + performUpdateWorkflowMcpTool: mocks.updateTool, + performDeleteWorkflowMcpTool: mocks.deleteTool, +})) +vi.mock('@/lib/mcp/pubsub', () => ({ + mcpPubSub: { publishWorkflowToolsChanged: mocks.publishToolsChanged }, +})) +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + getDeployedWorkflowInputFormat: mocks.inputFormat, +})) +vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ + applyDescriptionOverrides: (schema: unknown) => schema, + generateToolInputSchema: () => ({ type: 'object', properties: {} }), + sanitizeToolName: (name: string) => name.toLowerCase().replace(/[^a-z0-9_]/g, '_'), +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { DELETE } from '@/app/api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId]/route' +import { GET, POST } from '@/app/api/v2/workflow-mcp-servers/[serverId]/tools/route' + +const WORKSPACE_ID = 'workspace-1' +const SERVER_ID = 'wfmcp-1' +const WORKFLOW_ID = 'workflow-1' + +const personalKeyAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const serverRow = { + id: SERVER_ID, + workspaceId: WORKSPACE_ID, + createdBy: 'user-1', + name: 'Support agents', + description: null, + isPublic: false, + deletedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:30:00.000Z'), +} + +const toolRow = { + id: 'wfmcptool-1', + serverId: SERVER_ID, + workflowId: WORKFLOW_ID, + toolName: 'triage_ticket', + toolDescription: 'Execute Ticket triage workflow', + parameterSchema: {}, + parameterDescriptionOverrides: {}, + archivedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:30:00.000Z'), +} + +/** The workflow row `resolveWorkflowToolContext` loads inside the server's workspace. */ +function queueWorkflowLookup( + row: unknown = { id: WORKFLOW_ID, name: 'Ticket triage', isDeployed: true } +) { + mocks.getWorkflow.mockResolvedValue(row) +} + +async function get() { + const request = new NextRequest(`http://localhost/api/v2/workflow-mcp-servers/${SERVER_ID}/tools`) + return GET(request, { params: Promise.resolve({ serverId: SERVER_ID }) }) +} + +async function post(body: unknown) { + const request = new NextRequest( + `http://localhost/api/v2/workflow-mcp-servers/${SERVER_ID}/tools`, + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } + ) + return POST(request, { params: Promise.resolve({ serverId: SERVER_ID }) }) +} + +async function del() { + const request = new NextRequest( + `http://localhost/api/v2/workflow-mcp-servers/${SERVER_ID}/tools/${WORKFLOW_ID}`, + { method: 'DELETE' } + ) + return DELETE(request, { + params: Promise.resolve({ serverId: SERVER_ID, workflowId: WORKFLOW_ID }), + }) +} + +describe('/api/v2/workflow-mcp-servers/[serverId]/tools', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + v2RouteMocks.authenticate.mockResolvedValue(personalKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadWorkspaceContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.getServer.mockResolvedValue(serverRow) + mocks.getLiveTool.mockResolvedValue(null) + mocks.inputFormat.mockResolvedValue([]) + mocks.createTool.mockResolvedValue({ success: true, tool: toolRow }) + mocks.updateTool.mockResolvedValue({ success: true, tool: toolRow }) + mocks.deleteTool.mockResolvedValue({ success: true, tool: toolRow }) + }) + + describe('POST', () => { + it('publishes a deployed workflow and reports it as new', async () => { + queueWorkflowLookup() + + const response = await post({ workflowId: WORKFLOW_ID }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: 'wfmcptool-1', + serverId: SERVER_ID, + workflowId: WORKFLOW_ID, + toolName: 'triage_ticket', + toolDescription: 'Execute Ticket triage workflow', + mcpServerUrl: expect.stringContaining(`/api/mcp/serve/${SERVER_ID}`), + apiEndpoint: expect.stringContaining(`/api/v2/workflows/${WORKFLOW_ID}/execute`), + updated: false, + createdAt: '2026-06-12T10:30:00.000Z', + updatedAt: '2026-06-12T10:30:00.000Z', + }, + }) + expect(mocks.createTool).toHaveBeenCalled() + expect(mocks.updateTool).not.toHaveBeenCalled() + }) + + /** Publishing is idempotent per workflow — a repeat replaces rather than conflicts. */ + it('replaces an existing tool and reports updated', async () => { + queueWorkflowLookup() + mocks.getLiveTool.mockResolvedValue(toolRow) + + const body = await (await post({ workflowId: WORKFLOW_ID })).json() + + expect(body.data.updated).toBe(true) + expect(mocks.updateTool).toHaveBeenCalled() + expect(mocks.createTool).not.toHaveBeenCalled() + }) + + it('refuses an undeployed workflow with 400 rather than publishing an empty schema', async () => { + queueWorkflowLookup({ id: WORKFLOW_ID, name: 'Ticket triage', isDeployed: false }) + + const response = await post({ workflowId: WORKFLOW_ID }) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toContain('must be deployed') + expect(mocks.createTool).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('conceals a workflow outside the server workspace as 404', async () => { + queueWorkflowLookup(null) + + const response = await post({ workflowId: WORKFLOW_ID }) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Workflow not found') + }) + + it('rejects more parameter descriptions than the domain accepts', async () => { + const response = await post({ + workflowId: WORKFLOW_ID, + parameterDescriptions: Array.from({ length: 101 }, (_, index) => ({ + name: `field${index}`, + description: 'x', + })), + }) + + expect(response.status).toBe(400) + expect(JSON.stringify(await response.json())).toContain('at most 100 entries') + expect(mocks.getServer).not.toHaveBeenCalled() + }) + + it('rejects a nested unknown key in parameterDescriptions', async () => { + const response = await post({ + workflowId: WORKFLOW_ID, + parameterDescriptions: [{ name: 'field', description: 'x', required: true }], + }) + + expect(response.status).toBe(400) + expect(mocks.getServer).not.toHaveBeenCalled() + }) + + it('refuses a caller below workspace admin with 403', async () => { + queueWorkflowLookup() + mocks.resolvePermission.mockResolvedValue('write') + + const response = await post({ workflowId: WORKFLOW_ID }) + + expect(response.status).toBe(403) + expect(mocks.createTool).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await post({ workflowId: WORKFLOW_ID }) + + expect(response.status).toBe(401) + }) + }) + + describe('DELETE', () => { + it('removes the tool addressed by workflow', async () => { + queueWorkflowLookup() + mocks.getLiveTool.mockResolvedValue(toolRow) + + const response = await del() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: 'wfmcptool-1', + serverId: SERVER_ID, + workflowId: WORKFLOW_ID, + deleted: true, + }, + }) + expect(mocks.publishToolsChanged).toHaveBeenCalledWith({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }) + }) + + it('answers 404 when the workflow is not published on this server', async () => { + queueWorkflowLookup() + mocks.getLiveTool.mockResolvedValue(null) + + const response = await del() + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe( + 'Workflow is not deployed to this MCP server' + ) + expect(mocks.audit).not.toHaveBeenCalled() + }) + }) + + /** + * The server list reports tool NAMES only, so before this read nothing + * published the `workflowId` that `DELETE .../tools/{workflowId}` addresses — + * a caller that lost the publish response could not reconcile a server. + */ + describe('GET', () => { + it('returns each published tool with the workflowId that addresses it', async () => { + mocks.getServer.mockResolvedValue(serverRow) + mocks.listTools.mockResolvedValue({ tools: [toolRow], truncated: false }) + + const response = await get() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toHaveLength(1) + expect(body.data[0]).toMatchObject({ + id: 'wfmcptool-1', + serverId: SERVER_ID, + workflowId: WORKFLOW_ID, + toolName: 'triage_ticket', + }) + }) + + /** `updated` reports what a publish did; a read has no publish to report. */ + it('omits the publish-only updated flag', async () => { + mocks.getServer.mockResolvedValue(serverRow) + mocks.listTools.mockResolvedValue({ tools: [toolRow], truncated: false }) + + const body = await (await get()).json() + + expect(body.data[0]).not.toHaveProperty('updated') + }) + + it('is a full set, so nextCursor is always null', async () => { + mocks.getServer.mockResolvedValue(serverRow) + mocks.listTools.mockResolvedValue({ tools: [toolRow], truncated: false }) + + const body = await (await get()).json() + + expect(body.nextCursor).toBeNull() + }) + + it('conceals a server in another workspace as not found', async () => { + mocks.getServer.mockResolvedValue(null) + + expect((await get()).status).toBe(404) + expect(mocks.listTools).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.ts b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.ts new file mode 100644 index 00000000000..bf714b290e7 --- /dev/null +++ b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.ts @@ -0,0 +1,72 @@ +import { + v2DeployWorkflowMcpToolContract, + v2ListWorkflowMcpToolsContract, +} from '@/lib/api/contracts/v2/workflow-mcp-servers' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { + deployWorkflowMcpTool, + listWorkflowMcpDeploymentTools, +} from '@/lib/mcp/application/workflow-deployments' +import { + toV2WorkflowMcpTool, + toV2WorkflowMcpToolListItem, + workflowMcpServerErrorPolicy, +} from '@/app/api/v2/workflow-mcp-servers/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflow-mcp-servers/[serverId]/tools — List the tools a server publishes. + * + * The server list reports tool *names* only, so nothing published the + * `workflowId` that `DELETE .../tools/{workflowId}` addresses — a caller that + * did not keep the publish response could not reconcile a server's inventory. + * Mirrors `GET /api/v2/mcp-servers/{mcpServerId}/tools` beside it. + * + * A full set rather than a page, for the same reason as its twin: the inventory + * is bounded by the workspace's deployed workflows, and a caller reconciling it + * wants all of it. `nextCursor` is therefore always null. + * + * Head-safe: nothing is written and no audit is projected. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListWorkflowMcpToolsContract, + auth: v2ApiKeyAuth, + operation: mcpServerOperations.listWorkflowDeploymentTools, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workflowMcpServerErrorPolicy, + mapInput: ({ params }) => ({ serverId: params.serverId }), + useCase: listWorkflowMcpDeploymentTools, + present: ({ tools }) => ({ + data: tools.map(toV2WorkflowMcpToolListItem), + nextCursor: null, + }), +}) + +/** + * POST /api/v2/workflow-mcp-servers/[serverId]/tools — Publish a workflow as a tool. + * + * Idempotent per workflow: a server carries at most one tool per workflow, so a + * repeat call replaces the existing tool and answers `200` with `updated: true` + * rather than `201` or a conflict. The workflow must already be deployed — the + * tool schema is generated from the deployed input format, so an undeployed + * workflow has nothing to publish. + */ +export const POST = defineV2JsonRoute({ + contract: v2DeployWorkflowMcpToolContract, + auth: v2ApiKeyAuth, + operation: mcpServerOperations.deployWorkflowTool, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workflowMcpServerErrorPolicy, + mapInput: ({ params, body }) => ({ + serverId: params.serverId, + workflowId: body.workflowId, + toolName: body.toolName, + toolDescription: body.toolDescription, + parameterDescriptions: body.parameterDescriptions, + }), + useCase: deployWorkflowMcpTool, + present: ({ tool, updated }) => ({ data: toV2WorkflowMcpTool(tool, updated) }), +}) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/route.test.ts b/apps/sim/app/api/v2/workflow-mcp-servers/route.test.ts new file mode 100644 index 00000000000..537a342ca9f --- /dev/null +++ b/apps/sim/app/api/v2/workflow-mcp-servers/route.test.ts @@ -0,0 +1,332 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + resetDbChainMock, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + loadWorkspaceContext: vi.fn(), + listServers: vi.fn(), + listToolNames: vi.fn(), + createServer: vi.fn(), + audit: vi.fn(), + publishToolsChanged: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + MCP_SERVER_ADDED: 'mcp_server.added', + MCP_SERVER_UPDATED: 'mcp_server.updated', + MCP_SERVER_REMOVED: 'mcp_server.removed', + }, + AuditResourceType: { MCP_SERVER: 'mcp_server' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspaceContext, +})) +vi.mock('@/lib/mcp/queries', async (importOriginal) => ({ + ...(await importOriginal()), + listWorkspaceWorkflowMcpServers: mocks.listServers, + listWorkflowMcpToolNames: mocks.listToolNames, +})) +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpServer: mocks.createServer, + performUpdateWorkflowMcpServer: vi.fn(), + performDeleteWorkflowMcpServer: vi.fn(), + performCreateWorkflowMcpTool: vi.fn(), + performUpdateWorkflowMcpTool: vi.fn(), + performDeleteWorkflowMcpTool: vi.fn(), +})) +vi.mock('@/lib/mcp/pubsub', () => ({ + mcpPubSub: { publishWorkflowToolsChanged: mocks.publishToolsChanged }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { GET, POST } from '@/app/api/v2/workflow-mcp-servers/route' + +const WORKSPACE_ID = 'workspace-1' + +const personalKeyAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const workspaceKeyAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:workspace-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +function serverRow(overrides: Record = {}) { + return { + id: 'wfmcp-1', + workspaceId: WORKSPACE_ID, + createdBy: 'user-1', + name: 'Support agents', + description: 'Ticket triage', + isPublic: false, + deletedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:30:00.000Z'), + ...overrides, + } +} + +async function get(search = `?workspaceId=${WORKSPACE_ID}`) { + const request = new NextRequest(`http://localhost/api/v2/workflow-mcp-servers${search}`) + return GET(request, { params: Promise.resolve({}) }) +} + +async function post(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/workflow-mcp-servers', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(request, { params: Promise.resolve({}) }) +} + +describe('/api/v2/workflow-mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + v2RouteMocks.authenticate.mockResolvedValue(personalKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.listServers.mockResolvedValue({ data: [serverRow()], nextCursorKeys: null }) + mocks.listToolNames.mockResolvedValue({ namesByServerId: new Map(), truncated: false }) + mocks.createServer.mockResolvedValue({ + success: true, + server: serverRow(), + addedTools: [{ workflowId: 'workflow-1', toolName: 'triage_ticket' }], + }) + }) + + describe('GET', () => { + it('publishes the served endpoint and tool inventory for each server', async () => { + mocks.listToolNames.mockResolvedValue({ + namesByServerId: new Map([['wfmcp-1', ['triage_ticket']]]), + truncated: false, + }) + + const response = await get() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: 'wfmcp-1', + name: 'Support agents', + description: 'Ticket triage', + isPublic: false, + mcpServerUrl: expect.stringContaining('/api/mcp/serve/wfmcp-1'), + toolCount: 1, + toolNames: ['triage_ticket'], + createdAt: '2026-06-12T10:30:00.000Z', + updatedAt: '2026-06-12T10:30:00.000Z', + }, + ], + nextCursor: null, + }) + }) + + /** + * The row carries `createdBy` and `deletedAt`; the response schema strips + * them rather than the presenter enumerating what to keep, so a column added + * later cannot leak by omission. + */ + it('never publishes the stored row verbatim', async () => { + const body = await (await get()).json() + + expect(body.data[0]).not.toHaveProperty('createdBy') + expect(body.data[0]).not.toHaveProperty('deletedAt') + expect(body.data[0]).not.toHaveProperty('workspaceId') + }) + + it('mints a cursor when the page was cut', async () => { + mocks.listServers.mockResolvedValue({ + data: [serverRow()], + nextCursorKeys: [{ key: 'createdAt', value: '2026-06-12T10:30:00.000Z' }], + }) + + const body = await (await get()).json() + + expect(body.nextCursor).toEqual(expect.any(String)) + }) + + it('rejects a cursor minted under a different ordering', async () => { + mocks.listServers.mockResolvedValue({ + data: [serverRow()], + nextCursorKeys: [{ key: 'createdAt', value: '2026-06-12T10:30:00.000Z' }], + }) + const cursor = (await (await get()).json()).nextCursor + + const response = await get( + `?workspaceId=${WORKSPACE_ID}&sortBy=name&cursor=${encodeURIComponent(cursor)}` + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + }) + + it('requires a workspace', async () => { + const response = await get('') + + expect(response.status).toBe(400) + expect(mocks.loadWorkspaceContext).not.toHaveBeenCalled() + }) + + it('rejects a workspace API key before canonical loading', async () => { + v2RouteMocks.authenticate.mockResolvedValue(workspaceKeyAuth) + + const response = await get() + + expect(response.status).toBe(403) + expect(mocks.loadWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.listServers).not.toHaveBeenCalled() + }) + + it('conceals a workspace the caller cannot reach as 404', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await get() + + expect(response.status).toBe(404) + expect(mocks.listServers).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await get() + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + }) + + describe('POST', () => { + it('publishes a server and records one semantic audit entry', async () => { + const response = await post({ workspaceId: WORKSPACE_ID, name: 'Support agents' }) + + expect(response.status).toBe(201) + expect((await response.json()).data).toMatchObject({ + id: 'wfmcp-1', + name: 'Support agents', + mcpServerUrl: expect.stringContaining('/api/mcp/serve/wfmcp-1'), + }) + expect(mocks.createServer).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: WORKSPACE_ID, name: 'Support agents' }) + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.publishToolsChanged).toHaveBeenCalledWith({ + serverId: 'wfmcp-1', + workspaceId: WORKSPACE_ID, + }) + }) + + /** The create response has no tool inventory to report, so it must not claim one. */ + it('omits the tool inventory the write never read', async () => { + const body = await (await post({ workspaceId: WORKSPACE_ID, name: 'Support agents' })).json() + + expect(body.data).not.toHaveProperty('toolCount') + expect(body.data).not.toHaveProperty('toolNames') + }) + + it('rejects an unknown field rather than storing it', async () => { + const response = await post({ + workspaceId: WORKSPACE_ID, + name: 'Support agents', + transport: 'streamable-http', + }) + + expect(response.status).toBe(400) + expect(mocks.createServer).not.toHaveBeenCalled() + }) + + it('refuses a caller below workspace admin with 403', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + const response = await post({ workspaceId: WORKSPACE_ID, name: 'Support agents' }) + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('INSUFFICIENT_WORKSPACE_ROLE') + expect(mocks.createServer).not.toHaveBeenCalled() + }) + + it('surfaces an undeployed workflow as a validation error, not a 500', async () => { + mocks.createServer.mockResolvedValue({ + success: false, + errorCode: 'validation', + error: 'Workflow must be deployed before adding as an MCP tool', + }) + + const response = await post({ + workspaceId: WORKSPACE_ID, + name: 'Support agents', + workflowIds: ['workflow-1'], + }) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toBe( + 'Workflow must be deployed before adding as an MCP tool' + ) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('does not expose an internal orchestration failure message', async () => { + mocks.createServer.mockResolvedValue({ + success: false, + errorCode: 'internal', + error: 'driver connection string', + }) + + const response = await post({ workspaceId: WORKSPACE_ID, name: 'Support agents' }) + + expect(response.status).toBe(500) + expect(JSON.stringify(await response.json())).not.toContain('driver connection string') + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/route.ts b/apps/sim/app/api/v2/workflow-mcp-servers/route.ts new file mode 100644 index 00000000000..be1975a7567 --- /dev/null +++ b/apps/sim/app/api/v2/workflow-mcp-servers/route.ts @@ -0,0 +1,87 @@ +import { + v2CreateWorkflowMcpServerContract, + v2ListWorkflowMcpServersContract, +} from '@/lib/api/contracts/v2/workflow-mcp-servers' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { + createWorkflowMcpDeploymentServer, + listWorkflowMcpDeployments, +} from '@/lib/mcp/application/workflow-deployments' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +import { + toV2WorkflowMcpServer, + toV2WorkflowMcpServerListItem, + workflowMcpServerErrorPolicy, +} from '@/app/api/v2/workflow-mcp-servers/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Every param that changes which servers, in which order, this list returns. */ +function workflowMcpServerCursorFilters(query: { workspaceId: string }) { + return cursorScopeKey(cursorRoute(v2ListWorkflowMcpServersContract), { + workspaceId: query.workspaceId, + }) +} + +/** + * GET /api/v2/workflow-mcp-servers — List MCP servers a workspace publishes. + * + * These are servers Sim *serves*; `GET /api/v2/mcp-servers` lists the external + * ones Sim *calls*. Nothing caps how many a workspace publishes, so the list is + * keyset-paged like its sibling. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListWorkflowMcpServersContract, + auth: v2ApiKeyAuth, + operation: mcpServerOperations.listWorkflowDeployments, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workflowMcpServerErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + workflowMcpServerCursorFilters(query) + ), + }), + useCase: listWorkflowMcpDeployments, + present: ({ servers, nextCursorKeys }, { query }) => ({ + data: servers.map(toV2WorkflowMcpServerListItem), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + workflowMcpServerCursorFilters(query) + ), + }), +}) + +/** + * POST /api/v2/workflow-mcp-servers — Publish a new MCP server. + * + * `workflowIds` publishes those workflows as tools in the same transaction; a + * workflow that is not deployed is refused there rather than silently skipped. + */ +export const POST = defineV2JsonRoute({ + contract: v2CreateWorkflowMcpServerContract, + auth: v2ApiKeyAuth, + operation: mcpServerOperations.createWorkflowDeploymentServer, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workflowMcpServerErrorPolicy, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + description: body.description, + isPublic: body.isPublic, + workflowIds: body.workflowIds, + }), + useCase: createWorkflowMcpDeploymentServer, + present: ({ server }) => ({ data: toV2WorkflowMcpServer(server) }), +}) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/utils.ts b/apps/sim/app/api/v2/workflow-mcp-servers/utils.ts new file mode 100644 index 00000000000..1aaacb436e2 --- /dev/null +++ b/apps/sim/app/api/v2/workflow-mcp-servers/utils.ts @@ -0,0 +1,77 @@ +import { + type V2WorkflowMcpServer, + type V2WorkflowMcpServerListItem, + type V2WorkflowMcpTool, + type V2WorkflowMcpToolListItem, + v2WorkflowMcpServerListItemSchema, + v2WorkflowMcpServerSchema, + v2WorkflowMcpToolListItemSchema, + v2WorkflowMcpToolSchema, +} from '@/lib/api/contracts/v2/workflow-mcp-servers' +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' +import type { WorkflowMcpServerRow, WorkflowMcpToolRow } from '@/lib/mcp/queries' +import { buildWorkflowMcpApiEndpoint, buildWorkflowMcpServerUrl } from '@/lib/mcp/urls' + +/** + * Shared serialization + error mapping for the v2 workflow-MCP surface. + */ + +/** + * Projects a stored workflow-MCP server row onto the public shape. + * + * The row is parsed through {@link v2WorkflowMcpServerSchema}, whose strip + * behaviour is the boundary: `createdBy` and `deletedAt` are dropped rather than + * enumerated by hand, so a column added later cannot leak by omission. + */ +export function toV2WorkflowMcpServer(row: WorkflowMcpServerRow): V2WorkflowMcpServer { + return v2WorkflowMcpServerSchema.parse({ + ...row, + mcpServerUrl: buildWorkflowMcpServerUrl(row.id), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }) +} + +/** {@link toV2WorkflowMcpServer} plus the tool inventory only the list reads. */ +export function toV2WorkflowMcpServerListItem( + row: WorkflowMcpServerRow & { toolCount: number; toolNames: string[] } +): V2WorkflowMcpServerListItem { + return v2WorkflowMcpServerListItemSchema.parse({ + ...row, + mcpServerUrl: buildWorkflowMcpServerUrl(row.id), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }) +} + +export const workflowMcpServerErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'MCP server not found', +}) + +/** + * Projects a stored workflow-MCP tool row onto the public shape. + * + * `updated` is not a column — it is whether the publish replaced an existing + * tool — so it is passed in rather than read off the row. + */ +export function toV2WorkflowMcpTool(row: WorkflowMcpToolRow, updated: boolean): V2WorkflowMcpTool { + return v2WorkflowMcpToolSchema.parse({ + ...row, + mcpServerUrl: buildWorkflowMcpServerUrl(row.serverId), + apiEndpoint: buildWorkflowMcpApiEndpoint(row.workflowId), + updated, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }) +} + +/** {@link toV2WorkflowMcpTool} for a read, which has no publish outcome to report. */ +export function toV2WorkflowMcpToolListItem(row: WorkflowMcpToolRow): V2WorkflowMcpToolListItem { + return v2WorkflowMcpToolListItemSchema.parse({ + ...row, + mcpServerUrl: buildWorkflowMcpServerUrl(row.serverId), + apiEndpoint: buildWorkflowMcpApiEndpoint(row.workflowId), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }) +} diff --git a/apps/sim/app/api/v2/workflows/[id]/deployment/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/deployment/route.test.ts deleted file mode 100644 index 54f7bc7664d..00000000000 --- a/apps/sim/app/api/v2/workflows/[id]/deployment/route.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * @vitest-environment node - */ -import { - MockV2ApiKeyUnauthenticatedError, - V2_OPERATION_RATE_LIMIT_ALLOWED, - V2_PREAUTH_RATE_LIMIT_ALLOWED, - v2ApiKeyAuthModuleMock, - v2GateModuleMock, - v2RateLimiterModuleMock, - v2RouteMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - resolvePermission: vi.fn(), - resolveWorkflowContext: vi.fn(), - getWorkflowDeploymentSummary: vi.fn(), - checkNeedsRedeployment: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: (actual: string | null, required: string) => { - const rank = { read: 1, write: 2, admin: 3 } as const - return ( - actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] - ) - }, - resolveEffectiveWorkspacePermission: mocks.resolvePermission, -})) -vi.mock('@/lib/workflows/application/context', () => ({ - resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, -})) -vi.mock('@/lib/workflows/orchestration/deploy', () => ({ - getWorkflowDeploymentSummary: mocks.getWorkflowDeploymentSummary, - performActivateVersion: vi.fn(), - performFullDeploy: vi.fn(), - performFullUndeploy: vi.fn(), - performRevertToVersion: vi.fn(), -})) -vi.mock('@/lib/workflows/deployment-status', () => ({ - checkNeedsRedeployment: mocks.checkNeedsRedeployment, -})) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) -vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) -vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) - -import { GET } from '@/app/api/v2/workflows/[id]/deployment/route' - -const auth = { - principal: { - kind: 'personal_api_key' as const, - userId: 'user-1', - keyId: 'personal-key-1', - }, - rolloutUserId: 'user-1', - rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, - rateLimitSubscription: null, - keyType: 'personal' as const, -} - -const activeDeployment = { - deploymentVersionId: 'depver-2', - version: 2, - deployedAt: '2026-08-01T00:00:00.000Z', -} - -const latestDeploymentAttempt = { - id: 'op-2', - deploymentVersionId: 'depver-2', - version: 2, - action: 'deploy' as const, - status: 'active' as const, - isCurrent: true, - readiness: { - webhooks: 'not_applicable' as const, - schedules: 'not_applicable' as const, - mcp: 'not_applicable' as const, - }, - requestedAt: '2026-08-01T00:00:00.000Z', - activatedAt: '2026-08-01T00:00:01.000Z', - error: null, -} - -/** - * `workflow.deployedAt` carries a stale timestamp from a deployment that was - * later undeployed — the presenter must never fall back to it. - */ -const workflowContext = { - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - allowPersonalApiKeys: true, - billedAccountUserId: 'billing-owner-1', - workflowId: 'workflow-1', - workflow: { - id: 'workflow-1', - workspaceId: 'workspace-1', - deployedAt: new Date('2025-01-01T00:00:00.000Z'), - }, -} - -async function get() { - const request = new NextRequest('http://localhost/api/v2/workflows/workflow-1/deployment') - return GET(request, { params: Promise.resolve({ id: 'workflow-1' }) }) -} - -describe('GET /api/v2/workflows/[id]/deployment', () => { - beforeEach(() => { - vi.clearAllMocks() - v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.gate.mockResolvedValue(null) - v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) - v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.resolvePermission.mockResolvedValue('read') - mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) - mocks.getWorkflowDeploymentSummary.mockResolvedValue({ - activeDeployment, - latestDeploymentAttempt, - warnings: undefined, - }) - mocks.checkNeedsRedeployment.mockResolvedValue(true) - }) - - it('publishes draft-versus-live drift and the latest attempt after canonical authorization', async () => { - const response = await get() - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - data: { - id: 'workflow-1', - isDeployed: true, - needsRedeployment: true, - deployedAt: '2026-08-01T00:00:00.000Z', - warnings: [], - activeDeployment, - latestDeploymentAttempt, - }, - }) - expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.getWorkflowDeploymentSummary) - }) - - it('carries the failed attempt error payload when nothing is live', async () => { - mocks.getWorkflowDeploymentSummary.mockResolvedValue({ - activeDeployment: null, - latestDeploymentAttempt: { - ...latestDeploymentAttempt, - status: 'failed' as const, - activatedAt: null, - error: { - code: 'webhook_conflict', - message: 'Webhook path already in use', - retryable: false, - }, - }, - warnings: ['Deployment attempt failed'], - }) - - const response = await get() - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.data.isDeployed).toBe(false) - expect(body.data.needsRedeployment).toBe(false) - expect(body.data.deployedAt).toBeNull() - expect(body.data.warnings).toEqual(['Deployment attempt failed']) - expect(body.data.latestDeploymentAttempt.error).toEqual({ - code: 'webhook_conflict', - message: 'Webhook path already in use', - retryable: false, - }) - expect(mocks.checkNeedsRedeployment).not.toHaveBeenCalled() - }) - - it('never reports a deploy time from the stale workflow column once nothing is live', async () => { - mocks.getWorkflowDeploymentSummary.mockResolvedValue({ - activeDeployment: null, - latestDeploymentAttempt: null, - warnings: undefined, - }) - - const response = await get() - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.data.deployedAt).toBeNull() - }) - - it('conceals a workflow the caller cannot reach as 404', async () => { - mocks.resolvePermission.mockResolvedValue(null) - - const response = await get() - - expect(response.status).toBe(404) - expect((await response.json()).error.code).toBe('NOT_FOUND') - expect(mocks.getWorkflowDeploymentSummary).not.toHaveBeenCalled() - }) - - it('rejects an unauthenticated request', async () => { - v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - - const response = await get() - - expect(response.status).toBe(401) - expect((await response.json()).error.code).toBe('UNAUTHORIZED') - }) -}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts deleted file mode 100644 index 351e2de49fa..00000000000 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { V2WorkflowVersionDetail } from '@/lib/api/contracts/v2/workflows' -import { v2GetWorkflowVersionContract } 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 { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -export const GET = defineV2JsonRoute({ - contract: v2GetWorkflowVersionContract, - auth: v2ApiKeyAuth, - operation: workflowOperations.readVersion, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), - useCase: readWorkflowVersion, - present: ({ version }) => ({ - data: { - id: version.id, - version: version.version, - name: version.name, - description: version.description, - isActive: version.isActive, - createdAt: version.createdAt.toISOString(), - state: version.state as V2WorkflowVersionDetail['state'], - }, - }), -}) diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts similarity index 92% rename from apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts index 21e6ad41708..00850da058f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts @@ -27,9 +27,9 @@ import { import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' import { workflowOperations } from '@/lib/workflows/application/operations' -import { DELETE, POST } from '@/app/api/v2/workflows/[id]/deploy/route' +import { DELETE, POST } from '@/app/api/v2/workflows/[workflowId]/deploy/route' -describe('/api/v2/workflows/[id]/deploy route definitions', () => { +describe('/api/v2/workflows/[workflowId]/deploy route definitions', () => { /** * Both the malformed-body 400 and the oversized-body 413 are v2 builder * defaults, so neither belongs on the route. The envelope they produce is @@ -44,7 +44,9 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => { errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, parseOptions: { optionalJsonBody: true }, }) - expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( + expect( + Reflect.get(POST, 'mapInput')({ params: { workflowId: 'workflow-1' }, body: {} }) + ).toEqual( expect.objectContaining({ workflowId: 'workflow-1', name: undefined, diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts similarity index 95% rename from apps/sim/app/api/v2/workflows/[id]/deploy/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts index 93f0e6a8cdb..59aa0fd0e9d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts @@ -23,7 +23,7 @@ export const POST = defineV2JsonRoute({ optionalJsonBody: true, }, mapInput: ({ params, body }) => ({ - workflowId: params.id, + workflowId: params.workflowId, name: body.name, description: body.description ?? undefined, requestId: generateRequestId(), @@ -48,7 +48,7 @@ export const DELETE = defineV2JsonRoute({ operation: workflowOperations.undeploy, rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), + mapInput: ({ params }) => ({ workflowId: params.workflowId, requestId: generateRequestId() }), useCase: undeployWorkflow, present: (result) => ({ data: { diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.test.ts new file mode 100644 index 00000000000..4f4d8e2e584 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.test.ts @@ -0,0 +1,374 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockPublicApiNotAllowedError, mocks } = vi.hoisted(() => { + class MockPublicApiNotAllowedError extends Error {} + return { + MockPublicApiNotAllowedError, + mocks: { + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + getWorkflowDeploymentSummary: vi.fn(), + checkNeedsRedeployment: vi.fn(), + validatePublicApiAllowed: vi.fn(), + updatePublicApiRow: vi.fn(), + audit: vi.fn(), + notifyUpdated: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_PUBLIC_API_TOGGLED: 'workflow.public_api_toggled' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/db', () => ({ + db: { + update: () => ({ + set: () => ({ where: () => ({ returning: () => mocks.updatePublicApiRow() }) }), + }), + }, + workflow: {}, +})) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + PublicApiNotAllowedError: MockPublicApiNotAllowedError, + validatePublicApiAllowed: mocks.validatePublicApiAllowed, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notifyUpdated })) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/workflows/orchestration/deploy', () => ({ + getWorkflowDeploymentSummary: mocks.getWorkflowDeploymentSummary, + performActivateVersion: vi.fn(), + performFullDeploy: vi.fn(), + performFullUndeploy: vi.fn(), + performRevertToVersion: vi.fn(), +})) +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.checkNeedsRedeployment, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { GET, PATCH } from '@/app/api/v2/workflows/[workflowId]/deployment/route' + +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const activeDeployment = { + deploymentVersionId: 'depver-2', + version: 2, + deployedAt: '2026-08-01T00:00:00.000Z', +} + +const latestDeploymentAttempt = { + id: 'op-2', + deploymentVersionId: 'depver-2', + version: 2, + action: 'deploy' as const, + status: 'active' as const, + isCurrent: true, + readiness: { + webhooks: 'not_applicable' as const, + schedules: 'not_applicable' as const, + mcp: 'not_applicable' as const, + }, + requestedAt: '2026-08-01T00:00:00.000Z', + activatedAt: '2026-08-01T00:00:01.000Z', + error: null, +} + +/** + * `workflow.deployedAt` carries a stale timestamp from a deployment that was + * later undeployed — the presenter must never fall back to it. + */ +const workflowContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + workflowId: 'workflow-1', + workflow: { + id: 'workflow-1', + workspaceId: 'workspace-1', + deployedAt: new Date('2025-01-01T00:00:00.000Z'), + isPublicApi: false, + }, +} + +async function get() { + const request = new NextRequest('http://localhost/api/v2/workflows/workflow-1/deployment') + return GET(request, { params: Promise.resolve({ workflowId: 'workflow-1' }) }) +} + +describe('GET /api/v2/workflows/[workflowId]/deployment', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment, + latestDeploymentAttempt, + warnings: undefined, + }) + mocks.checkNeedsRedeployment.mockResolvedValue(true) + }) + + it('publishes draft-versus-live drift and the latest attempt after canonical authorization', async () => { + const response = await get() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: 'workflow-1', + isDeployed: true, + needsRedeployment: true, + isPublicApi: false, + deployedAt: '2026-08-01T00:00:00.000Z', + warnings: [], + activeDeployment, + latestDeploymentAttempt, + }, + }) + expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.getWorkflowDeploymentSummary) + }) + + it('carries the failed attempt error payload when nothing is live', async () => { + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { + ...latestDeploymentAttempt, + status: 'failed' as const, + activatedAt: null, + error: { + code: 'webhook_conflict', + message: 'Webhook path already in use', + retryable: false, + }, + }, + warnings: ['Deployment attempt failed'], + }) + + const response = await get() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.isDeployed).toBe(false) + expect(body.data.needsRedeployment).toBe(false) + expect(body.data.deployedAt).toBeNull() + expect(body.data.warnings).toEqual(['Deployment attempt failed']) + expect(body.data.latestDeploymentAttempt.error).toEqual({ + code: 'webhook_conflict', + message: 'Webhook path already in use', + retryable: false, + }) + expect(mocks.checkNeedsRedeployment).not.toHaveBeenCalled() + }) + + it('never reports a deploy time from the stale workflow column once nothing is live', async () => { + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: undefined, + }) + + const response = await get() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.deployedAt).toBeNull() + }) + + /** + * `isPublicApi` removes authentication from a deployed workflow and was + * settable through `PATCH` on this path while appearing in no read, so a + * caller had no way to audit whether it was on. It must track the column in + * both directions, not be pinned to a constant. + */ + it('publishes the public-API flag in both states', async () => { + const offBody = await (await get()).json() + expect(offBody.data.isPublicApi).toBe(false) + + mocks.resolveWorkflowContext.mockResolvedValue({ + ...workflowContext, + workflow: { ...workflowContext.workflow, isPublicApi: true }, + }) + + const onBody = await (await get()).json() + expect(onBody.data.isPublicApi).toBe(true) + }) + + it('conceals a workflow the caller cannot reach as 404', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await get() + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(mocks.getWorkflowDeploymentSummary).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await get() + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) +}) + +const workspaceKeyAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:workspace-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +async function patch(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/workflows/workflow-1/deployment', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return PATCH(request, { params: Promise.resolve({ workflowId: 'workflow-1' }) }) +} + +describe('PATCH /api/v2/workflows/[workflowId]/deployment', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.validatePublicApiAllowed.mockResolvedValue(undefined) + mocks.updatePublicApiRow.mockResolvedValue([{ id: 'workflow-1' }]) + }) + + /** + * The widening this route depends on: the operation used to accept sessions + * only, which made a personal key — the same accountable human — a 403. + */ + it('accepts a personal API key and checks the sharing policy for the acting human', async () => { + const response = await patch({ isPublicApi: true }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'workflow-1', isPublicApi: true } }) + expect(mocks.validatePublicApiAllowed).toHaveBeenCalledWith('user-1', 'workspace-1') + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.notifyUpdated).toHaveBeenCalledWith('workflow-1') + }) + + it('does not consult the sharing policy when disabling public access', async () => { + const response = await patch({ isPublicApi: false }) + + expect(response.status).toBe(200) + expect((await response.json()).data.isPublicApi).toBe(false) + expect(mocks.validatePublicApiAllowed).not.toHaveBeenCalled() + }) + + it('names the sharing refusal with an actionable forbidden code', async () => { + mocks.validatePublicApiAllowed.mockRejectedValue( + new MockPublicApiNotAllowedError('not allowed') + ) + + const response = await patch({ isPublicApi: true }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error.details.code).toBe('PUBLIC_SHARING_NOT_ALLOWED') + expect(body.error.message).toBe('Public API access is disabled') + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects a workspace API key before canonical loading', async () => { + v2RouteMocks.authenticate.mockResolvedValue(workspaceKeyAuth) + + const response = await patch({ isPublicApi: true }) + + expect(response.status).toBe(403) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + }) + + it('refuses a caller below workspace admin with 403', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + const response = await patch({ isPublicApi: true }) + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('INSUFFICIENT_WORKSPACE_ROLE') + expect(mocks.updatePublicApiRow).not.toHaveBeenCalled() + }) + + it('conceals a workflow the caller cannot reach as 404', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await patch({ isPublicApi: true }) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(mocks.updatePublicApiRow).not.toHaveBeenCalled() + }) + + it('rejects a body that names no setting', async () => { + const response = await patch({}) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.updatePublicApiRow).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await patch({ isPublicApi: true }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.ts similarity index 50% rename from apps/sim/app/api/v2/workflows/[id]/deployment/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.ts index 6999c325e3c..b473aebb4cd 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.ts @@ -1,14 +1,18 @@ -import { v2GetWorkflowDeploymentContract } from '@/lib/api/contracts/v2/workflows' +import { + v2GetWorkflowDeploymentContract, + v2UpdateWorkflowPublicApiContract, +} from '@/lib/api/contracts/v2/workflows' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { readWorkflowDeploymentStatus } from '@/lib/workflows/application/deployments' import { workflowOperations } from '@/lib/workflows/application/operations' +import { updateWorkflowPublicApi } from '@/lib/workflows/application/update-workflow-deployment-settings' export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * GET /api/v2/workflows/[id]/deployment — Read current deployment state. + * GET /api/v2/workflows/[workflowId]/deployment — Read current deployment state. * * The deploy, undeploy, and rollback responses are the only other place this * state is published, so a caller that lost one — or that polls from a @@ -16,6 +20,11 @@ export const revalidate = 0 * only: it compares the draft against the live version, so it is meaningless on * the response of the mutation that just made them equal. * + * `isPublicApi` is published here because it was otherwise write-only: it is + * settable through `PATCH` on this path but appeared in no read, so a caller + * that removed authentication from a deployed workflow had no way to audit + * that it was still off. + * * `deployedAt` comes from the active deployment version, which always carries * one. The workflow's own `deployed_at` column is deliberately not used as a * fallback: it retains the timestamp of a deployment that has since been @@ -23,7 +32,7 @@ export const revalidate = 0 * `isDeployed: false`. * * Deliberately head-safe despite the migrate-on-read write, for the reasons on - * `GET /api/v2/workflows/[id]`. + * `GET /api/v2/workflows/[workflowId]`. */ export const GET = defineV2JsonRoute({ contract: v2GetWorkflowDeploymentContract, @@ -31,13 +40,14 @@ export const GET = defineV2JsonRoute({ operation: workflowOperations.read, rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params }) => ({ workflowId: params.id }), + mapInput: ({ params }) => ({ workflowId: params.workflowId }), useCase: readWorkflowDeploymentStatus, present: (result) => ({ data: { id: result.workflow.id, isDeployed: result.isDeployed, needsRedeployment: result.needsRedeployment, + isPublicApi: result.workflow.isPublicApi, deployedAt: result.activeDeployment?.deployedAt ?? null, warnings: result.warnings ?? [], activeDeployment: result.activeDeployment ?? null, @@ -45,3 +55,31 @@ export const GET = defineV2JsonRoute({ }, }), }) + +/** + * PATCH /api/v2/workflows/[workflowId]/deployment — public API access. + * + * Enabling this removes the authentication requirement from the deployed + * workflow: anyone holding the URL can execute it. It is therefore an admin + * operation restricted to human principals, and an organization that forbids + * public sharing refuses it with `PUBLIC_SHARING_NOT_ALLOWED`. + * + * It shares a path with the deployment read rather than taking one of its own + * because the flag is deployment state — `GET` on this path is where a caller + * looks to see what a deploy currently exposes. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateWorkflowPublicApiContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.updatePublicApi, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, body }) => ({ + workflowId: params.workflowId, + isPublicApi: body.isPublicApi, + }), + useCase: updateWorkflowPublicApi, + present: (result) => ({ + data: { id: result.workflowId, isPublicApi: result.isPublicApi }, + }), +}) 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 new file mode 100644 index 00000000000..f5557643113 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts @@ -0,0 +1,492 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + resetDbChainMock, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + loadWorkspaceContext: vi.fn(), + resolveWorkflowContext: vi.fn(), + getLiveChatDeployment: vi.fn(), + getIdentifierOwner: vi.fn(), + performChatDeploy: vi.fn(), + performChatUndeploy: vi.fn(), + validateChatDeployAuth: vi.fn(), + audit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { CHAT_DEPLOYED: 'chat.deployed', CHAT_DELETED: 'chat.deleted' }, + AuditResourceType: { CHAT: 'chat' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspaceContext, + resolveActiveWorkspaceApplicationContext: async (workspaceId: string) => { + const context = await mocks.loadWorkspaceContext(workspaceId) + if (!context) throw new Error('Workspace not found') + return context + }, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/chat-deployments/queries', () => ({ + listWorkspaceChatDeployments: vi.fn(), + getLiveChatDeploymentForWorkflow: mocks.getLiveChatDeployment, + getChatDeploymentIdOwningIdentifier: mocks.getIdentifierOwner, + getChatDeploymentWithWorkspace: vi.fn(), + updateChatDeploymentRow: vi.fn(), +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + performChatDeploy: mocks.performChatDeploy, + performChatUndeploy: mocks.performChatUndeploy, + getWorkflowDeploymentSummary: vi.fn(), + performFullDeploy: vi.fn(), +})) +vi.mock('@/ee/access-control/utils/permission-check', () => { + class ChatDeployAuthNotAllowedError extends Error { + constructor() { + super('This chat authentication mode is not allowed') + this.name = 'ChatDeployAuthNotAllowedError' + } + } + return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } +}) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { DELETE, GET, PUT } from '@/app/api/v2/workflows/[workflowId]/deployments/chat/route' +import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' +const PATH = `http://localhost/api/v2/workflows/${WORKFLOW_ID}/deployments/chat` + +const personalKeyAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const workspaceKeyAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:workspace-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +function chatRow(overrides: Record = {}) { + return { + id: 'chat-1', + workflowId: WORKFLOW_ID, + userId: 'owner-1', + identifier: 'support', + title: 'Support chat', + description: 'Ask us anything', + isActive: true, + customizations: { primaryColor: '#000', welcomeMessage: 'Hi' }, + authType: 'public', + password: null, + allowedEmails: [], + outputConfigs: [], + includeThinking: false, + includeToolCalls: null, + archivedAt: null, + createdAt: new Date('2026-06-12T10:30:00.000Z'), + updatedAt: new Date('2026-06-12T10:30:00.000Z'), + ...overrides, + } +} + +const routeContext = { params: Promise.resolve({ workflowId: WORKFLOW_ID }) } + +const get = () => GET(new NextRequest(PATH), routeContext) +const del = () => DELETE(new NextRequest(PATH, { method: 'DELETE' }), routeContext) +const put = (body: unknown) => + PUT( + new NextRequest(PATH, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext + ) + +/** The shape the `postgres` driver throws when a partial unique index rejects a write. */ +function uniqueViolation(constraint: string) { + return Object.assign( + new Error(`duplicate key value violates unique constraint "${constraint}"`), + { code: '23505', constraint_name: constraint } + ) +} + +const validBody = { identifier: 'support', title: 'Support chat' } + +describe('/api/v2/workflows/[workflowId]/deployments/chat', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + v2RouteMocks.authenticate.mockResolvedValue(personalKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.resolveWorkflowContext.mockResolvedValue({ + ...workspaceContext, + workflowId: WORKFLOW_ID, + workflow: { id: WORKFLOW_ID, name: 'Support', workspaceId: WORKSPACE_ID }, + }) + mocks.getLiveChatDeployment.mockResolvedValue(chatRow()) + mocks.getIdentifierOwner.mockResolvedValue(null) + mocks.validateChatDeployAuth.mockResolvedValue(undefined) + mocks.performChatDeploy.mockResolvedValue({ + success: true, + chatId: 'chat-1', + chatUrl: 'http://localhost:3000/chat/support', + isUpdate: false, + }) + mocks.performChatUndeploy.mockResolvedValue({ success: true }) + }) + + describe('GET', () => { + it("publishes the workflow's chat with its public URL and no password", async () => { + mocks.getLiveChatDeployment.mockResolvedValue( + chatRow({ authType: 'password', password: 'encrypted-secret' }) + ) + + const response = await get() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toMatchObject({ + id: 'chat-1', + workflowId: WORKFLOW_ID, + workspaceId: WORKSPACE_ID, + identifier: 'support', + url: expect.stringContaining('/chat/support'), + hasPassword: true, + }) + expect(JSON.stringify(body)).not.toContain('encrypted-secret') + }) + + it('answers 404 when the workflow publishes no chat', async () => { + mocks.getLiveChatDeployment.mockResolvedValue(null) + + const response = await get() + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + /** The gate configuration it carries is admin-only, unlike the workspace list. */ + it('refuses a caller below workspace admin with 403', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + const response = await get() + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('INSUFFICIENT_WORKSPACE_ROLE') + }) + + it('rejects an undeclared query param rather than ignoring it', async () => { + const response = await GET(new NextRequest(`${PATH}?workspaceId=other`), routeContext) + + expect(response.status).toBe(400) + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + expect((await get()).status).toBe(401) + }) + }) + + describe('PUT', () => { + it('creates the chat when the workflow publishes none', async () => { + mocks.getLiveChatDeployment.mockResolvedValueOnce(null).mockResolvedValue(chatRow()) + + const response = await put(validBody) + + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ + id: 'chat-1', + workspaceId: WORKSPACE_ID, + identifier: 'support', + url: expect.stringContaining('/chat/support'), + }) + expect(mocks.audit).toHaveBeenCalledTimes(1) + }) + + /** + * The defining property of the verb, and the one a merge-shaped + * implementation would silently break: an omitted optional field must take + * its platform default, never the value the previous deployment carried. + */ + it('replaces wholesale rather than merging the previous deployment', async () => { + mocks.getLiveChatDeployment.mockResolvedValue( + chatRow({ + authType: 'email', + password: 'encrypted-secret', + allowedEmails: ['old@example.com'], + outputConfigs: [{ blockId: 'block-1', path: 'content' }], + includeThinking: true, + includeToolCalls: true, + description: 'Previous description', + }) + ) + + await put(validBody) + + expect(mocks.performChatDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + authType: 'public', + password: null, + allowedEmails: [], + outputConfigs: [], + includeThinking: false, + includeToolCalls: false, + description: '', + }) + ) + }) + + it('is idempotent: the same body twice asks for the same stored state', async () => { + await put(validBody) + const first = mocks.performChatDeploy.mock.calls[0][0] + mocks.performChatDeploy.mockClear() + await put(validBody) + + expect(mocks.performChatDeploy.mock.calls[0][0]).toEqual(first) + }) + + /** + * `password` is write-only, so a caller cannot read one back to re-send it. + * Requiring it is what stops replace quietly carrying a secret over. + */ + it('requires a password whenever the result is password-gated', async () => { + const response = await put({ ...validBody, authType: 'password' }) + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toBe( + 'password is required when authType is "password"' + ) + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + it('rejects a password the resulting mode would not store', async () => { + const response = await put({ ...validBody, authType: 'email', password: 'hunter2' }) + + expect(response.status).toBe(400) + expect(JSON.stringify(await response.json())).toContain('password cannot be set') + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + it.each(['email', 'sso'])('refuses %s gating with an empty allow-list', async (authType) => { + const response = await put({ ...validBody, authType }) + + expect(response.status).toBe(400) + expect(JSON.stringify(await response.json())).toContain('allowedEmails must contain at least') + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + it('rejects an allow-list the resulting mode would not admit', async () => { + const response = await put({ ...validBody, allowedEmails: ['a@example.com'] }) + + expect(response.status).toBe(400) + expect(JSON.stringify(await response.json())).toContain('allowedEmails cannot be set') + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + it('rejects an unknown field rather than storing it', async () => { + const response = await put({ ...validBody, workflowId: WORKFLOW_ID }) + + expect(response.status).toBe(400) + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + it('reports an identifier the pre-check finds taken as 409', async () => { + mocks.getIdentifierOwner.mockResolvedValue('other-chat') + + const response = await put(validBody) + + expect(response.status).toBe(409) + expect((await response.json()).error.message).toBe('Identifier already in use') + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + /** + * The race the pre-check cannot close: another caller claims the identifier + * between the check and the write, so the partial unique index rejects this + * one. Unclassified that surfaced as a caller-reachable `500`, which is the + * highest-severity defect class on this surface — it is the same condition + * the pre-check reports, so it answers the same `409`. + */ + it('reports losing the identifier race as 409, not 500', async () => { + mocks.performChatDeploy.mockRejectedValue(uniqueViolation('identifier_idx')) + + const response = await put(validBody) + + expect(response.status).toBe(409) + const body = await response.json() + expect(body.error.code).toBe('CONFLICT') + expect(body.error.message).toContain('support') + expect(body.error.message).toContain('choose a different identifier') + expect(mocks.audit).not.toHaveBeenCalled() + }) + + /** Only that index is the caller's conflict; any other violation is a real fault. */ + it('keeps a unique violation on a different constraint a 500', async () => { + mocks.performChatDeploy.mockRejectedValue(uniqueViolation('chat_pkey')) + + const response = await put(validBody) + + expect(response.status).toBe(500) + expect((await response.json()).error.code).toBe('INTERNAL_ERROR') + }) + + it('reports an in-flight workflow deployment as a conflict', async () => { + mocks.performChatDeploy.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A workflow deployment is still preparing.', + }) + + const response = await put(validBody) + + expect(response.status).toBe(409) + expect((await response.json()).error.message).toContain('still preparing') + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('keeps an internal invariant failure a 500 with a generic message', async () => { + mocks.performChatDeploy.mockResolvedValue({ + success: false, + errorCode: 'internal', + error: 'Workflow deployment reported active without a live deployment version.', + }) + + const response = await put(validBody) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body.error.code).toBe('INTERNAL_ERROR') + expect(JSON.stringify(body)).not.toContain('live deployment version') + }) + + it('rejects a workspace API key before canonical loading', async () => { + v2RouteMocks.authenticate.mockResolvedValue(workspaceKeyAuth) + + const response = await put(validBody) + + expect(response.status).toBe(403) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + }) + + it('refuses a caller below workspace admin with 403', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + const response = await put(validBody) + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('INSUFFICIENT_WORKSPACE_ROLE') + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + it('names a blocked auth mode with an actionable forbidden code', async () => { + mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) + + const response = await put({ + ...validBody, + authType: 'email', + allowedEmails: ['a@example.com'], + }) + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('CHAT_AUTH_MODE_NOT_PERMITTED') + expect(mocks.performChatDeploy).not.toHaveBeenCalled() + }) + + /** A mode already stored can be re-saved without re-clearing the allow-list check. */ + it('does not re-check an auth mode the chat already carries', async () => { + mocks.getLiveChatDeployment.mockResolvedValue(chatRow({ authType: 'public' })) + + await put(validBody) + + expect(mocks.validateChatDeployAuth).not.toHaveBeenCalled() + }) + }) + + describe('DELETE', () => { + it('stops the chat serving and leaves the workflow deployment alone', async () => { + const response = await del() + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ id: 'chat-1', deleted: true }) + expect(mocks.performChatUndeploy).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'chat-1', workspaceId: WORKSPACE_ID }) + ) + }) + + it('answers 404 when the workflow publishes no chat', async () => { + mocks.getLiveChatDeployment.mockResolvedValue(null) + + expect((await del()).status).toBe(404) + expect(mocks.performChatUndeploy).not.toHaveBeenCalled() + }) + + /** An infrastructure failure must not read as "the chat is already gone". */ + it('keeps a non-not-found undeploy failure a 500', async () => { + mocks.performChatUndeploy.mockResolvedValue({ + success: false, + errorCode: 'internal', + error: 'storage unavailable', + }) + + expect((await del()).status).toBe(500) + }) + + it('refuses a caller below workspace admin with 403', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + expect((await del()).status).toBe(403) + expect(mocks.performChatUndeploy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.ts new file mode 100644 index 00000000000..13ad41bfe26 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.ts @@ -0,0 +1,120 @@ +import { + v2DeleteWorkflowChatDeploymentContract, + v2GetWorkflowChatDeploymentContract, + v2ReplaceWorkflowChatDeploymentContract, +} from '@/lib/api/contracts/v2/chat-deployments' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { + chatDeploymentOperations, + deleteWorkflowChatDeployment, + readWorkflowChatDeployment, + replaceWorkflowChatDeployment, +} from '@/lib/chat-deployments/application' +import { generateRequestId } from '@/lib/core/utils/request' +import { chatDeploymentErrorPolicy, toV2ChatDeployment } from '@/app/api/v2/chat-deployments/utils' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +/** + * The chat a workflow is published as — a singleton of its workflow. + * + * Not to be confused with `/api/v2/workflows/{workflowId}/deployment` (singular), which + * is the workflow's **own** API deployment: its live version, `isPublicApi`, and + * whether the draft has drifted. That one governs whether the workflow can be + * executed at all; this one governs one surface it is served on. A workflow can + * have an API deployment and no chat, and deleting the chat leaves the API + * deployment live and executable. + * + * `deployments/chat` is plural-and-child because it names a member of the set of + * surfaces a workflow is published on. `deployment` is singular because a + * workflow has exactly one of those. + * + * There is no `POST` and no `PATCH`. A singleton is already uniquely addressed + * by its parent, so it has no separate create verb: `PUT` is create-or-replace + * and is the only write, which is what keeps one domain effect from being + * reachable through two authorization paths. + */ + +/** + * GET — read the workflow's chat. + * + * `404` when the workflow publishes no chat, which is also what a workflow the + * caller cannot reach answers. Carries the visitor gate — `authType`, + * `hasPassword`, and the `allowedEmails` allow-list — so it requires workspace + * `admin`, unlike the workspace-wide list. The stored password is never + * returned; `hasPassword` reports only whether one is set. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowChatDeploymentContract, + auth: v2ApiKeyAuth, + operation: chatDeploymentOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: chatDeploymentErrorPolicy, + mapInput: ({ params }) => ({ workflowId: params.workflowId }), + useCase: readWorkflowChatDeployment, + present: ({ deployment, workspaceId }) => ({ + data: toV2ChatDeployment(deployment, workspaceId), + }), +}) + +/** + * PUT — create the workflow's chat, or replace it wholesale. + * + * Replace, not merge: the deployment ends up as exactly what the body describes, + * so an omitted optional field takes its platform default rather than whatever + * the previous deployment carried. `password` is therefore required whenever the + * result is password-gated and rejected otherwise — it is write-only, so a + * caller cannot read one back to re-send it, and carrying it over implicitly is + * the one place a replace would quietly stop meaning replace. + * + * `customizations` is the documented exception, and it is not this surface's to + * fix: `performChatDeploy` is shared with the internal editor and the Copilot + * deploy tool, which both send partial objects and rely on a per-field merge, so + * an empty `imageUrl` keeps the stored one rather than clearing it. That same + * shared rebuild keeps only the three keys declared here, so customization keys + * written by another surface — the editor's `logoUrl` and `headerText` — do not + * survive a deploy from any surface, this one included. Both behaviors predate + * this endpoint; changing either changes the editor and Copilot too. + * + * This also deploys the workflow, because a chat serves the live version: a + * drifted draft is republished as part of the call, and a call landing while + * another deployment attempt is still preparing is a `409` rather than a second + * admitted version. An identifier another live deployment already holds is the + * other `409`. + */ +export const PUT = defineV2JsonRoute({ + contract: v2ReplaceWorkflowChatDeploymentContract, + auth: v2ApiKeyAuth, + operation: chatDeploymentOperations.replace, + rateLimit: v2RateLimits.publicApi, + errorPolicy: chatDeploymentErrorPolicy, + mapInput: ({ params, body }) => ({ + ...body, + workflowId: params.workflowId, + requestId: generateRequestId(), + }), + useCase: replaceWorkflowChatDeployment, + present: ({ deployment, workspaceId }) => ({ + data: toV2ChatDeployment(deployment, workspaceId), + }), +}) + +/** + * DELETE — stop serving the workflow's chat. + * + * Its URL stops answering and the identifier becomes free again. The workflow's + * own deployment is untouched and stays executable through the workflow API — + * to undeploy that, use `/api/v2/workflows/{workflowId}/deployment`. + */ +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteWorkflowChatDeploymentContract, + auth: v2ApiKeyAuth, + operation: chatDeploymentOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: chatDeploymentErrorPolicy, + mapInput: ({ params }) => ({ workflowId: params.workflowId }), + useCase: deleteWorkflowChatDeployment, + present: ({ deployment }) => ({ data: { id: deployment.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/duplicate/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/duplicate/route.test.ts new file mode 100644 index 00000000000..04332c87dc8 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/duplicate/route.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ duplicateWorkflow: vi.fn() })) + +vi.mock('@/lib/workflows/application/duplicate-workflow', () => ({ + duplicateWorkflow: { + operation: { id: 'workflows.duplicate' }, + execute: mocks.duplicateWorkflow, + }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { POST } from '@/app/api/v2/workflows/[workflowId]/duplicate/route' + +const WORKFLOW_ID = 'workflow-1' +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', keyId: 'ws-key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:ws-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const routeContext = { params: Promise.resolve({ workflowId: WORKFLOW_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/v2/workflows/[workflowId]/duplicate', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.duplicateWorkflow.mockResolvedValue({ + id: 'workflow-2', + name: 'Daily digest (copy)', + description: null, + workspaceId: 'workspace-1', + folderId: null, + folderPath: '/Operations', + sortOrder: 0, + locked: false, + blocksCount: 3, + edgesCount: 2, + subflowsCount: 0, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + }) + + it('authenticates before parsing the body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await POST(request({ nonsense: true }), routeContext) + + expect(response.status).toBe(401) + expect(mocks.duplicateWorkflow).not.toHaveBeenCalled() + }) + + it('creates the copy with the workflow summary contract', async () => { + const response = await POST(request({ folderPath: '/Operations' }), routeContext) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ + data: { + id: 'workflow-2', + name: 'Daily digest (copy)', + description: null, + folderPath: '/Operations', + workspaceId: 'workspace-1', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, + }) + expect(mocks.duplicateWorkflow).toHaveBeenCalledWith({ + principal: auth.principal, + input: { sourceWorkflowId: WORKFLOW_ID, name: undefined, folderPath: '/Operations' }, + request: expect.anything(), + }) + }) + + it('accepts an empty body and lets the use case default the name', async () => { + const response = await POST(request({}), routeContext) + + expect(response.status).toBe(201) + expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + input: { sourceWorkflowId: WORKFLOW_ID, name: undefined, folderPath: undefined }, + }) + ) + }) + + it('rejects an unknown body member', async () => { + const response = await POST(request({ folderId: 'folder-1' }), routeContext) + + expect(response.status).toBe(400) + expect(mocks.duplicateWorkflow).not.toHaveBeenCalled() + }) + + it('conceals a cross-tenant duplicate as not found', async () => { + mocks.duplicateWorkflow.mockRejectedValue(new NoWorkspaceAccessError('workspace-2')) + + const response = await POST(request({}), routeContext) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/duplicate/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/duplicate/route.ts new file mode 100644 index 00000000000..d558275c335 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/duplicate/route.ts @@ -0,0 +1,37 @@ +import { v2DuplicateWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { duplicateWorkflow } from '@/lib/workflows/application/duplicate-workflow' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ + contract: v2DuplicateWorkflowContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.duplicate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, body }) => ({ + sourceWorkflowId: params.workflowId, + name: body.name, + folderPath: body.folderPath, + }), + useCase: duplicateWorkflow, + present: (result) => ({ + data: { + id: result.id, + name: result.name, + description: result.description, + folderPath: result.folderPath, + workspaceId: result.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: result.createdAt.toISOString(), + updatedAt: result.updatedAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts similarity index 99% rename from apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index 8d2abfda872..d17c55e1149 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -198,7 +198,7 @@ function callExecute(body: Record, headers: Record, headers: Record = {}) { @@ -206,10 +206,10 @@ function callPublicExecute(body: Record, headers: Record { +describe('POST /api/v2/workflows/[workflowId]/execute', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts similarity index 90% rename from apps/sim/app/api/v2/workflows/[id]/execute/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts index 002fa26b1c3..e170353e107 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts @@ -5,6 +5,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import type { ContractParamsInput } from '@/lib/api/contracts' import type { V2ErrorCode } from '@/lib/api/contracts/v2/error-codes' import { V2_WORKFLOW_RUN_ID_HEADER, @@ -16,6 +17,7 @@ import { V2_PARSE_DEFAULTS, V2RouteInfrastructureError, v2ApiKeyAuth, + v2InvalidBodyResponse, v2RateLimits, } from '@/lib/api/server/routes' import type { V2ApiKeyPrincipal } from '@/lib/api/server/routes/v2-api-key-auth' @@ -98,7 +100,24 @@ function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { } /** - * POST /api/v2/workflows/[id]/execute — syntactic sugar over + * Path parameters read straight from the Next context, typed from the contract + * rather than restated inline. + * + * This route keeps raw `withRouteHandler` because it owns a pre-parse lifecycle + * the declarative builders do not model — optional (anonymous public-API) auth, + * a call-chain guard, a capacity ticket, and an SSE passthrough response — so it + * must know the workflow id before `parseRequest` reads the body. Deriving the + * shape from `v2ExecuteWorkflowContract` keeps that early read on the + * type-checked edge: renaming the dynamic segment (and with it the contract's + * param) fails to compile here instead of silently yielding `undefined`. + * `parseRequest` still re-validates the same values below. + */ +type V2ExecuteWorkflowRouteContext = { + params: Promise> +} + +/** + * POST /api/v2/workflows/[workflowId]/execute — syntactic sugar over * {@link executeWorkflowService}. * * - Auth: `X-API-Key` (personal/workspace) or the anonymous public-API path for @@ -115,9 +134,9 @@ function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { * bucket, quota, billing, and concurrency checks. */ export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + async (req: NextRequest, context: V2ExecuteWorkflowRouteContext) => { const requestId = generateRequestId() - const { id: workflowId } = await context.params + const { workflowId } = await context.params let userId: string let isPublicApiAccess = false @@ -191,6 +210,10 @@ export const POST = withRouteHandler( try { const parsed = await parseRequest(v2ExecuteWorkflowContract, req, context, { ...V2_PARSE_DEFAULTS, + // The defaults' shared entry takes no arguments, so it can only answer 400. + // This route publishes 415, and only a caller still holding the request can + // install the media-type-aware form — see V2_PARSE_DEFAULTS' own TSDoc. + invalidJsonResponse: () => v2InvalidBodyResponse(req), maxBodyBytes: 10 * 1024 * 1024, }) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.test.ts similarity index 93% rename from apps/sim/app/api/v2/workflows/[id]/export/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/export/route.test.ts index 872932e6ba1..641c88e6372 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.test.ts @@ -19,9 +19,9 @@ vi.mock('@/lib/api/server/routes', () => ({ import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { exportWorkflow } from '@/lib/workflows/application/import-export' import { workflowOperations } from '@/lib/workflows/application/operations' -import { GET } from '@/app/api/v2/workflows/[id]/export/route' +import { GET } from '@/app/api/v2/workflows/[workflowId]/export/route' -describe('/api/v2/workflows/[id]/export route definition', () => { +describe('/api/v2/workflows/[workflowId]/export route definition', () => { it('uses canonical workflow authorization with tenant-boundary concealment', () => { expect(GET).toMatchObject({ operation: workflowOperations.export, diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts similarity index 95% rename from apps/sim/app/api/v2/workflows/[id]/export/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts index ad3af5d0518..93b622d85bf 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts @@ -19,7 +19,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, headSafe: false, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params }) => ({ workflowId: params.id }), + mapInput: ({ params }) => ({ workflowId: params.workflowId }), useCase: exportWorkflow, present: ({ payload, folderPath }) => ({ data: { diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts new file mode 100644 index 00000000000..eadb9065a8a --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts @@ -0,0 +1,324 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + applyWorkflowOperations: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/apply-workflow-operations', () => ({ + applyWorkflowOperations: { + operation: { id: 'workflows.operations.apply' }, + execute: mocks.applyWorkflowOperations, + }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' +import { POST } from '@/app/api/v2/workflows/[workflowId]/operations/route' + +const WORKFLOW_ID = 'workflow-1' +const SKIPPED = { + type: 'duplicate_block_name', + operationType: 'add', + blockId: 'block-2', + reason: 'Name taken', +} + +const DROPPED_INPUT = { + blockId: 'block-2', + blockType: 'agent', + field: 'credential', + value: 'cred-9', + error: 'Invalid credential ID', +} + +/** An empty report, with every field the contract publishes. */ +const LINT = { + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + fieldIssues: [], + unresolvedReferences: [], + notes: [], +} + +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const routeContext = { params: Promise.resolve({ workflowId: WORKFLOW_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/operations`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const ADD = { + operation_type: 'add', + block_id: 'block-2', + params: { type: 'agent', name: 'Triage' }, +} + +describe('/api/v2/workflows/[workflowId]/operations', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.applyWorkflowOperations.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workflowName: 'Daily digest', + workspaceId: 'workspace-1', + graph: { blocks: {}, edges: [], loops: {}, parallels: {} }, + operationCount: 1, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + mintedBlockIds: { 'agent-1': 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77' }, + lint: LINT, + warnings: [], + needsRedeployment: true, + dryRun: false, + }) + }) + + it('authenticates before parsing the body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await POST(request({ nonsense: true }), routeContext) + + expect(response.status).toBe(401) + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) + + it('applies a batch and returns the exact result contract', async () => { + const response = await POST(request({ operations: [ADD] }), routeContext) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: WORKFLOW_ID, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + mintedBlockIds: { 'agent-1': 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77' }, + lint: LINT, + warnings: [], + needsRedeployment: true, + dryRun: false, + }, + }) + expect(mocks.applyWorkflowOperations).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + workflowId: WORKFLOW_ID, + operations: [ADD], + atomic: false, + layout: 'targeted', + }), + }) + ) + }) + + it('maps the setBlockEnabled flag onto the use case input', async () => { + await POST( + request({ + operations: [ADD], + setBlockEnabled: [{ block_id: 'block-1', enabled: false }], + }), + routeContext + ) + + expect(mocks.applyWorkflowOperations).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + blockEnabledChanges: [{ blockId: 'block-1', enabled: false }], + }), + }) + ) + }) + + it('answers a refused atomic batch with 409 and the declined operations', async () => { + mocks.applyWorkflowOperations.mockRejectedValue( + new WorkflowOperationsNotAppliedError([SKIPPED] as never) + ) + + const response = await POST(request({ operations: [ADD], atomic: true }), routeContext) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: + '1 operation(s) could not be applied and 0 input(s) would have been dropped; atomic was requested, so nothing was written', + details: { code: 'OPERATIONS_NOT_APPLIED', skipped: [SKIPPED], droppedInputs: [] }, + }, + }) + }) + + /** + * A stripped credential refuses the batch too, and the caller needs to see + * which field went — a `skipped` list alone would say only "0 operations + * declined" while the credential silently vanished. + */ + it('carries the dropped inputs of a refused atomic batch', async () => { + mocks.applyWorkflowOperations.mockRejectedValue( + new WorkflowOperationsNotAppliedError([], [DROPPED_INPUT] as never) + ) + + const response = await POST(request({ operations: [ADD], atomic: true }), routeContext) + + expect(response.status).toBe(409) + expect((await response.json()).error.details).toEqual({ + code: 'OPERATIONS_NOT_APPLIED', + skipped: [], + droppedInputs: [DROPPED_INPUT], + }) + }) + + it('conceals a cross-tenant write as not found', async () => { + mocks.applyWorkflowOperations.mockRejectedValue(new NoWorkspaceAccessError('workspace-2')) + + const response = await POST(request({ operations: [ADD] }), routeContext) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects an empty batch', async () => { + const response = await POST(request({ operations: [] }), routeContext) + + expect(response.status).toBe(400) + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) + + it('rejects an add operation with no block type or name', async () => { + const response = await POST( + request({ operations: [{ operation_type: 'add', block_id: 'block-2', params: {} }] }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) + + /** + * `fieldIssues` is the most actionable half of the report for a headless + * graph builder — a block missing a required field fails at run time — and + * the `kind` discriminator is what lets a client branch on an unresolved + * reference instead of string-matching `reason`. Both were dropped. + */ + it('publishes the full lint report, not just unresolved reference prose', async () => { + const FIELD_ISSUE = { + blockId: 'block-2', + blockName: 'Triage', + blockType: 'agent', + missingRequiredFields: ['systemPrompt'], + inactiveModeValues: [ + { + canonicalId: 'model', + activeMemberId: 'model', + inactiveMemberId: 'modelAdvanced', + kind: 'other', + }, + ], + } + const SINK = { blockId: 'block-2', blockName: 'Triage', blockType: 'agent' } + mocks.applyWorkflowOperations.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workflowName: 'Daily digest', + workspaceId: 'workspace-1', + graph: { blocks: {}, edges: [], loops: {}, parallels: {} }, + operationCount: 1, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + mintedBlockIds: { 'agent-1': 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77' }, + lint: { + ...LINT, + sinks: [SINK], + fieldIssues: [FIELD_ISSUE], + unresolvedReferences: [ + { + blockId: 'block-2', + blockName: 'Triage', + blockType: 'agent', + field: 'credential', + value: 'cred-9', + kind: 'credential', + reason: 'Not accessible', + }, + ], + }, + warnings: [], + needsRedeployment: true, + dryRun: false, + }) + + const response = await POST(request({ operations: [ADD] }), routeContext) + + expect(response.status).toBe(200) + const { lint } = (await response.json()).data + expect(lint.sinks).toEqual([SINK]) + expect(lint.fieldIssues).toEqual([FIELD_ISSUE]) + expect(lint.unresolvedReferences[0].kind).toBe('credential') + expect(lint.unresolvedReferences[0].value).toBe('cred-9') + }) + + /** + * `baseGraph` is Copilot's alone: it substitutes the authoritative graph with + * one the caller supplies, which the use case honours only for a `delegated` + * principal. The v2 body is `.strict()`, so it must never reach the use case + * from an API key at all. + */ + it('rejects baseGraph in a v2 body', async () => { + const response = await POST( + request({ operations: [ADD], baseGraph: { blocks: {}, edges: [] } }), + routeContext + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) + + it('rejects params on a delete operation', async () => { + const response = await POST( + request({ + operations: [{ operation_type: 'delete', block_id: 'block-2', params: { type: 'agent' } }], + }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.ts new file mode 100644 index 00000000000..a160f0ced08 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.ts @@ -0,0 +1,58 @@ +import { v2ApplyWorkflowOperationsContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { applyWorkflowOperations } from '@/lib/workflows/application/apply-workflow-operations' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' +import { presentWorkflowLint } from '@/app/api/v2/lib/workflow-lint' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Semantic edits against a workflow graph. + * + * Best-effort per operation, atomic per write: the engine applies what it can + * and reports the rest in `skipped`, and exactly one write of the fully-resolved + * graph happens at the end. `atomic: true` moves the decision in front of that + * write and answers `409` instead, so nothing is persisted. + */ +export const POST = defineV2JsonRoute({ + contract: v2ApplyWorkflowOperationsContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.applyOperations, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowGraphAuthorization, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + mapInput: ({ params, query, body }) => ({ + workflowId: params.workflowId, + dryRun: query.dryRun, + operations: body.operations, + atomic: body.atomic, + layout: body.layout, + blockEnabledChanges: body.setBlockEnabled?.map((change) => ({ + blockId: change.block_id, + enabled: change.enabled, + })), + }), + useCase: applyWorkflowOperations, + present: (result) => ({ + data: { + id: result.workflowId, + applied: result.applied, + skipped: result.skipped, + deferred: result.deferred, + inputValidationErrors: result.inputValidationErrors.map((error) => ({ + blockId: error.blockId, + blockType: error.blockType, + field: error.field, + error: error.error, + })), + mintedBlockIds: result.mintedBlockIds, + lint: presentWorkflowLint(result.lint), + warnings: result.warnings, + needsRedeployment: result.needsRedeployment, + dryRun: result.dryRun, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/restore/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/restore/route.test.ts new file mode 100644 index 00000000000..1fd6b4c0659 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/restore/route.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ restoreWorkflow: vi.fn() })) + +vi.mock('@/lib/workflows/application/restore-workflow', () => ({ + restoreWorkflow: { operation: { id: 'workflows.restore' }, execute: mocks.restoreWorkflow }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/workflows/[workflowId]/restore/route' + +const WORKFLOW_ID = 'workflow-1' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const routeContext = { params: Promise.resolve({ workflowId: WORKFLOW_ID }) } +const url = `http://localhost/api/v2/workflows/${WORKFLOW_ID}/restore` + +describe('/api/v2/workflows/[workflowId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.restoreWorkflow.mockResolvedValue({ + workflow: { + id: WORKFLOW_ID, + name: 'Daily digest', + description: null, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), + }, + workspaceId: 'workspace-1', + folderPath: '/', + }) + }) + + it('authenticates before running the use case', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await POST(new NextRequest(url, { method: 'POST' }), routeContext) + + expect(response.status).toBe(401) + expect(mocks.restoreWorkflow).not.toHaveBeenCalled() + }) + + it('returns the restored workflow summary', async () => { + const response = await POST(new NextRequest(url, { method: 'POST' }), routeContext) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: WORKFLOW_ID, + name: 'Daily digest', + description: null, + folderPath: '/', + workspaceId: 'workspace-1', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + }) + }) + + it('answers a workflow that is not archived with 409', async () => { + mocks.restoreWorkflow.mockRejectedValue( + new OrchestrationError('conflict', 'Workflow is not archived') + ) + + const response = await POST(new NextRequest(url, { method: 'POST' }), routeContext) + + expect(response.status).toBe(409) + expect((await response.json()).error).toEqual({ + code: 'CONFLICT', + message: 'Workflow is not archived', + }) + }) + + it('conceals a cross-tenant restore as not found', async () => { + mocks.restoreWorkflow.mockRejectedValue(new NoWorkspaceAccessError('workspace-2')) + + const response = await POST(new NextRequest(url, { method: 'POST' }), routeContext) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects an undeclared query param', async () => { + const response = await POST(new NextRequest(`${url}?force=1`, { method: 'POST' }), routeContext) + + expect(response.status).toBe(400) + expect(mocks.restoreWorkflow).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/restore/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/restore/route.ts new file mode 100644 index 00000000000..277b34bc9af --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/restore/route.ts @@ -0,0 +1,38 @@ +import { v2RestoreWorkflowContract } 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 { restoreWorkflow } from '@/lib/workflows/application/restore-workflow' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Un-archives a workflow, along with the schedules, webhooks, MCP tools, and + * chats that were archived with it. A workflow that is not archived is a `409`, + * not a silent success. + */ +export const POST = defineV2JsonRoute({ + contract: v2RestoreWorkflowContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.restore, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.workflowId }), + useCase: restoreWorkflow, + present: ({ workflow, workspaceId, folderPath }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderPath, + workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt?.toISOString() ?? null, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt?.toISOString() ?? null, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/rollback/route.test.ts similarity index 91% rename from apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/rollback/route.test.ts index e06621ad0cc..d62c9dac443 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/rollback/route.test.ts @@ -22,9 +22,9 @@ import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' import { workflowOperations } from '@/lib/workflows/application/operations' -import { POST } from '@/app/api/v2/workflows/[id]/rollback/route' +import { POST } from '@/app/api/v2/workflows/[workflowId]/rollback/route' -describe('/api/v2/workflows/[id]/rollback route definition', () => { +describe('/api/v2/workflows/[workflowId]/rollback route definition', () => { /** * Both the malformed-body 400 and the oversized-body 413 are v2 builder * defaults, so neither belongs on the route. The envelope they produce is @@ -39,7 +39,9 @@ describe('/api/v2/workflows/[id]/rollback route definition', () => { errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, parseOptions: { optionalJsonBody: true }, }) - expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( + expect( + Reflect.get(POST, 'mapInput')({ params: { workflowId: 'workflow-1' }, body: {} }) + ).toEqual( expect.objectContaining({ workflowId: 'workflow-1', version: undefined, diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/rollback/route.ts similarity index 97% rename from apps/sim/app/api/v2/workflows/[id]/rollback/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/rollback/route.ts index 3c40d92b4ba..ec251586552 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/rollback/route.ts @@ -19,7 +19,7 @@ export const POST = defineV2JsonRoute({ optionalJsonBody: true, }, mapInput: ({ params, body }) => ({ - workflowId: params.id, + workflowId: params.workflowId, version: body.version, transition: 'rollback' as const, requestId: generateRequestId(), diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/route.test.ts similarity index 94% rename from apps/sim/app/api/v2/workflows/[id]/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/route.test.ts index 72003b5574f..9befbcb5811 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/route.test.ts @@ -33,7 +33,7 @@ vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application' -import { DELETE, GET, PATCH } from '@/app/api/v2/workflows/[id]/route' +import { DELETE, GET, PATCH } from '@/app/api/v2/workflows/[workflowId]/route' const WORKSPACE_ID = 'workspace-1' const WORKFLOW_ID = 'workflow-1' @@ -62,9 +62,9 @@ const auth = { rateLimitSubscription: null, keyType: 'personal' as const, } -const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } +const routeContext = { params: Promise.resolve({ workflowId: WORKFLOW_ID }) } -describe('/api/v2/workflows/[id]', () => { +describe('/api/v2/workflows/[workflowId]', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) @@ -157,7 +157,9 @@ describe('/api/v2/workflows/[id]', () => { const response = await DELETE(request, routeContext) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: { id: WORKFLOW_ID, deleted: true } }) + expect(await response.json()).toEqual({ + data: { id: WORKFLOW_ID, deleted: true, archived: true }, + }) expect(mocks.deleteWorkflow).toHaveBeenCalledWith({ principal: auth.principal, input: { workflowId: WORKFLOW_ID }, diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/route.ts similarity index 90% rename from apps/sim/app/api/v2/workflows/[id]/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/route.ts index 9e1d6be489e..27232adc3c9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/route.ts @@ -30,7 +30,7 @@ export const GET = defineV2JsonRoute({ operation: workflowOperations.read, rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params }) => ({ workflowId: params.id }), + mapInput: ({ params }) => ({ workflowId: params.workflowId }), useCase: readWorkflow, present: ({ workflow, workspaceId, folderPath, inputs }) => ({ data: { @@ -57,7 +57,7 @@ export const PATCH = defineV2JsonRoute({ operation: workflowOperations.update, rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params, body }) => ({ workflowId: params.id, ...body }), + mapInput: ({ params, body }) => ({ workflowId: params.workflowId, ...body }), useCase: updateWorkflow, present: ({ workflow, workspaceId, folderPath, deployment }) => ({ data: { @@ -82,7 +82,9 @@ export const DELETE = defineV2JsonRoute({ operation: workflowOperations.delete, rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params }) => ({ workflowId: params.id }), + mapInput: ({ params }) => ({ workflowId: params.workflowId }), useCase: deleteWorkflow, - present: ({ workflowId }) => ({ data: { id: workflowId, deleted: true as const } }), + present: ({ workflowId }) => ({ + data: { id: workflowId, deleted: true as const, archived: true as const }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.test.ts similarity index 93% rename from apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.test.ts index 12f0b1fbea5..bd5122c98b0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/workflows/application/cancel-run', () => ({ cancelWorkflowRun: { operation: { id: 'workflows.runs.cancel' }, execute: mocks.cancel }, })) -import { POST } from '@/app/api/v2/workflows/[id]/runs/[runId]/cancel/route' +import { POST } from '@/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route' const WORKSPACE_ID = 'workspace-1' const WORKFLOW_ID = 'workflow-1' @@ -45,7 +45,7 @@ const auth = { keyType: 'personal' as const, } -const context = { params: Promise.resolve({ id: WORKFLOW_ID, runId: RUN_ID }) } +const context = { params: Promise.resolve({ workflowId: WORKFLOW_ID, runId: RUN_ID }) } function request() { return new NextRequest( @@ -67,7 +67,7 @@ function serviceResult(overrides: Record) { } } -describe('POST /api/v2/workflows/[id]/runs/[runId]/cancel', () => { +describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/cancel', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts similarity index 95% rename from apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts index c492fe46957..ba04e01740b 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts @@ -14,7 +14,7 @@ export const POST = defineV2JsonRoute({ operation: workflowOperations.cancelRun, rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, - mapInput: ({ params }) => ({ workflowId: params.id, runId: params.runId }), + mapInput: ({ params }) => ({ workflowId: params.workflowId, runId: params.runId }), useCase: cancelWorkflowRun, present: (result) => ({ data: { diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.test.ts new file mode 100644 index 00000000000..777241fb848 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.test.ts @@ -0,0 +1,215 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + download: vi.fn(), + authorizeDownload: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/download-workflow-run-file', () => ({ + downloadWorkflowRunFileStream: { + operation: { + id: 'workflows.download_run_file', + minimumRole: 'read', + workspaceApiKey: 'allow', + }, + execute: mocks.download, + authorize: mocks.authorizeDownload, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = '3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36' +const RUN_ID = 'run_8f14e45f-ceea-467f-a' +const FILE_ID = 'file_report' + +const context = { + params: Promise.resolve({ workflowId: WORKFLOW_ID, runId: RUN_ID, fileId: FILE_ID }), +} + +const workspaceKeyAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const personalKeyAuth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-2', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-2'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +function url(): string { + return `http://localhost:3000/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/files/${FILE_ID}` +} + +function getRequest(): NextRequest { + return new NextRequest(url()) +} + +function headRequest(): NextRequest { + return new NextRequest(url(), { method: 'HEAD' }) +} + +describe('GET /api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(workspaceKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.authorizeDownload.mockResolvedValue(undefined) + mocks.download.mockResolvedValue({ + file: { id: FILE_ID, name: 'report.pdf', key: 'execution/ws/wf/run/report.pdf', size: 3 }, + stream: new Blob(['pdf']).stream(), + contentType: 'application/pdf', + contentLength: 3, + }) + }) + + /** + * The regression test for the whole cluster: run output carries + * `/api/files/serve/...` URLs that reject `x-api-key` outright, so a + * workspace key succeeding here is the byte path that previously did not + * exist for an async run. + */ + it('serves run bytes to a workspace API key', async () => { + const response = await GET(getRequest(), context) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Content-Disposition')).toContain('report.pdf') + expect(response.headers.get('Content-Length')).toBe('3') + expect(await response.text()).toBe('pdf') + }) + + it('serves run bytes to a personal API key', async () => { + v2RouteMocks.authenticate.mockResolvedValueOnce(personalKeyAuth) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('pdf') + }) + + /** + * The caller addresses a file by id only. Nothing resembling a storage key + * reaches the use case, so the endpoint cannot be aimed at other bytes. + */ + it('passes only the path identifiers to the use case', async () => { + await GET(getRequest(), context) + + expect(mocks.download).toHaveBeenCalledWith({ + principal: workspaceKeyAuth.principal, + input: { workflowId: WORKFLOW_ID, runId: RUN_ID, fileId: FILE_ID }, + request: expect.anything(), + }) + }) + + it('sets private, no-store caching on the bytes', async () => { + const response = await GET(getRequest(), context) + + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('surfaces operation rate-limit headers', async () => { + const response = await GET(getRequest(), context) + + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + + /** Cross-tenant reads must 404, never 403 — a 403 confirms the run exists. */ + it('conceals a run in another workspace as 404', async () => { + mocks.download.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('reports a run that has not finished as a conflict', async () => { + mocks.download.mockRejectedValueOnce( + new OrchestrationError('conflict', 'Run has not finished yet') + ) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(409) + }) + + /** + * `headSafe: false`: a `HEAD` authorizes and answers bodiless without running + * the download, so it never records a `FILE_DOWNLOADED` audit event and never + * becomes an existence oracle for a file the `GET` would 404. + */ + it('answers an authorized HEAD bodiless without downloading', async () => { + const response = await GET(headRequest(), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(mocks.download).not.toHaveBeenCalled() + expect(mocks.authorizeDownload).toHaveBeenCalledOnce() + }) + + it('does not confirm via HEAD a run the caller cannot reach', async () => { + mocks.authorizeDownload.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(headRequest(), context) + + expect(response.status).toBe(404) + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('does not confirm via HEAD a file id that does not exist', async () => { + mocks.authorizeDownload.mockRejectedValueOnce( + new OrchestrationError('not_found', 'File not found') + ) + + const response = await GET(headRequest(), context) + + expect(response.status).toBe(404) + expect(mocks.download).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts new file mode 100644 index 00000000000..589bc8bf972 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts @@ -0,0 +1,51 @@ +import { v2DownloadRunFileContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { downloadWorkflowRunFileStream } from '@/lib/workflows/application/download-workflow-run-file' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { encodeFilenameForHeader } from '@/app/api/files/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId] — download a file a + * run produced (binary). + * + * This is the byte path out of an async run for an API-key caller: the + * `UserFile` URLs carried in a run's output point at `/api/files/serve/...`, + * which rejects `x-api-key` outright. + * + * The file is addressed by the id reported on the run resource and resolved + * against the run's own recorded output, from which the storage key is read. + * The request never supplies a storage key, so the endpoint cannot be aimed at + * bytes the run did not produce. + * + * Execution files are not retained forever; a `404` after a run's objects have + * been collected is expected rather than a fault. Unknown run, unknown file, + * cross-tenant run, and expired object all render the same `File not found` + * so the response cannot be used to probe which ids exist. + * + * `headSafe: false` because downloading records a `FILE_DOWNLOADED` audit event + * and pulls the bytes out of object storage. + */ +export const GET = defineV2BinaryRoute({ + contract: v2DownloadRunFileContract, + auth: v2ApiKeyAuth, + headSafe: false, + operation: workflowOperations.downloadRunFile, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, + mapInput: ({ params }) => ({ + workflowId: params.workflowId, + runId: params.runId, + fileId: params.fileId, + }), + useCase: downloadWorkflowRunFileStream, + present: ({ file, stream, contentType, contentLength }) => ({ + body: stream, + contentType, + contentDisposition: `attachment; ${encodeFilenameForHeader(file.name)}`, + contentLength, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts similarity index 97% rename from apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts index 87d14c90168..01e8844b57f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts @@ -67,7 +67,7 @@ import { v2ResumeWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { PersonalApiKeysDisabledError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { workflowOperations } from '@/lib/workflows/application/operations' -import { POST } from '@/app/api/v2/workflows/[id]/runs/[runId]/resume/route' +import { POST } from '@/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route' const WORKFLOW_ID = 'workflow-1' const RUN_ID = 'run-1' @@ -83,11 +83,11 @@ function makeRequest(body: string) { body, } ), - context: { params: Promise.resolve({ id: WORKFLOW_ID, runId: RUN_ID }) }, + context: { params: Promise.resolve({ workflowId: WORKFLOW_ID, runId: RUN_ID }) }, } } -describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { +describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/resume', () => { beforeEach(() => { vi.clearAllMocks() mocks.admit.mockResolvedValue({ success: true, auth: { principal } }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.ts similarity index 82% rename from apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.ts index 1b5b87070e9..e7d5c4ff63f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' +import type { ContractParamsInput } from '@/lib/api/contracts' import type { V2ErrorCode } from '@/lib/api/contracts/v2/error-codes' import { V2_WORKFLOW_RUN_ID_HEADER, @@ -12,6 +13,7 @@ import { V2_PARSE_DEFAULTS, V2RouteInfrastructureError, v2ApiKeyAuth, + v2InvalidBodyResponse, v2RateLimits, } from '@/lib/api/server/routes' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -43,8 +45,18 @@ const ERROR_CODE_BY_STATUS: Record = { const TERMINAL_RESUME_STATUSES = new Set(['completed', 'failed', 'paused', 'cancelled']) +/** + * Path parameters typed from the contract rather than restated inline, so a + * renamed dynamic segment is a compile error here. This route keeps raw + * `withRouteHandler` for its bespoke resume error projection; the values it + * uses come from `parseRequest`, never from this raw context. + */ +type V2ResumeWorkflowRouteContext = { + params: Promise> +} + export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { + async (request: NextRequest, context: V2ResumeWorkflowRouteContext) => { const admission = await admitV2Request( request, workflowOperations.resumeRun, @@ -55,10 +67,14 @@ export const POST = withRouteHandler( const parsed = await parseRequest(v2ResumeWorkflowContract, request, context, { ...V2_PARSE_DEFAULTS, + // The defaults' shared entry takes no arguments, so it can only answer 400. + // This route publishes 415, and only a caller still holding the request can + // install the media-type-aware form — see V2_PARSE_DEFAULTS' own TSDoc. + invalidJsonResponse: () => v2InvalidBodyResponse(request), maxBodyBytes: 10 * 1024 * 1024, }) if (!parsed.success) return parsed.response - const { id: workflowId, runId } = parsed.data.params + const { workflowId, runId } = parsed.data.params const { contextId, input } = parsed.data.body try { diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts similarity index 68% rename from apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts index e67b9ddf739..37083a8a107 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ cancel: vi.fn(), capture: vi.fn(), readRun: vi.fn(), + authorizeReadRun: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -29,6 +30,7 @@ vi.mock('@/lib/workflows/application/read-workflow-run', () => ({ readWorkflowRun: { operation: { id: 'workflows.runs.read' }, execute: mocks.readRun, + authorize: mocks.authorizeReadRun, }, })) @@ -43,8 +45,9 @@ import { InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, } from '@/lib/core/application' -import { POST as cancelPost } from '@/app/api/v2/workflows/[id]/runs/[runId]/cancel/route' -import { GET } from '@/app/api/v2/workflows/[id]/runs/[runId]/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST as cancelPost } from '@/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route' +import { GET } from '@/app/api/v2/workflows/[workflowId]/runs/[runId]/route' const principal = { kind: 'workspace_api_key' as const, @@ -66,7 +69,7 @@ function callStatus(query = '') { {}, `http://localhost:3000/api/v2/workflows/workflow-1/runs/run-1${query}` ) - return GET(req, { params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }) }) + return GET(req, { params: Promise.resolve({ workflowId: 'workflow-1', runId: 'run-1' }) }) } const baseStatus = { @@ -83,6 +86,7 @@ const baseStatus = { error: 'Send Email: Invalid credentials', finalOutput: null, blockOutputs: null, + files: null, } /** @@ -116,6 +120,7 @@ describe('v2 run detail and cancel adapters', () => { v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.readRun.mockResolvedValue(baseStatus) + mocks.authorizeReadRun.mockResolvedValue(undefined) mocks.cancel.mockResolvedValue(successfulCancellation) }) @@ -141,11 +146,108 @@ describe('v2 run detail and cancel adapters', () => { runId: 'run-1', includeOutput: false, selectedOutputs: [], + includeFileBase64: false, + base64MaxBytes: undefined, }, request: expect.anything(), }) }) + it('emits files as null when output was not requested', async () => { + expect((await (await callStatus()).json()).data.files).toBeNull() + }) + + /** + * The byte path out of an async run: each produced file arrives with a + * `downloadPath` even when its bytes are not inlined. + */ + it('emits run file descriptors with a download path', async () => { + mocks.readRun.mockResolvedValueOnce({ + ...baseStatus, + status: 'completed', + error: null, + files: [ + { + id: 'file_1', + name: 'report.pdf', + size: 10, + type: 'application/pdf', + downloadPath: '/api/v2/workflows/workflow-1/runs/run-1/files/file_1', + base64: null, + }, + ], + }) + + const body = await (await callStatus('?includeOutput=true')).json() + + expect(body.data.files).toEqual([ + { + id: 'file_1', + name: 'report.pdf', + size: 10, + type: 'application/pdf', + downloadPath: '/api/v2/workflows/workflow-1/runs/run-1/files/file_1', + base64: null, + }, + ]) + }) + + it('forwards includeFileBase64 and its ceiling to the use case', async () => { + await callStatus('?includeOutput=true&includeFileBase64=true&base64MaxBytes=4096') + + expect(mocks.readRun).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ includeFileBase64: true, base64MaxBytes: 4096 }), + }) + ) + }) + + it('rejects a base64MaxBytes above the inline ceiling', async () => { + const response = await callStatus( + `?includeOutput=true&includeFileBase64=true&base64MaxBytes=${64 * 1024 * 1024}` + ) + + expect(response.status).toBe(400) + expect(mocks.readRun).not.toHaveBeenCalled() + }) + + /** The 413 must name the download path so the caller is not left stuck. */ + it('answers 413 naming the download path when a file exceeds the inline ceiling', async () => { + mocks.readRun.mockRejectedValueOnce( + new OrchestrationError( + 'payload_too_large', + 'File "report.pdf" (23.1 MB) exceeds the 16 MB inline limit; download it with GET /api/v2/workflows/workflow-1/runs/run-1/files/file_1' + ) + ) + + const response = await callStatus('?includeOutput=true&includeFileBase64=true') + + expect(response.status).toBe(413) + expect((await response.json()).error.message).toContain( + '/api/v2/workflows/workflow-1/runs/run-1/files/file_1' + ) + }) + + /** + * `headSafe: false` — inlining reads object storage, so HEAD answers bodiless + * without running the read. + */ + it('answers HEAD bodiless without reading the run', async () => { + const req = createMockRequest( + 'HEAD', + undefined, + {}, + 'http://localhost:3000/api/v2/workflows/workflow-1/runs/run-1' + ) + const response = await GET(req, { + params: Promise.resolve({ workflowId: 'workflow-1', runId: 'run-1' }), + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(mocks.readRun).not.toHaveBeenCalled() + }) + it('returns the queued run resource before a durable log exists', async () => { mocks.readRun.mockResolvedValueOnce({ ...baseStatus, @@ -227,7 +329,7 @@ describe('v2 run detail and cancel adapters', () => { it('keeps cancel on its semantic application operation', async () => { const response = await cancelPost(createMockRequest('POST', undefined, {}), { - params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + params: Promise.resolve({ workflowId: 'workflow-1', runId: 'run-1' }), }) expect(response.status).toBe(200) @@ -255,7 +357,7 @@ describe('v2 run detail and cancel adapters', () => { .mockResolvedValueOnce(V2_OPERATION_RATE_LIMIT_ALLOWED) const response = await cancelPost(createMockRequest('POST', undefined, {}), { - params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + params: Promise.resolve({ workflowId: 'workflow-1', runId: 'run-1' }), }) expect(response.status).toBe(429) @@ -267,7 +369,7 @@ describe('v2 run detail and cancel adapters', () => { mocks.cancel.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) const response = await cancelPost(createMockRequest('POST', undefined, {}), { - params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + params: Promise.resolve({ workflowId: 'workflow-1', runId: 'run-1' }), }) expect(response.status).toBe(403) @@ -288,7 +390,7 @@ describe('v2 run detail and cancel adapters', () => { }) const response = await cancelPost(createMockRequest('POST', undefined, {}), { - params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + params: Promise.resolve({ workflowId: 'workflow-1', runId: 'run-1' }), }) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts similarity index 79% rename from apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts index f511c4391a8..dab2588457e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts @@ -11,22 +11,29 @@ import { classifyExecutionError } from '@/executor/utils/errors' export const dynamic = 'force-dynamic' /** - * GET /api/v2/workflows/[id]/runs/[runId] — the single status URL + * GET /api/v2/workflows/[workflowId]/runs/[runId] — the single status URL * for both sync and async runs. When no log row exists yet, the async job * queue is consulted (deterministic job id) so a freshly-queued run reports * `queued` instead of 404. + * + * `headSafe: false` because `includeFileBase64` makes this read pull bytes out + * of object storage. A bodiless `HEAD` loses nothing here — the whole point of + * the request is the body. */ export const GET = defineV2JsonRoute({ + headSafe: false, contract: v2GetWorkflowRunContract, auth: v2ApiKeyAuth, operation: workflowOperations.readRun, rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, mapInput: ({ params, query }) => ({ - workflowId: params.id, + workflowId: params.workflowId, runId: params.runId, includeOutput: query.includeOutput, selectedOutputs: query.selectedOutputs, + includeFileBase64: query.includeFileBase64, + base64MaxBytes: query.base64MaxBytes, }), useCase: readWorkflowRun, present: (status) => ({ @@ -43,6 +50,7 @@ export const GET = defineV2JsonRoute({ error: status.error ? classifyExecutionError(new Error(status.error)) : null, output: status.finalOutput, blockOutputs: status.blockOutputs, + files: status.files, }, }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/route.test.ts similarity index 97% rename from apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/runs/route.test.ts index 0d01548f713..20e370fc12a 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/route.test.ts @@ -30,7 +30,7 @@ vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({ import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application' -import { GET } from '@/app/api/v2/workflows/[id]/runs/route' +import { GET } from '@/app/api/v2/workflows/[workflowId]/runs/route' const principal = { kind: 'workspace_api_key' as const, @@ -44,7 +44,7 @@ const auth = { rateLimitSubscription: null, keyType: 'workspace' as const, } -const routeContext = () => ({ params: Promise.resolve({ id: 'workflow-1' }) }) +const routeContext = () => ({ params: Promise.resolve({ workflowId: 'workflow-1' }) }) const callGet = (query = '') => GET( new NextRequest(`http://localhost:3000/api/v2/workflows/workflow-1/runs${query}`), @@ -76,7 +76,7 @@ const EXECUTIONS = [ }, ] -describe('GET /api/v2/workflows/[id]/runs', () => { +describe('GET /api/v2/workflows/[workflowId]/runs', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/route.ts similarity index 95% rename from apps/sim/app/api/v2/workflows/[id]/runs/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/runs/route.ts index e504143b335..e1816aff77a 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/route.ts @@ -24,7 +24,7 @@ function runCursorFilters( workflowId: string, query: { status?: string; trigger?: string; startDate?: string; endDate?: string } ) { - return cursorScopeKey(cursorRoute(v2ListWorkflowRunsContract, { id: workflowId }), { + return cursorScopeKey(cursorRoute(v2ListWorkflowRunsContract, { workflowId }), { status: query.status, trigger: query.trigger, startDate: instantScopePart(query.startDate), @@ -45,7 +45,7 @@ export const GET = defineV2JsonRoute({ cursor, 'startedAt', order, - runCursorFilters(params.id, query) + runCursorFilters(params.workflowId, query) ) const [cursorStartedAt, cursorRowId] = cursorKeys ?? [] const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null @@ -60,7 +60,7 @@ export const GET = defineV2JsonRoute({ } return { - workflowId: params.id, + workflowId: params.workflowId, status, trigger, startDate: startDate ? new Date(startDate) : undefined, @@ -91,7 +91,7 @@ export const GET = defineV2JsonRoute({ : null, 'startedAt', result.order, - runCursorFilters(params.id, query) + runCursorFilters(params.workflowId, query) ) return { data, nextCursor } }, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts new file mode 100644 index 00000000000..a8d47ee9ee6 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts @@ -0,0 +1,277 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + readWorkflowGraph: vi.fn(), + replaceWorkflowState: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/read-workflow-graph', () => ({ + readWorkflowGraph: { operation: { id: 'workflows.read' }, execute: mocks.readWorkflowGraph }, +})) +vi.mock('@/lib/workflows/application/replace-workflow-state', () => ({ + replaceWorkflowState: { + operation: { id: 'workflows.state.replace' }, + execute: mocks.replaceWorkflowState, + }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { GET, PUT } from '@/app/api/v2/workflows/[workflowId]/state/route' + +const WORKFLOW_ID = 'workflow-1' +const BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { 'sub-1': { id: 'sub-1', type: 'short-input', value: 'hello' } }, + outputs: {}, + enabled: true, +} +const GRAPH = { + blocks: { 'block-1': BLOCK }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, +} + +const personalAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const workspaceAuth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', keyId: 'ws-key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:ws-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const routeContext = { params: Promise.resolve({ workflowId: WORKFLOW_ID }) } + +function putRequest(body: unknown) { + return new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const EMPTY_LINT = { + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + fieldIssues: [], + unresolvedReferences: [], + notes: [], +} + +describe('/api/v2/workflows/[workflowId]/state', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(personalAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.readWorkflowGraph.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workspaceId: 'workspace-1', + ...GRAPH, + }) + mocks.replaceWorkflowState.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workflowName: 'Daily digest', + workspaceId: 'workspace-1', + blocksCount: 1, + edgesCount: 0, + warnings: ['Dropped edge "edge-9": target block does not exist'], + needsRedeployment: true, + lint: EMPTY_LINT, + dryRun: false, + }) + }) + + it('returns the graph in the v2 envelope with a private, no-store cache directive', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`), + routeContext + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(await response.json()).toEqual({ data: GRAPH }) + }) + + /** + * Next aliases a missing `HEAD` export onto `GET`, so the handler runs with + * `request.method === 'HEAD'`. The route is declared head-safe, which means + * the probe must run the read and produce the same representation the `GET` + * would — not the bodiless `v2HeadNoEffect` short-circuit a `headSafe: false` + * route answers with, which would make the endpoint useless for polling. + */ + it('answers a HEAD through the GET with the same representation', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`, { + method: 'HEAD', + }), + routeContext + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(await response.json()).toEqual({ data: GRAPH }) + expect(mocks.readWorkflowGraph).toHaveBeenCalledOnce() + }) + + /** + * The blockless-draft round trip: `PUT { blocks: {}, edges: [] }` is the + * contract's own published example, and the graph schema promises the read + * that follows it closes. + */ + it('reads a blockless draft back as an empty graph', async () => { + const empty = { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} } + mocks.readWorkflowGraph.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workspaceId: 'workspace-1', + ...empty, + }) + + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`), + routeContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: empty }) + }) + + it('accepts the published empty-graph example', async () => { + const response = await PUT(putRequest({ blocks: {}, edges: [] }), routeContext) + + expect(response.status).toBe(200) + expect(mocks.replaceWorkflowState).toHaveBeenCalledOnce() + }) + + it('rejects an undeclared query param', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state?bogus=1`), + routeContext + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.readWorkflowGraph).not.toHaveBeenCalled() + }) + + it('conceals a cross-tenant read as not found, never forbidden', async () => { + mocks.readWorkflowGraph.mockRejectedValue(new NoWorkspaceAccessError('workspace-2')) + + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`), + routeContext + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) + }) + + it('authenticates before parsing the write body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await PUT(putRequest({ nonsense: true }), routeContext) + + expect(response.status).toBe(401) + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() + expect(mocks.replaceWorkflowState).not.toHaveBeenCalled() + }) + + it('replaces the graph and returns the preparation warnings', async () => { + const response = await PUT( + putRequest({ blocks: { 'block-1': BLOCK }, edges: [] }), + routeContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: WORKFLOW_ID, + warnings: ['Dropped edge "edge-9": target block does not exist'], + needsRedeployment: true, + lint: EMPTY_LINT, + dryRun: false, + }, + }) + expect(mocks.replaceWorkflowState).toHaveBeenCalledWith({ + principal: personalAuth.principal, + input: { + workflowId: WORKFLOW_ID, + blocks: { 'block-1': BLOCK }, + edges: [], + variables: undefined, + }, + request: expect.anything(), + }) + }) + + it('accepts a workspace API key, which the operation policy allows', async () => { + v2RouteMocks.authenticate.mockResolvedValue(workspaceAuth) + + const response = await PUT( + putRequest({ blocks: { 'block-1': BLOCK }, edges: [] }), + routeContext + ) + + expect(response.status).toBe(200) + expect(mocks.replaceWorkflowState).toHaveBeenCalledWith( + expect.objectContaining({ principal: workspaceAuth.principal }) + ) + }) + + it('accepts a graph read straight back from the GET', async () => { + const response = await PUT(putRequest(GRAPH), routeContext) + + expect(response.status).toBe(200) + }) + + it('rejects an unknown top-level body member', async () => { + const response = await PUT(putRequest({ blocks: {}, edges: [], lastSaved: 1 }), routeContext) + + expect(response.status).toBe(400) + expect(mocks.replaceWorkflowState).not.toHaveBeenCalled() + }) + + it('rejects a block missing the fields the tables require', async () => { + const response = await PUT( + putRequest({ blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, edges: [] }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.replaceWorkflowState).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/state/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/state/route.ts new file mode 100644 index 00000000000..dd78278a92c --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/state/route.ts @@ -0,0 +1,71 @@ +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { + v2GetWorkflowStateContract, + v2ReplaceWorkflowStateContract, +} 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 { readWorkflowGraph } from '@/lib/workflows/application/read-workflow-graph' +import { replaceWorkflowState } from '@/lib/workflows/application/replace-workflow-state' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' +import { presentWorkflowLint } from '@/app/api/v2/lib/workflow-lint' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Head-safe for the same reason `GET /api/v2/workflows/{workflowId}` is: the only write + * this read can trigger is migrate-on-read inside + * `loadWorkflowFromNormalizedTables`, which is conditional, idempotent, and + * convergent — a `HEAD` only brings forward a write the next ordinary read + * performs. + * + * It records no audit event, and that is what makes it pollable. `/export` is + * the audited, portable, sanitized read; this one is the unsanitized draft a + * caller reads before writing it back. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowStateContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.workflowId }), + useCase: readWorkflowGraph, + present: ({ blocks, edges, loops, parallels, variables }) => ({ + data: { blocks, edges, loops, parallels, variables }, + }), +}) + +/** + * PUT /api/v2/workflows/[workflowId]/state — replace the draft graph wholesale. + * + * Answers with the same `lint` report as `POST /operations`. The two are the + * only ways to write a graph, and an agent that authors one from scratch needs + * the findings at least as much as one that edits an existing graph — reporting + * them on only one of the two was the asymmetry this closes. + * + * `?dryRun=true` validates and lints without persisting, so a caller can see + * exactly what a write would produce before committing to it. + */ +export const PUT = defineV2JsonRoute({ + contract: v2ReplaceWorkflowStateContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.replaceState, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + mapInput: ({ params, query, body }) => ({ + workflowId: params.workflowId, + dryRun: query.dryRun, + // double-cast-allowed: the wire schema leaves sub-block `type` an open string, which the domain type narrows to the block registry's union; the persistence layer re-validates every sub-block. + blocks: body.blocks as unknown as Record, + edges: body.edges as WorkflowState['edges'], + variables: body.variables, + }), + useCase: replaceWorkflowState, + present: ({ workflowId, warnings, needsRedeployment, lint, dryRun }) => ({ + data: { id: workflowId, warnings, needsRedeployment, lint: presentWorkflowLint(lint), dryRun }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/variables/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/variables/route.test.ts new file mode 100644 index 00000000000..0fa7936e7d1 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/variables/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ applyWorkflowVariableOperations: vi.fn() })) + +vi.mock('@/lib/workflows/application/update-workflow-content', () => ({ + applyWorkflowVariableOperations: { + operation: { id: 'workflows.variables.apply_operations' }, + execute: mocks.applyWorkflowVariableOperations, + }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { PATCH } from '@/app/api/v2/workflows/[workflowId]/variables/route' + +const WORKFLOW_ID = 'workflow-1' +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', keyId: 'ws-key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:ws-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const routeContext = { params: Promise.resolve({ workflowId: WORKFLOW_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/variables`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/v2/workflows/[workflowId]/variables', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.applyWorkflowVariableOperations.mockResolvedValue({ updated: 3, changed: true }) + }) + + it('authenticates before parsing the body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await PATCH(request({ nonsense: true }), routeContext) + + expect(response.status).toBe(401) + expect(mocks.applyWorkflowVariableOperations).not.toHaveBeenCalled() + }) + + it('applies a batch under a workspace API key, which the widened policy allows', async () => { + const response = await PATCH( + request({ operations: [{ operation: 'add', name: 'region', type: 'string', value: 'eu' }] }), + routeContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: WORKFLOW_ID, variableCount: 3, changed: true }, + }) + expect(mocks.applyWorkflowVariableOperations).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workflowId: WORKFLOW_ID, + operations: [{ operation: 'add', name: 'region', type: 'string', value: 'eu' }], + }, + request: expect.anything(), + }) + }) + + it('does not forward a value or type on a delete operation', async () => { + await PATCH(request({ operations: [{ operation: 'delete', name: 'region' }] }), routeContext) + + expect(mocks.applyWorkflowVariableOperations).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + operations: [{ operation: 'delete', name: 'region' }], + }), + }) + ) + }) + + it('reports an authoritative no-op without pretending anything changed', async () => { + mocks.applyWorkflowVariableOperations.mockResolvedValue({ updated: 3, changed: false }) + + const response = await PATCH( + request({ operations: [{ operation: 'delete', name: 'missing' }] }), + routeContext + ) + + expect(response.status).toBe(200) + expect((await response.json()).data.changed).toBe(false) + }) + + it('rejects a value on a delete operation', async () => { + const response = await PATCH( + request({ operations: [{ operation: 'delete', name: 'region', value: 'eu' }] }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.applyWorkflowVariableOperations).not.toHaveBeenCalled() + }) + + it('rejects an empty batch', async () => { + const response = await PATCH(request({ operations: [] }), routeContext) + + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/variables/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/variables/route.ts new file mode 100644 index 00000000000..50174068fde --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/variables/route.ts @@ -0,0 +1,33 @@ +import { v2ApplyWorkflowVariablesContract } 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 { applyWorkflowVariableOperations } from '@/lib/workflows/application/update-workflow-content' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Merge-patch shaped: only the named variables change, and a `delete` operation + * is how one is removed. A batch that changes nothing answers `200` with + * `changed: false` and writes neither a row nor an audit event. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2ApplyWorkflowVariablesContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.applyVariableOperations, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, body }) => ({ + workflowId: params.workflowId, + operations: body.operations.map((operation) => ({ + name: operation.name, + operation: operation.operation, + ...(operation.operation === 'delete' ? {} : { value: operation.value, type: operation.type }), + })), + }), + useCase: applyWorkflowVariableOperations, + present: (result, { params }) => ({ + data: { id: params.workflowId, variableCount: result.updated, changed: result.changed }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route.test.ts new file mode 100644 index 00000000000..3aae895c08c --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route.test.ts @@ -0,0 +1,213 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + assertMutable: vi.fn(), + activate: vi.fn(), + findPrevious: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + getWorkflowDeploymentSummary: vi.fn(), + performActivateVersion: mocks.activate, + performFullDeploy: vi.fn(), + performFullUndeploy: vi.fn(), + performRevertToVersion: vi.fn(), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + findPreviousDeploymentVersion: mocks.findPrevious, + updateDeploymentVersionMetadata: vi.fn(), +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { POST } from '@/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route' + +const personalKeyAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const workspaceKeyAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:workspace-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const workflowContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Release workflow', workspaceId: 'workspace-1' }, +} + +async function post(version = '3', body?: unknown) { + const request = new NextRequest( + `http://localhost/api/v2/workflows/workflow-1/versions/${version}/activate`, + body === undefined + ? { method: 'POST' } + : { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + } + ) + return POST(request, { params: Promise.resolve({ workflowId: 'workflow-1', version }) }) +} + +describe('POST /api/v2/workflows/[workflowId]/versions/[version]/activate', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(personalKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.assertMutable.mockResolvedValue(undefined) + mocks.activate.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-08-01T00:00:00.000Z'), + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + }) + + it('promotes the version named by the path with an empty body', async () => { + const response = await post() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: 'workflow-1', + isDeployed: false, + deployedAt: '2026-08-01T00:00:00.000Z', + version: 3, + warnings: [], + activeDeployment: null, + latestDeploymentAttempt: null, + }, + }) + expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.activate) + expect(mocks.activate).toHaveBeenCalledWith(expect.objectContaining({ version: 3 })) + }) + + /** + * Activation is unconditional on the current state, unlike rollback, which + * refuses when nothing is deployed. Nothing may consult the previous version. + */ + it('never falls back to the previous version', async () => { + await post() + + expect(mocks.findPrevious).not.toHaveBeenCalled() + }) + + it('rejects the rollback body rather than activating a different version', async () => { + const response = await post('3', { version: 2 }) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.activate).not.toHaveBeenCalled() + }) + + it('rejects a fractional version in the path before any canonical load', async () => { + const response = await post('1.5') + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + }) + + it('rejects a workspace API key before canonical loading', async () => { + v2RouteMocks.authenticate.mockResolvedValue(workspaceKeyAuth) + + const response = await post() + + expect(response.status).toBe(403) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.activate).not.toHaveBeenCalled() + }) + + it('refuses a caller below workspace admin with 403', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + const response = await post() + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('INSUFFICIENT_WORKSPACE_ROLE') + expect(mocks.activate).not.toHaveBeenCalled() + }) + + it('conceals a workflow the caller cannot reach as 404', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await post() + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(mocks.activate).not.toHaveBeenCalled() + }) + + it('maps a competing lifecycle attempt to 409', async () => { + mocks.activate.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A deployment is already in progress', + }) + + const response = await post() + + expect(response.status).toBe(409) + const body = await response.json() + expect(body.error.code).toBe('CONFLICT') + expect(body.error.message).toBe('A deployment is already in progress') + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await post() + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route.ts new file mode 100644 index 00000000000..e767f2bc6d6 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route.ts @@ -0,0 +1,49 @@ +import { v2ActivateWorkflowVersionContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { generateRequestId } from '@/lib/core/utils/request' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +/** + * POST /api/v2/workflows/[workflowId]/versions/[version]/activate — promote a version to live. + * + * The same application operation as rollback, under a different transition. + * They stay separate paths because the two mean opposite things to a caller — + * rollback selects the version preceding the active one and refuses when + * nothing is deployed, while activation names its target and works from any + * state — and a single endpoint whose direction depended on whether `version` + * was supplied would make the destructive reading the default one. + */ +export const POST = defineV2JsonRoute({ + contract: v2ActivateWorkflowVersionContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.activateVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { + optionalJsonBody: true, + }, + mapInput: ({ params }) => ({ + workflowId: params.workflowId, + version: params.version, + transition: 'activate' as const, + requestId: generateRequestId(), + }), + useCase: activateWorkflowVersion, + present: (result) => ({ + data: { + id: result.workflowId, + isDeployed: Boolean(result.activeDeployment), + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route.test.ts new file mode 100644 index 00000000000..9522d445e08 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + revert: vi.fn(), + audit: vi.fn(), + notifyReverted: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + getWorkflowDeploymentSummary: vi.fn(), + performActivateVersion: vi.fn(), + performFullDeploy: vi.fn(), + performFullUndeploy: vi.fn(), + performRevertToVersion: mocks.revert, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + findPreviousDeploymentVersion: vi.fn(), + updateDeploymentVersionMetadata: vi.fn(), +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowReverted: mocks.notifyReverted })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { POST } from '@/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route' + +const personalKeyAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const workspaceKeyAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:workspace-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const workflowContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Release workflow', workspaceId: 'workspace-1' }, +} + +async function post(version = '3') { + const request = new NextRequest( + `http://localhost/api/v2/workflows/workflow-1/versions/${version}/revert`, + { method: 'POST' } + ) + return POST(request, { params: Promise.resolve({ workflowId: 'workflow-1', version }) }) +} + +describe('POST /api/v2/workflows/[workflowId]/versions/[version]/revert', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(personalKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.revert.mockResolvedValue({ success: true, lastSaved: 1765535400000 }) + }) + + it('overwrites the draft with the version named by the path', async () => { + const response = await post() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: 'workflow-1', version: 3, lastSaved: 1765535400000 }, + }) + expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.revert) + expect(mocks.revert).toHaveBeenCalledWith(expect.objectContaining({ version: 3 })) + }) + + it('accepts the literal active as a version', async () => { + const response = await post('active') + + expect(response.status).toBe(200) + expect((await response.json()).data.version).toBe('active') + expect(mocks.revert).toHaveBeenCalledWith(expect.objectContaining({ version: 'active' })) + }) + + it.each(['0', '-1', '1.5', 'latest'])( + 'rejects %s as a version before any canonical load', + async (version) => { + const response = await post(version) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + } + ) + + it('records one semantic audit entry and notifies collaborators', async () => { + await post() + + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.deployment_reverted', + resourceId: 'workflow-1', + }) + ) + expect(mocks.notifyReverted).toHaveBeenCalledWith('workflow-1', 1765535400000) + }) + + it('rejects a workspace API key before canonical loading', async () => { + v2RouteMocks.authenticate.mockResolvedValue(workspaceKeyAuth) + + const response = await post() + + expect(response.status).toBe(403) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.revert).not.toHaveBeenCalled() + }) + + it('refuses a caller below workspace admin with 403', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + const response = await post() + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('INSUFFICIENT_WORKSPACE_ROLE') + expect(mocks.revert).not.toHaveBeenCalled() + }) + + it('conceals a workflow the caller cannot reach as 404', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await post() + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(mocks.revert).not.toHaveBeenCalled() + }) + + it('writes no audit entry and sends no notification when the revert fails', async () => { + mocks.revert.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'Deployment version not found', + }) + + const response = await post() + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Deployment version not found') + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.notifyReverted).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await post() + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route.ts new file mode 100644 index 00000000000..8b50f40df2a --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route.ts @@ -0,0 +1,37 @@ +import { v2RevertWorkflowVersionContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { revertWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +/** + * POST /api/v2/workflows/[workflowId]/versions/[version]/revert — overwrite the draft. + * + * This replaces the editable draft with the graph pinned by the named version + * and discards every unsaved edit; it is the most destructive operation in the + * deployment family. It does not change what is live — a caller looking to move + * production wants `activate` or `rollback`, both of which leave the draft + * alone. + */ +export const POST = defineV2JsonRoute({ + contract: v2RevertWorkflowVersionContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.revertVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { + optionalJsonBody: true, + }, + mapInput: ({ params }) => ({ workflowId: params.workflowId, version: params.version }), + useCase: revertWorkflowVersion, + present: (result) => ({ + data: { + id: result.workflowId, + version: result.version, + lastSaved: result.lastSaved, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/route.test.ts similarity index 54% rename from apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/route.test.ts index fd19cec176f..48cc18c48ff 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/route.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), resolveWorkflowContext: vi.fn(), readVersion: vi.fn(), + updateVersionMetadata: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -33,6 +34,15 @@ vi.mock('@/lib/workflows/application/context', () => ({ })) vi.mock('@/lib/workflows/persistence/utils', () => ({ getWorkflowDeploymentVersion: mocks.readVersion, + findPreviousDeploymentVersion: vi.fn(), + updateDeploymentVersionMetadata: mocks.updateVersionMetadata, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + getWorkflowDeploymentSummary: vi.fn(), + performActivateVersion: vi.fn(), + performFullDeploy: vi.fn(), + performFullUndeploy: vi.fn(), + performRevertToVersion: vi.fn(), })) vi.mock('@/lib/workflows/search-replace/indexer', () => ({ getToolInputParamConfigs: ({ @@ -69,7 +79,7 @@ vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) -import { GET } from '@/app/api/v2/workflows/[id]/versions/[version]/route' +import { GET, PATCH } from '@/app/api/v2/workflows/[workflowId]/versions/[version]/route' const auth = { principal: { @@ -129,7 +139,7 @@ function versionState() { } } -describe('GET /api/v2/workflows/[id]/versions/[version]', () => { +describe('GET /api/v2/workflows/[workflowId]/versions/[version]', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) @@ -138,6 +148,7 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => { v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.resolvePermission.mockResolvedValue('admin') mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.updateVersionMetadata.mockResolvedValue({ name: 'Production', description: null }) mocks.readVersion.mockResolvedValue({ id: 'version-2', version: 2, @@ -151,7 +162,7 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => { async function get() { const request = new NextRequest('http://localhost/api/v2/workflows/workflow-1/versions/2') - return GET(request, { params: Promise.resolve({ id: 'workflow-1', version: '2' }) }) + return GET(request, { params: Promise.resolve({ workflowId: 'workflow-1', version: '2' }) }) } it('reads the requested version only after canonical workflow authorization', async () => { @@ -192,3 +203,128 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => { expect((await response.json()).error.code).toBe('UNAUTHORIZED') }) }) + +describe('PATCH /api/v2/workflows/[workflowId]/versions/[version]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.updateVersionMetadata.mockResolvedValue({ + name: 'Escalation routing', + description: 'Adds the escalation branch.', + }) + }) + + async function patch(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/workflows/workflow-1/versions/2', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return PATCH(request, { params: Promise.resolve({ workflowId: 'workflow-1', version: '2' }) }) + } + + it('writes metadata only after canonical workflow authorization', async () => { + const response = await patch({ + name: 'Escalation routing', + description: 'Adds the escalation branch.', + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + version: 2, + name: 'Escalation routing', + description: 'Adds the escalation branch.', + }, + }) + expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.updateVersionMetadata) + expect(mocks.updateVersionMetadata).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + version: 2, + name: 'Escalation routing', + description: 'Adds the escalation branch.', + }) + }) + + it('clears the release note on an explicit null and leaves an omitted label alone', async () => { + mocks.updateVersionMetadata.mockResolvedValue({ name: 'Production', description: null }) + + const response = await patch({ description: null }) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ + version: 2, + name: 'Production', + description: null, + }) + expect(mocks.updateVersionMetadata).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + version: 2, + name: undefined, + description: null, + }) + }) + + it('rejects a body that would change nothing', async () => { + const response = await patch({}) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error.code).toBe('BAD_REQUEST') + expect(JSON.stringify(body.error)).toContain( + 'At least one of name or description must be provided' + ) + expect(mocks.updateVersionMetadata).not.toHaveBeenCalled() + }) + + it('rejects the activation body shape rather than silently relabelling', async () => { + const response = await patch({ isActive: true }) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.updateVersionMetadata).not.toHaveBeenCalled() + }) + + it('answers 404 for a version that does not exist', async () => { + mocks.updateVersionMetadata.mockResolvedValue(null) + + const response = await patch({ name: 'Escalation routing' }) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Deployment version not found') + }) + + it('refuses a caller below workspace write with 403', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + const response = await patch({ name: 'Escalation routing' }) + + expect(response.status).toBe(403) + expect((await response.json()).error.details.code).toBe('INSUFFICIENT_WORKSPACE_ROLE') + expect(mocks.updateVersionMetadata).not.toHaveBeenCalled() + }) + + it('conceals a workflow the caller cannot reach as 404', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await patch({ name: 'Escalation routing' }) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(mocks.updateVersionMetadata).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await patch({ name: 'Escalation routing' }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/route.ts new file mode 100644 index 00000000000..a36bf8a9a63 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[workflowId]/versions/[version]/route.ts @@ -0,0 +1,66 @@ +import type { V2WorkflowVersionDetail } from '@/lib/api/contracts/v2/workflows' +import { + v2GetWorkflowVersionContract, + v2UpdateWorkflowVersionContract, +} from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { updateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowVersionContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.readVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.workflowId, version: params.version }), + useCase: readWorkflowVersion, + present: ({ version }) => ({ + data: { + id: version.id, + version: version.version, + name: version.name, + description: version.description, + isActive: version.isActive, + createdAt: version.createdAt.toISOString(), + state: version.state as V2WorkflowVersionDetail['state'], + }, + }), +}) + +/** + * PATCH — relabel a deployment version. + * + * Metadata only. The pinned graph is immutable, so this can never change what + * the version executes, and it never touches which version is live: promoting + * one is `POST .../activate`. The internal editor's PATCH dispatches between + * the two on the presence of `isActive` in the body; v2 deliberately does not, + * because a body-shape switch between "rename" and "change what production + * serves" is one typo away from the wrong outcome. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateWorkflowVersionContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.updateVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, body }) => ({ + workflowId: params.workflowId, + version: params.version, + name: body.name, + description: body.description, + }), + useCase: updateWorkflowVersion, + present: (result) => ({ + data: { + version: result.version, + name: result.name, + description: result.description, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/versions/route.test.ts similarity index 96% rename from apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/versions/route.test.ts index d9762d98578..a92c5c507e5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/versions/route.test.ts @@ -28,7 +28,7 @@ vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) -import { GET } from '@/app/api/v2/workflows/[id]/versions/route' +import { GET } from '@/app/api/v2/workflows/[workflowId]/versions/route' const auth = { principal: { @@ -41,10 +41,10 @@ const auth = { rateLimitSubscription: null, keyType: 'workspace' as const, } -const context = { params: Promise.resolve({ id: 'workflow-1' }) } +const context = { params: Promise.resolve({ workflowId: 'workflow-1' }) } function contextFor(workflowId: string) { - return { params: Promise.resolve({ id: workflowId }) } + return { params: Promise.resolve({ workflowId: workflowId }) } } function listVersions(workflowId: string, query = '') { @@ -87,7 +87,7 @@ async function forgeInsideBinding(workflowId: string, payload: unknown): Promise return Buffer.from(JSON.stringify({ scope, inner })).toString('base64') } -describe('GET /api/v2/workflows/[id]/versions', () => { +describe('GET /api/v2/workflows/[workflowId]/versions', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/versions/route.ts similarity index 95% rename from apps/sim/app/api/v2/workflows/[id]/versions/route.ts rename to apps/sim/app/api/v2/workflows/[workflowId]/versions/route.ts index 30cff126cec..fb588e523e4 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/versions/route.ts @@ -30,7 +30,7 @@ export const revalidate = 0 * which history the ordinal counts within. */ function versionCursorScope(workflowId: string): string { - return cursorScopeKey(cursorRoute(v2ListWorkflowVersionsContract, { id: workflowId })) + return cursorScopeKey(cursorRoute(v2ListWorkflowVersionsContract, { workflowId })) } export const GET = defineV2JsonRoute({ @@ -40,13 +40,13 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params, query }) => { - const inner = readScopedCursor(query.cursor, versionCursorScope(params.id)) + const inner = readScopedCursor(query.cursor, versionCursorScope(params.workflowId)) const decoded = inner ? v2WorkflowVersionCursorSchema.safeParse(decodeCursor(inner)) : undefined if (decoded && !decoded.success) { throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } return { - workflowId: params.id, + workflowId: params.workflowId, limit: query.limit, afterVersion: decoded?.data.version, } @@ -69,7 +69,7 @@ export const GET = defineV2JsonRoute({ nextCursor: hasMore && data.length > 0 ? encodeScopedCursor( - versionCursorScope(params.id), + versionCursorScope(params.workflowId), encodeCursor({ version: data[data.length - 1].version }) ) : null, diff --git a/apps/sim/app/api/v2/workflows/move/route.test.ts b/apps/sim/app/api/v2/workflows/move/route.test.ts new file mode 100644 index 00000000000..c9ee15951e0 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/move/route.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ moveWorkflowsBulk: vi.fn() })) + +vi.mock('@/lib/workflows/application/move-workflows-bulk', () => ({ + moveWorkflowsBulk: { operation: { id: 'workflows.bulk.move' }, execute: mocks.moveWorkflowsBulk }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { POST } from '@/app/api/v2/workflows/move/route' + +const WORKSPACE_ID = 'workspace-1' +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'ws-key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:ws-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function request(body: unknown) { + return new NextRequest('http://localhost/api/v2/workflows/move', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/v2/workflows/move', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.moveWorkflowsBulk.mockResolvedValue({ + moved: ['workflow-1'], + failed: ['workflow-2'], + folderId: 'folder-1', + changes: [], + }) + }) + + it('authenticates before parsing the body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await POST(request({ nonsense: true })) + + expect(response.status).toBe(401) + expect(mocks.moveWorkflowsBulk).not.toHaveBeenCalled() + }) + + it('exposes both arms of the best-effort result', async () => { + const response = await POST( + request({ + workspaceId: WORKSPACE_ID, + workflowIds: ['workflow-1', 'workflow-2'], + folderPath: '/Operations', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { moved: ['workflow-1'], failed: ['workflow-2'], folderPath: '/Operations' }, + }) + expect(mocks.moveWorkflowsBulk).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + workflowIds: ['workflow-1', 'workflow-2'], + folderPath: '/Operations', + }, + request: expect.anything(), + }) + }) + + it('normalizes a folder path with no leading slash', async () => { + await POST( + request({ workspaceId: WORKSPACE_ID, workflowIds: ['workflow-1'], folderPath: 'Operations' }) + ) + + expect(mocks.moveWorkflowsBulk).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ folderPath: '/Operations' }), + }) + ) + }) + + it('rejects a batch above the cap', async () => { + const response = await POST( + request({ + workspaceId: WORKSPACE_ID, + workflowIds: Array.from({ length: 101 }, (_, index) => `workflow-${index}`), + folderPath: '/Operations', + }) + ) + + expect(response.status).toBe(400) + expect(mocks.moveWorkflowsBulk).not.toHaveBeenCalled() + }) + + it('rejects a folderId, which is not part of the public surface', async () => { + const response = await POST( + request({ workspaceId: WORKSPACE_ID, workflowIds: ['workflow-1'], folderId: null }) + ) + + expect(response.status).toBe(400) + expect(mocks.moveWorkflowsBulk).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/move/route.ts b/apps/sim/app/api/v2/workflows/move/route.ts new file mode 100644 index 00000000000..db1f3558932 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/move/route.ts @@ -0,0 +1,40 @@ +import { v2MoveWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Relocates up to 100 workflows into one folder. + * + * Explicitly best-effort: each workflow moves in its own transaction, and one + * that is absent from the workspace, archived, or locked lands in `failed` while + * the rest still move. An infrastructure fault is propagated rather than + * reported as a per-item failure. + * + * Sits beside `/workflows/folders` at the collection level so it cannot shadow + * `/workflows/{workflowId}`. + */ +export const POST = defineV2JsonRoute({ + contract: v2MoveWorkflowsContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.moveBulk, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + workflowIds: body.workflowIds, + folderPath: body.folderPath, + }), + useCase: moveWorkflowsBulk, + present: (result, { body }) => ({ + data: { moved: result.moved, failed: result.failed, folderPath: body.folderPath }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 0eea5e18ef4..44f8d0661f3 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -30,9 +30,21 @@ vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +import { v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { writeSortedCursor } from '@/app/api/v2/lib/response' import { GET, POST } from '@/app/api/v2/workflows/route' const WORKSPACE_ID = 'workspace-1' +const SEEDED_START_BLOCK = { + id: 'start-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} const WORKFLOW = { id: 'workflow-1', name: 'Daily digest', @@ -86,7 +98,58 @@ describe('/api/v2/workflows', () => { sortBy: 'position', sortOrder: 'asc', }) - mocks.createWorkflow.mockResolvedValue({ workflow: WORKFLOW, folderPath: '/' }) + mocks.createWorkflow.mockResolvedValue({ + workflow: WORKFLOW, + folderPath: '/', + normalizedState: { blocks: { 'start-1': SEEDED_START_BLOCK } }, + }) + }) + + it('lists the active scope by default and forwards an explicit archived scope', async () => { + await GET(new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`)) + expect(mocks.listWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ scope: 'active' }) }) + ) + + await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&scope=archived` + ) + ) + expect(mocks.listWorkflows).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ scope: 'archived' }) }) + ) + }) + + it('rejects a scope the surface does not serve', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&scope=all`) + ) + + expect(response.status).toBe(400) + expect(mocks.listWorkflows).not.toHaveBeenCalled() + }) + + it('refuses a cursor replayed under a different scope', async () => { + mocks.listWorkflows.mockResolvedValueOnce({ + workflows: [WORKFLOW], + nextCursorKeys: [1, WORKFLOW.id], + sortBy: 'position', + sortOrder: 'asc', + }) + const first = await GET( + new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`) + ) + const { nextCursor } = await first.json() + expect(nextCursor).toEqual(expect.any(String)) + + const replayed = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&scope=archived&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) }) it('authenticates and rate limits before parsing list input', async () => { @@ -163,6 +226,39 @@ describe('/api/v2/workflows', () => { ) }) + /** + * `scope` carries `.default('active')`, so it is present on every parsed + * query. Stamping it unconditionally would put a constant in every + * fingerprint and refuse every cursor minted before the param existed, with + * the misleading "cursor does not match the requested filters" message — a + * caller that changed nothing would be told it changed a filter. The default + * must therefore contribute nothing to the scope. + */ + it('resumes a cursor minted before scope entered the binding', async () => { + const legacyCursor = writeSortedCursor( + [1, WORKFLOW.id], + 'position', + 'asc', + cursorScopeKey(cursorRoute(v2ListWorkflowsContract), { + workspaceId: WORKSPACE_ID, + deployedOnly: false, + }) + ) as string + + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&cursor=${encodeURIComponent(legacyCursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.listWorkflows).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ cursorKeys: [1, WORKFLOW.id] }), + }) + ) + }) + it('lists through the workspace principal and preserves rate headers', async () => { const request = new NextRequest( `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`, @@ -208,7 +304,9 @@ describe('/api/v2/workflows', () => { const response = await POST(request) expect(response.status).toBe(201) - expect((await response.json()).data.id).toBe(WORKFLOW.id) + const created = (await response.json()).data + expect(created.id).toBe(WORKFLOW.id) + expect(created.blocks).toEqual([{ id: 'start-1', type: 'starter', name: 'Start' }]) expect(mocks.createWorkflow).toHaveBeenCalledWith({ principal: personalAuth.principal, input: { workspaceId: WORKSPACE_ID, name: WORKFLOW.name }, diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index d291c231a6c..e1afaaadb8b 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -19,12 +19,19 @@ export const revalidate = 0 function workflowCursorFilters(query: { workspaceId: string folderPath?: string + scope: 'active' | 'archived' deployedOnly: boolean search?: string }) { return cursorScopeKey(cursorRoute(v2ListWorkflowsContract), { workspaceId: query.workspaceId, folderPath: query.folderPath, + // Stamped only when it is not the default. `scope` carries + // `.default('active')`, so it is always present on the parsed query; + // binding it unconditionally would put a constant in every fingerprint and + // reject every cursor minted before the field existed — including on + // callers who never sent it. + scope: query.scope === 'active' ? undefined : query.scope, deployedOnly: query.deployedOnly, search: query.search, }) @@ -39,6 +46,7 @@ export const GET = defineV2JsonRoute({ mapInput: ({ query }) => ({ workspaceId: query.workspaceId, folderPath: query.folderPath, + scope: query.scope, deployedOnly: query.deployedOnly, search: query.search, sortBy: query.sortBy, @@ -85,8 +93,13 @@ export const POST = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ body }) => body, useCase: createWorkflow, - present: ({ workflow, folderPath }) => ({ + present: ({ workflow, folderPath, normalizedState }) => ({ data: { + blocks: Object.values(normalizedState.blocks).map((block) => ({ + id: block.id, + type: block.type, + name: block.name, + })), id: workflow.id, name: workflow.name, description: workflow.description ?? null, diff --git a/apps/sim/blocks/registry-lookup.test.ts b/apps/sim/blocks/registry-lookup.test.ts new file mode 100644 index 00000000000..060e3b1702f --- /dev/null +++ b/apps/sim/blocks/registry-lookup.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +// The suite-wide mock stubs the registry; this file is about the real lookup. +vi.unmock('@/blocks/registry') + +const { getBlock } = await import('@/blocks/registry') + +/** + * `BLOCK_REGISTRY` is an object literal, so a bare bracket lookup answers every + * inherited `Object.prototype` member with a function. Those are truthy and + * carry no `type`, so a consumer that trusts the lookup reads `undefined.type` + * and throws — turning a caller-supplied path segment into a 500 on a + * well-formed request. `GET /api/v2/blocks/{blockId}` accepts any string, which + * is what makes this reachable rather than theoretical. + */ +describe('getBlock prototype safety', () => { + it.each(['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__', 'isPrototypeOf'])( + 'answers %s with undefined rather than an inherited member', + (key) => { + expect(getBlock(key)).toBeUndefined() + } + ) + + it('still resolves a real block', () => { + const block = getBlock('agent') + expect(block?.type).toBe('agent') + }) +}) + +/** + * The list and the detail read must agree about a type. + * + * `slack_v2` is `preview`-gated while `slack` v1 deliberately stays in the + * toolbar so a workspace has a Slack block during the gate. Resolving the + * detail to the newest version and then hiding it answered `404` for a type + * `GET /api/v2/blocks` was publishing in the same breath. + */ +describe('version resolution for a viewer', () => { + it.each(['slack', 'table'])( + 'resolves %s to a version the unrevealed viewer can actually see', + async (type) => { + const { getLatestBlockForViewer, getAllBlocks } = await import('@/blocks/registry') + + const detail = getLatestBlockForViewer(type) + const listed = getAllBlocks().find( + (block) => + !block.hideFromToolbar && (block.type === type || block.type.startsWith(`${type}_v`)) + ) + + expect(Boolean(detail)).toBe(Boolean(listed)) + if (detail && listed) expect(detail.type).toBe(listed.type) + } + ) +}) diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 6d1170e5198..dfc104d7516 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -19,9 +19,23 @@ function normalizeType(type: string): string { return type.replace(/-/g, '_') } +/** + * Reads a registry entry by its own key only. + * + * `BLOCK_REGISTRY` is an object literal with an intact prototype, so a bare + * bracket lookup answers `constructor`, `toString`, `valueOf` and friends with + * an inherited function. Those are truthy and carry no `type`, so every + * consumer downstream treats them as a block and throws on the first field it + * reads — a caller-supplied string turning into a 500. `getToolMetadata` guards + * the same way for the same reason. + */ +function ownBlock(type: string): BlockConfig | undefined { + return Object.hasOwn(BLOCK_REGISTRY, type) ? BLOCK_REGISTRY[type] : undefined +} + /** Get the block config for a single block type. Falls back to the custom-block overlay. */ export function getBlock(type: string): BlockConfig | undefined { - return BLOCK_REGISTRY[type] ?? BLOCK_REGISTRY[normalizeType(type)] ?? resolveOverlayBlock(type) + return ownBlock(type) ?? ownBlock(normalizeType(type)) ?? resolveOverlayBlock(type) } /** @@ -94,11 +108,84 @@ export function getAllBlocks(): BlockConfig[] { return all.map((block) => projectBlock(block, vis)) } +/** + * The newest version of a block type, projected through the viewer's visibility. + * + * The detail-read counterpart of {@link getAllBlocks}, and the two must agree. + * {@link getBlock} is a pure key lookup: it answers `confluence` with the + * superseded v1 (which carries `hideFromToolbar`, so a catalog read of it 404s + * while the list contains `confluence_v2`), and it never applies the + * " (Preview)" display suffix a revealed preview block carries in the list. This + * resolves the version the way {@link getLatestBlock} does and projects the + * result the way {@link getAllBlocks} does, so a detail read can never + * contradict the list it came from. Execution paths keep using the pure + * {@link getBlock}. + */ +export function getLatestBlockForViewer(type: string): BlockConfig | undefined { + const vis = overlayVisibility() + const overlay = resolveOverlayBlock(type) + + for (const candidate of versionCandidates(type)) { + if (!effectiveHidden(candidate, vis)) { + return visibilityInert(vis) ? candidate : projectBlock(candidate, vis) + } + } + + if (overlay && !effectiveHidden(overlay, vis)) { + return visibilityInert(vis) ? overlay : projectBlock(overlay, vis) + } + return undefined +} + +/** + * Every registered version of a base type, newest first. + * + * The detail read walks these rather than taking `getLatestBlock` and hiding + * the result, because "newest" and "visible to this viewer" are different + * questions. `slack_v2` is `preview`-gated while `slack` v1 deliberately stays + * in the toolbar so the workspace has a Slack block at all — so resolving to + * the newest and then hiding it answers `404` for a type the list is + * simultaneously publishing. Walking down to the newest *visible* version is + * what makes the two agree. + */ +function versionCandidates(type: string): BlockConfig[] { + const normalized = normalizeType(type) + const prefix = `${normalized}_v` + const versioned: Array<{ version: number; config: BlockConfig }> = [] + for (const key of Object.keys(BLOCK_REGISTRY)) { + if (!key.startsWith(prefix)) continue + const version = parseVersionSuffix(key.slice(prefix.length)) + if (version !== undefined) versioned.push({ version, config: BLOCK_REGISTRY[key]! }) + } + versioned.sort((left, right) => right.version - left.version) + + const candidates = versioned.map((entry) => entry.config) + const base = ownBlock(normalized) + if (base) candidates.push(base) + return candidates +} + /** Find the block whose `tools.access` contains the given tool id. */ export function getBlockByToolName(toolName: string): BlockConfig | undefined { return Object.values(BLOCK_REGISTRY).find((b) => b.tools?.access?.includes(toolName)) } +/** + * The digits of a `_vN` suffix, or `undefined` when the remainder is not a + * version. + * + * Matched by string comparison rather than a `RegExp` built from the caller's + * type: `GET /api/v2/blocks/{blockId}` accepts any string, so `[`, `(`, `*` or + * `a{2,1}` would be interpolated into the pattern and throw a `SyntaxError` + * during compilation — an unclassified 500 on a well-formed request. + * `tools/tool-ids.ts` resolves the same `_vN` convention the same way. + */ +function parseVersionSuffix(suffix: string): number | undefined { + if (!suffix || !/^\d+$/.test(suffix)) return undefined + const version = Number.parseInt(suffix, 10) + return Number.isSafeInteger(version) ? version : undefined +} + /** * Resolve the canonical (highest-version) block for a base type. Handles * versioned variants like `confluence_v2`: callers pass `confluence` and @@ -108,20 +195,19 @@ export function getBlockByToolName(toolName: string): BlockConfig | undefined { */ function resolveLatest(baseType: string): { type: string; config: BlockConfig } | undefined { const normalized = normalizeType(baseType) - const versionPattern = new RegExp(`^${normalized}_v(\\d+)$`) + const prefix = `${normalized}_v` let latestKey: string | undefined let latestVersion = -1 for (const key of Object.keys(BLOCK_REGISTRY)) { - const match = key.match(versionPattern) - if (!match) continue - const version = Number.parseInt(match[1]!, 10) - if (version > latestVersion) { + if (!key.startsWith(prefix)) continue + const version = parseVersionSuffix(key.slice(prefix.length)) + if (version !== undefined && version > latestVersion) { latestVersion = version latestKey = key } } if (latestKey) return { type: latestKey, config: BLOCK_REGISTRY[latestKey]! } - const config = BLOCK_REGISTRY[normalized] + const config = ownBlock(normalized) return config ? { type: normalized, config } : undefined } diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 5419cd85721..41e97d90e62 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -664,6 +664,7 @@ export class DAGExecutor { resolution: startResolution, workflowInput: this.workflowInput, runMetadata: this.contextExtensions.startRunMetadata, + workspaceId: this.contextExtensions.workspaceId, }) state.setBlockState(startResolution.block.id, { diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 98b5c80d15d..41e4488177b 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -32,6 +32,13 @@ function createBlock( } } +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_WORKSPACE_ID = '22222222-2222-4222-8222-222222222222' +const WORKFLOW_ID = '33333333-3333-4333-8333-333333333333' +const EXECUTION_ID = '44444444-4444-4444-8444-444444444444' +const EXECUTION_FILE_KEY = `execution/${WORKSPACE_ID}/${WORKFLOW_ID}/${EXECUTION_ID}/screenshot.png` +const EXECUTION_FILE_URL = `/api/files/serve/s3/${encodeURIComponent(EXECUTION_FILE_KEY)}?context=execution` + describe('start-block utilities', () => { it.concurrent('buildResolutionFromBlock returns null when metadata id missing', () => { const block = createBlock('api_trigger') @@ -96,15 +103,17 @@ describe('start-block utilities', () => { { id: 'file-1', name: 'document.txt', - url: 'https://example.com/document.txt', + url: `/api/files/serve/s3/${encodeURIComponent(`workspace/${WORKSPACE_ID}/document.txt`)}?context=workspace`, size: 42, type: 'text/plain', - key: 'file-key', + key: `workspace/${WORKSPACE_ID}/document.txt`, + context: 'workspace', }, ] const output = buildStartBlockOutput({ resolution, + workspaceId: WORKSPACE_ID, workflowInput: { input: { name: 'Ada', @@ -129,12 +138,13 @@ describe('start-block utilities', () => { const output = buildStartBlockOutput({ resolution, + workspaceId: WORKSPACE_ID, workflowInput: { files: [ { id: 'file_1', name: 'screenshot.png', - url: '/api/files/serve/s3/execution%2Fworkspace-id%2Fworkflow-id%2Fexecution-id%2Fscreenshot.png?context=execution', + url: EXECUTION_FILE_URL, size: 243289, type: 'image/png', }, @@ -146,15 +156,309 @@ describe('start-block utilities', () => { { id: 'file_1', name: 'screenshot.png', - url: '/api/files/serve/s3/execution%2Fworkspace-id%2Fworkflow-id%2Fexecution-id%2Fscreenshot.png?context=execution', + url: EXECUTION_FILE_URL, size: 243289, type: 'image/png', - key: 'execution/workspace-id/workflow-id/execution-id/screenshot.png', + key: EXECUTION_FILE_KEY, context: 'execution', }, ]) }) + it.concurrent('drops a storage key naming another workspace, whatever URL carries it', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workspaceId: WORKSPACE_ID, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'victim.pdf', + url: 'https://example.com/victim.pdf', + size: 1024, + type: 'application/pdf', + key: `workspace/${OTHER_WORKSPACE_ID}/victim.pdf`, + context: 'workspace', + }, + ], + }, + }) + + expect(output.files).toBeUndefined() + }) + + /** + * The server-side uploader for run inputs returns a *presigned cloud* URL + * whenever object storage is configured, whose path is the bucket key rather + * than `/api/files/serve/...`. A URL-only ownership rule therefore drops every + * chat attachment, API `files[]` payload and webhook file field on any + * deployment not using local storage — silently, because normalization is + * all-or-nothing — while passing locally and under vitest, where the uploader + * falls back to an internal URL. + */ + it.concurrent('keeps an owned execution file carried by a presigned cloud URL', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + const key = `execution/${WORKSPACE_ID}/wf_1/exec_1/report.pdf` + + const output = buildStartBlockOutput({ + resolution, + workspaceId: WORKSPACE_ID, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'report.pdf', + url: `https://bucket.s3.us-east-1.amazonaws.com/${key}?X-Amz-Signature=abc`, + size: 2048, + type: 'application/pdf', + key, + context: 'execution', + }, + ], + }, + }) + + expect(output.files).toEqual([ + expect.objectContaining({ id: 'file_1', key, context: 'execution' }), + ]) + }) + + /** + * `context` selects the bucket a byte read targets, so it is derived from the + * accepted key rather than read from the payload or the URL's `?context=`. + * Otherwise an owned key could be labelled with a world-readable context. + */ + it.concurrent('derives context from the key, ignoring a caller-supplied one', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + const key = `execution/${WORKSPACE_ID}/wf_1/exec_1/report.pdf` + + const output = buildStartBlockOutput({ + resolution, + workspaceId: WORKSPACE_ID, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'report.pdf', + url: `/api/files/serve/${encodeURIComponent(key)}?context=profile-pictures`, + size: 2048, + type: 'application/pdf', + key, + context: 'profile-pictures', + }, + ], + }, + }) + + expect(output.files).toEqual([expect.objectContaining({ context: 'execution' })]) + }) + + /** + * A payload whose `key` and `url` disagree is refused rather than resolved in + * the caller's favour. Both fields are caller-authored, so picking the one + * that happens to pass would make a forged key free to send alongside a real + * URL; a genuine uploader always writes the two consistently, so nothing + * legitimate is refused. + */ + it.concurrent('drops a file whose supplied key contradicts its internal URL', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workspaceId: WORKSPACE_ID, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'screenshot.png', + url: EXECUTION_FILE_URL, + size: 243289, + type: 'image/png', + key: `workspace/${OTHER_WORKSPACE_ID}/victim.pdf`, + context: 'workspace', + }, + ], + }, + }) + + expect(output.files).toBeUndefined() + }) + + it.concurrent('derives the storage key from an internal URL when none is supplied', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workspaceId: WORKSPACE_ID, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'screenshot.png', + url: EXECUTION_FILE_URL, + size: 243289, + type: 'image/png', + }, + ], + }, + }) + + expect(output.files).toEqual([ + expect.objectContaining({ id: 'file_1', name: 'screenshot.png', context: 'execution' }), + ]) + }) + + it.concurrent('rejects a malformed internal URL rather than falling back to a forged key', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workspaceId: WORKSPACE_ID, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'victim.pdf', + url: '/api/files/serve/', + size: 1024, + type: 'application/pdf', + key: `workspace/${OTHER_WORKSPACE_ID}/victim.pdf`, + }, + ], + }, + }) + + expect(output.files).toBeUndefined() + }) + + it.concurrent('rejects an internal URL whose storage key names another workspace', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workspaceId: WORKSPACE_ID, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'victim.pdf', + url: '/api/files/serve/s3/workspace%2Fother-tenant-ws%2Fsecrets.pdf?context=workspace', + size: 1024, + type: 'application/pdf', + }, + { + id: 'file_2', + name: 'victim.pdf', + url: `/api/files/serve/s3/${encodeURIComponent(`workspace/${OTHER_WORKSPACE_ID}/secrets.pdf`)}?context=workspace`, + size: 1024, + type: 'application/pdf', + }, + { + id: 'file_3', + name: 'victim.pdf', + url: `https://evil.example.com/api/files/serve/s3/${encodeURIComponent( + `workspace/${OTHER_WORKSPACE_ID}/secrets.pdf` + )}?context=workspace`, + size: 1024, + type: 'application/pdf', + }, + ], + }, + }) + + expect(output.files).toBeUndefined() + }) + + it.concurrent('rejects a storage key whose layout names no workspace', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workspaceId: WORKSPACE_ID, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'notes.txt', + url: '/api/files/serve/s3/chat%2Fnotes.txt?context=chat', + size: 12, + type: 'text/plain', + }, + ], + }, + }) + + expect(output.files).toBeUndefined() + }) + + it.concurrent('rejects every Start file when the execution carries no workspace', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'screenshot.png', + url: EXECUTION_FILE_URL, + size: 243289, + type: 'image/png', + }, + ], + }, + }) + + expect(output.files).toBeUndefined() + }) + it.concurrent('rejects inputFormat fields that collide with executor routing keys', () => { const block = createBlock('start_trigger', 'start', { subBlocks: { diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index a40fc42260a..87ad0770986 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -1,5 +1,6 @@ import { isRecordLike } from '@sim/utils/object' import { + extractWorkspaceIdFromStorageKey, inferContextFromKey, isInternalFileUrl, parseInternalFileUrl, @@ -334,8 +335,61 @@ function getRawInputCandidate(workflowInput: unknown): unknown { return workflowInput } -function normalizeStartFile(file: unknown): UserFile | null { - if (!isRecordLike(file)) { +/** + * The storage key for a Start file, when the caller can prove the execution's + * workspace owns it. + * + * Checks the supplied key first and the key parsed out of an internal URL + * second; both are caller-authored, so both are held to the same ownership test + * and neither is preferred for being "more official". + */ +function resolveOwnedStartFileKey(suppliedKey: unknown, url: string, workspaceId: string): string { + if (typeof suppliedKey === 'string' && suppliedKey) { + if (extractWorkspaceIdFromStorageKey(suppliedKey) === workspaceId) { + return suppliedKey + } + return '' + } + + if (!url || !isInternalFileUrl(url)) { + return '' + } + + try { + const parsed = parseInternalFileUrl(url) + return extractWorkspaceIdFromStorageKey(parsed.key) === workspaceId ? parsed.key : '' + } catch { + return '' + } +} + +/** + * Normalizes one caller-supplied file object into the executor's canonical + * {@link UserFile}, or returns `null` when the shape is not usable. + * + * A storage key names a specific tenant's bytes, so the test is ownership, not + * provenance: a key is accepted when its own layout names the workspace this + * execution runs in — `workspace/{workspaceId}/…` or + * `execution/{workspaceId}/…` — and rejected otherwise. That holds whether the + * key arrives directly or is parsed out of the file's URL, so neither source has + * to be trusted: {@link isInternalFileUrl} matches the path prefix on any host, + * and a caller can author either field. + * + * Both sources have to be honoured. The server-side uploader for run inputs + * hands back a *presigned cloud* URL whenever object storage is configured + * (`generatePresignedDownloadUrl`), whose path is the bucket key rather than + * `/api/files/serve/...` — so a URL-only rule silently drops every chat + * attachment, API `files[]` payload and webhook file field on any deployment + * that is not using local storage, while passing locally and in tests. + * + * Key layouts that name no workspace (`kb/`, `chat/`, `copilot/`, the + * world-readable prefixes) prove no ownership and are rejected, as is every file + * when the execution carries no workspace at all. `context` is derived from the + * accepted key rather than read from the payload or the URL's `?context=`, so it + * cannot label owned bytes with a bucket they do not live in. + */ +function normalizeStartFile(file: unknown, workspaceId: string | undefined): UserFile | null { + if (!isRecordLike(file) || !workspaceId) { return null } @@ -345,26 +399,14 @@ function normalizeStartFile(file: unknown): UserFile | null { typeof file.url === 'string' ? file.url : typeof file.path === 'string' ? file.path : '' const size = typeof file.size === 'number' ? file.size : Number.NaN const type = typeof file.type === 'string' ? file.type : '' - const explicitKey = typeof file.key === 'string' ? file.key : '' - - let key = explicitKey - let context = typeof file.context === 'string' ? file.context : undefined - - if (!key && url && isInternalFileUrl(url)) { - try { - const parsed = parseInternalFileUrl(url) - key = parsed.key - context = context || parsed.context - } catch { - return null - } - } - if (!context && key) { + const key = resolveOwnedStartFileKey(file.key, url, workspaceId) + let context: string | undefined + if (key) { try { context = inferContextFromKey(key) } catch { - // Older file outputs may have opaque keys; keep the file shape intact. + return null } } @@ -384,7 +426,10 @@ function normalizeStartFile(file: unknown): UserFile | null { } } -function getFilesFromWorkflowInput(workflowInput: unknown): UserFile[] | undefined { +function getFilesFromWorkflowInput( + workflowInput: unknown, + workspaceId: string | undefined +): UserFile[] | undefined { if (!isRecordLike(workflowInput)) { return undefined } @@ -393,7 +438,7 @@ function getFilesFromWorkflowInput(workflowInput: unknown): UserFile[] | undefin return undefined } - const normalizedFiles = files.map(normalizeStartFile) + const normalizedFiles = files.map((file) => normalizeStartFile(file, workspaceId)) if (normalizedFiles.every((file): file is UserFile => Boolean(file))) { return normalizedFiles } @@ -402,9 +447,10 @@ function getFilesFromWorkflowInput(workflowInput: unknown): UserFile[] | undefin function mergeFilesIntoOutput( output: NormalizedBlockOutput, - workflowInput: unknown + workflowInput: unknown, + workspaceId: string | undefined ): NormalizedBlockOutput { - const files = getFilesFromWorkflowInput(workflowInput) + const files = getFilesFromWorkflowInput(workflowInput, workspaceId) if (files) { output.files = files } else if (isRecordLike(workflowInput) && Object.hasOwn(workflowInput, 'files')) { @@ -468,7 +514,7 @@ function buildUnifiedStartOutput( output.conversationId = undefined } - return mergeFilesIntoOutput(output, workflowInput) + return output } function buildApiOrInputOutput(finalInput: unknown, workflowInput: unknown): NormalizedBlockOutput { @@ -481,7 +527,7 @@ function buildApiOrInputOutput(finalInput: unknown, workflowInput: unknown): Nor } : { input: finalInput } - return mergeFilesIntoOutput(output, workflowInput) + return output } function buildChatOutput(workflowInput: unknown): NormalizedBlockOutput { @@ -496,7 +542,7 @@ function buildChatOutput(workflowInput: unknown): NormalizedBlockOutput { output.conversationId = conversationId } - return mergeFilesIntoOutput(output, workflowInput) + return output } function buildLegacyStarterOutput( @@ -523,7 +569,7 @@ function buildLegacyStarterOutput( output.conversationId = ensureString(conversationId) } - return mergeFilesIntoOutput(output, workflowInput) + return output } function buildManualTriggerOutput( @@ -538,7 +584,7 @@ function buildManualTriggerOutput( output.input = getRawInputCandidate(workflowInput) } - return mergeFilesIntoOutput(output, workflowInput) + return output } function buildIntegrationTriggerOutput( @@ -566,7 +612,7 @@ function buildIntegrationTriggerOutput( } } - return mergeFilesIntoOutput(output, workflowInput) + return output } function extractSubBlocks(block: SerializedBlock): Record | undefined { @@ -592,6 +638,11 @@ export interface StartBlockOutputOptions { workflowInput: unknown /** Trusted, server-built run metadata. Only applied when the block's toggle is on. */ runMetadata?: StartBlockRunMetadata + /** + * Workspace this execution runs in. Caller-supplied Start files are admitted + * only when their storage key names this workspace; absent it, none are. + */ + workspaceId?: string } function assertNoMetadataInputFormatField( @@ -665,6 +716,8 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali output = buildManualTriggerOutput(finalInput, workflowInput) } + output = mergeFilesIntoOutput(output, workflowInput, options.workspaceId) + if (runMetadataEnabled) { // The metadata key is server-owned when the toggle is on: any caller-supplied // value is dropped, and absent trusted metadata leaves the key absent (fail closed). diff --git a/apps/sim/lib/api/application/operations.ts b/apps/sim/lib/api/application/operations.ts new file mode 100644 index 00000000000..619bad28adf --- /dev/null +++ b/apps/sim/lib/api/application/operations.ts @@ -0,0 +1,17 @@ +import { defineOperation } from '@/lib/core/application' + +/** + * Operations a credential performs on itself. + * + * They carry no workspace scope and no role: the resource *is* the + * authenticated key, so holding it is the whole authorization story. What is + * left — which kinds of principal may hold that resource — is declared here as + * data through {@link defineOperation}, so the policy is inspectable rather + * than hand-rolled inside the use case. + */ +export const v2MetaOperations = { + read: defineOperation({ + id: 'meta.capabilities.read', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/api/application/read-v2-api-capabilities.test.ts b/apps/sim/lib/api/application/read-v2-api-capabilities.test.ts new file mode 100644 index 00000000000..492c1cecd1e --- /dev/null +++ b/apps/sim/lib/api/application/read-v2-api-capabilities.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + isFeatureEnabled: vi.fn(), +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mocks.isFeatureEnabled })) + +import { v2MetaOperations } from '@/lib/api/application/operations' +import { readV2ApiCapabilities } from '@/lib/api/application/read-v2-api-capabilities' + +const personalKey: Principal = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } +const workspaceKey: Principal = { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-2', +} + +describe('readV2ApiCapabilities', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.isFeatureEnabled.mockResolvedValue(true) + }) + + it('reports the cohort of the rollout subject the authenticator resolved', async () => { + const result = await readV2ApiCapabilities.execute({ + principal: personalKey, + input: { + rolloutUserId: 'user-1', + keyType: 'personal', + expiresAt: new Date('2027-01-01T00:00:00.000Z'), + }, + }) + + expect(result).toEqual({ + v2Enabled: true, + keyType: 'personal', + expiresAt: new Date('2027-01-01T00:00:00.000Z'), + }) + expect(mocks.isFeatureEnabled).toHaveBeenCalledWith('v2-api', { userId: 'user-1' }) + }) + + /** + * The gate keys a workspace key on the workspace's billing owner as + * rollout-only context, and the authenticator has already resolved it — + * re-deriving it here would be the application layer reading billing to + * answer a question authentication already answered. + */ + it('reports a workspace key against the billing owner the authenticator carried', async () => { + mocks.isFeatureEnabled.mockResolvedValue(false) + + const result = await readV2ApiCapabilities.execute({ + principal: workspaceKey, + input: { rolloutUserId: 'owner-1', keyType: 'workspace', expiresAt: null }, + }) + + expect(result).toEqual({ v2Enabled: false, keyType: 'workspace', expiresAt: null }) + expect(mocks.isFeatureEnabled).toHaveBeenCalledWith('v2-api', { userId: 'owner-1' }) + }) + + /** + * `v2ApiKeyAuth` can only ever build an API-key principal, so this branch is + * a wiring bug rather than a refusal a caller can provoke. It must not render + * as a `403`: the operation publishes none, and a codeless one would name no + * remedy from `FORBIDDEN_DETAIL_CODES`. + */ + it('treats an impossible principal kind as an invariant failure, not a forbidden', async () => { + const session: Principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } + + const error = await readV2ApiCapabilities + .execute({ + principal: session, + input: { rolloutUserId: 'user-1', keyType: 'personal', expiresAt: null }, + }) + .catch((e: unknown) => e) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain('meta.capabilities.read') + expect(error).not.toHaveProperty('code', 'forbidden') + expect(mocks.isFeatureEnabled).not.toHaveBeenCalled() + }) + + it('declares its principal policy as frozen data rather than leaving it implicit', () => { + expect(v2MetaOperations.read).toMatchObject({ + id: 'meta.capabilities.read', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }) + expect(Object.isFrozen(v2MetaOperations.read)).toBe(true) + expect(Object.isFrozen(v2MetaOperations.read.principalKinds)).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/application/read-v2-api-capabilities.ts b/apps/sim/lib/api/application/read-v2-api-capabilities.ts new file mode 100644 index 00000000000..49762e61032 --- /dev/null +++ b/apps/sim/lib/api/application/read-v2-api-capabilities.ts @@ -0,0 +1,50 @@ +import { v2MetaOperations } from '@/lib/api/application/operations' +import type { V2ApiKeyType } from '@/lib/api/contracts/v2/meta' +import { assertOperationPrincipal, type OperationUseCase } from '@/lib/core/application' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + +/** + * The credential facts the authenticating adapter has already established. + * + * They arrive as input rather than being re-read here because both live in the + * API-key row `authenticateV2ApiKey` has just validated, and the application + * layer does not query API keys. `rolloutUserId` is the subject the `v2-api` + * gate is keyed on — a personal key answers for its own user, an actor-less + * workspace key for its workspace's canonical billing owner. It is rollout + * context only, never an authorization principal. + */ +export interface ReadV2ApiCapabilitiesInput { + rolloutUserId: string + keyType: V2ApiKeyType + expiresAt: Date | null +} + +export interface ReadV2ApiCapabilitiesResult { + v2Enabled: boolean + keyType: V2ApiKeyType + expiresAt: Date | null +} + +/** + * Reports the calling credential's own rollout and lifecycle facts. + * + * The rare case where the application-boundary rule is satisfied trivially: + * there is no workspace resource to load and no resource authorization to make, + * because the resource *is* the key the caller already proved it holds. The one + * decision left is the rollout cohort, which is this use case's whole business. + */ +export const readV2ApiCapabilities: OperationUseCase< + typeof v2MetaOperations.read, + ReadV2ApiCapabilitiesInput, + ReadV2ApiCapabilitiesResult +> = { + operation: v2MetaOperations.read, + async execute({ principal, input }) { + assertOperationPrincipal(principal, v2MetaOperations.read) + return { + v2Enabled: await isFeatureEnabled('v2-api', { userId: input.rolloutUserId }), + keyType: input.keyType, + expiresAt: input.expiresAt, + } + }, +} diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 8d6c83288ad..a0f88bd5f36 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -78,29 +78,66 @@ export const chunkingStrategyOptionsSchema = storedChunkingStrategyOptionsSchema }) .strict() satisfies z.ZodType -export const chunkingConfigSchema = z - .object({ - maxSize: z.number().min(100).max(4000), - minSize: z.number().min(1).max(2000), - overlap: z.number().min(0).max(500), - strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).optional(), - strategyOptions: chunkingStrategyOptionsSchema.optional(), - }) - .refine((data) => data.minSize < data.maxSize * 4, { - message: 'Min chunk size (characters) must be less than max chunk size (tokens × 4)', - }) - .refine((data) => data.overlap < data.maxSize, { - message: 'Overlap must be less than max chunk size', - }) - .refine( - (data) => data.strategy !== 'regex' || typeof data.strategyOptions?.pattern === 'string', - { - message: 'Regex pattern is required when using the regex chunking strategy', - } - ) - .refine((data) => data.strategy === 'regex' || data.strategyOptions?.strictBoundaries !== true, { - message: 'strictBoundaries is only valid for the regex chunking strategy', - }) +export const chunkingStrategySchema = z.enum([ + 'auto', + 'text', + 'regex', + 'recursive', + 'sentence', + 'token', +]) + +/** + * The five chunking-config fields, before the cross-field rules below. Exported + * so a surface can re-describe or re-bound the fields it publishes and still + * pick the rules up from {@link withChunkingConfigRules}. + */ +export const chunkingConfigFieldsSchema = z.object({ + maxSize: z.number().min(100).max(4000), + minSize: z.number().min(1).max(2000), + overlap: z.number().min(0).max(500), + strategy: chunkingStrategySchema.optional(), + strategyOptions: chunkingStrategyOptionsSchema.optional(), +}) + +export type ChunkingConfigFields = z.output + +/** + * The four cross-field rules every chunking-config write must satisfy. + * + * Applied through a function rather than baked into one schema because the + * refinements make the result unextendable: a surface that needs its own + * descriptions, bounds, or strictness has to build the object first and take + * the rules afterwards. Restating them per surface is how a write path loses + * one — and the `strategyOptions.separators` bound that + * {@link chunkingStrategyOptionsSchema} carries is the difference between a + * persisted config and seconds of uninterruptible CPU on every later upload. + */ +export function withChunkingConfigRules( + schema: z.ZodType +): z.ZodType { + return schema + .refine((data) => data.minSize < data.maxSize * 4, { + message: 'Min chunk size (characters) must be less than max chunk size (tokens × 4)', + }) + .refine((data) => data.overlap < data.maxSize, { + message: 'Overlap must be less than max chunk size', + }) + .refine( + (data) => data.strategy !== 'regex' || typeof data.strategyOptions?.pattern === 'string', + { + message: 'Regex pattern is required when using the regex chunking strategy', + } + ) + .refine( + (data) => data.strategy === 'regex' || data.strategyOptions?.strictBoundaries !== true, + { + message: 'strictBoundaries is only valid for the regex chunking strategy', + } + ) +} + +export const chunkingConfigSchema = withChunkingConfigRules(chunkingConfigFieldsSchema) export const createKnowledgeBaseBodySchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -145,7 +182,7 @@ const knowledgeChunkingConfigSchema = z maxSize: z.number(), minSize: z.number(), overlap: z.number(), - strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).optional(), + strategy: chunkingStrategySchema.optional(), strategyOptions: storedChunkingStrategyOptionsSchema.optional(), }) .passthrough() diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index 71677a738cb..ac7ded33844 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -46,8 +46,39 @@ export const logDetailQuerySchema = z.object({ workspaceId: z.string().min(1), }) +/** + * Largest number of time buckets the dashboard stats read will build. + * + * The bound is load-bearing, not cosmetic. `segmentCount` reaches + * `buildDashboardStats` as the length of two densely materialized arrays — one + * per workflow, one for the workspace aggregate — so an unbounded value + * allocates without limit: `1e9` is a genuine 500. The lower bound is the + * quieter half — `0` does not throw, it makes `segmentMs` `Infinity` and every + * segment array empty, which serializes as `null` and hands the dashboard a + * shaped response with nothing in it. Both were reachable from the query + * string. + */ +export const MAX_STATS_SEGMENT_COUNT = 500 + +/** + * Largest number of per-workflow series the dashboard stats read will return. + * + * `workflows` carries one entry per workflow that ran in the window, each with + * `segmentCount` segments, so the response grows with the workspace rather than + * with anything the caller asked for. Entries past the cap are dropped from + * `workflows` only — the workspace aggregates are computed from every row first, + * so the totals stay exact — and the truncation is reported rather than silent. + */ +export const MAX_STATS_WORKFLOWS = 200 + export const statsQueryParamsSchema = logFilterQuerySchema.extend({ - segmentCount: z.coerce.number().optional().default(72), + segmentCount: z.coerce + .number() + .int('segmentCount must be a whole number') + .min(1, 'segmentCount must be at least 1') + .max(MAX_STATS_SEGMENT_COUNT, `segmentCount cannot exceed ${MAX_STATS_SEGMENT_COUNT}`) + .optional() + .default(72), }) const workflowSummarySchema = z diff --git a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts index 9d1a27be644..99e13f847f8 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts @@ -146,7 +146,7 @@ describe('tables nested strictness', () => { * parameter got a 200 for a request the server never honoured — and on * `POST /knowledge/search` the stripped keys were the ones that decide how many * search units the call is billed. The strictness was already there on - * `GET /knowledge/{id}/tags`, which is what made the divergence visible: + * `GET /knowledge/{knowledgeBaseId}/tags`, which is what made the divergence visible: * `?foo=1` was a 400 on that one route and a 200 on its siblings. * * Only `query` and `body` are swept. `params` are produced by the router from @@ -182,11 +182,16 @@ describe('knowledge and files request-slice strictness', () => { * A count, so a document that stopped listing its routes cannot make every * assertion below pass vacuously. It rises when a route gains a slice: it went * 45 → 63 when the knowledge and files endpoints that take no query params - * started saying so with `noInputSchema` instead of omitting `query`, then - * 63 → 75 when knowledge connector management joined the documented surface. + * started saying so with `noInputSchema` instead of omitting `query`, and + * 63 → 87 with the knowledge chunk, tag-write, archive, restore, and + * workspace-file-ingest operations, and 87 → 95 with the file upload-session + * read, archive extraction, file-text read, folder restore, bulk zip + * download, and permanent delete. It falls when two routes become one: 106 → + * 105 when the archived knowledge-base list folded into `GET /knowledge` as + * `scope=archived`. */ it('sweeps every documented query and body slice', () => { - expect(slices.length).toBe(75) + expect(slices.length).toBe(105) }) it.each(slices)('%s rejects an undeclared key', (_name, schema) => { diff --git a/apps/sim/lib/api/contracts/v2/__tests__/document-tag-slots.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/document-tag-slots.test.ts index 4d0a621e63e..2e8535cdb5f 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/document-tag-slots.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/document-tag-slots.test.ts @@ -5,7 +5,7 @@ import { v2UpdateKnowledgeDocumentBodySchema } from '@/lib/api/contracts/v2/know const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' describe('v2 document update tag slots', () => { - it('accepts every slot GET /knowledge/{id}/tags can advertise', () => { + it('accepts every slot GET /knowledge/{knowledgeBaseId}/tags can advertise', () => { for (const [field, value] of [ ['tag1', 'billing'], ['number1', 7], diff --git a/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts index 6392696edb3..ba36e1c4d27 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts @@ -180,3 +180,103 @@ describe('v2 knowledge document list query', () => { expect(issueMessages(parsed as never)).toContain(message) }) }) + +/** + * The write used to accept only `maxSize`/`minSize`/`overlap` while the read + * projected five keys, so a `strategy` the workflow builder set was readable and + * unwritable — and a three-key update replaced the stored object wholesale and + * dropped it. + */ +describe('v2 knowledge chunking configuration', () => { + const body = v2CreateKnowledgeBaseContract.body + + function parseChunkingConfig(chunkingConfig: unknown) { + return body?.safeParse({ workspaceId: 'ws-1', name: 'Docs', chunkingConfig }) + } + + it('accepts the strategy and strategy options the read projects', () => { + const parsed = parseChunkingConfig({ + maxSize: 1024, + minSize: 100, + overlap: 200, + strategy: 'regex', + strategyOptions: { pattern: '\\n\\n', strictBoundaries: true }, + }) + + expect(parsed?.success).toBe(true) + }) + + it('still accepts a body naming only one of the numbers', () => { + expect(parseChunkingConfig({ maxSize: 2048 })?.success).toBe(true) + }) + + it('requires a pattern for the regex strategy', () => { + const parsed = parseChunkingConfig({ maxSize: 1024, minSize: 100, strategy: 'regex' }) + + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never)).toContain( + 'Regex pattern is required when using the regex chunking strategy' + ) + }) + + it('rejects strict boundaries outside the regex strategy', () => { + const parsed = parseChunkingConfig({ + maxSize: 1024, + strategy: 'recursive', + strategyOptions: { strictBoundaries: true }, + }) + + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never)).toContain( + 'strictBoundaries is only valid for the regex chunking strategy' + ) + }) + + /** + * The bound is load-bearing rather than cosmetic: the recursive chunker + * rescans the whole document once per separator, synchronously, so an + * unbounded list turns one persisted config into seconds of uninterruptible + * CPU on every later upload. + */ + it('bounds the separator list the recursive chunker rescans per entry', () => { + const parsed = parseChunkingConfig({ + maxSize: 1024, + strategyOptions: { separators: Array.from({ length: 200 }, () => '\n') }, + }) + + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never).join(' ')).toContain('separators') + }) + + it('rejects an unknown chunking key rather than dropping it', () => { + expect(parseChunkingConfig({ maxSize: 1024, mode: 'fast' })?.success).toBe(false) + }) + + /** + * The response stays lenient in both directions. `knowledge_base.chunking_config` + * is schemaless JSONB, so a legacy row carrying a retired key would fail a + * strict response parse and reach the caller as a 500. + */ + it('keeps the response tolerant of a stored key the input rejects', () => { + const response = v2CreateKnowledgeBaseContract.response.schema.safeParse({ + data: { + id: 'kb-1', + name: 'Docs', + description: null, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200, retiredKnob: true }, + docCount: 0, + connectorTypes: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ownerEmail: 'owner@example.com', + folderPath: '/', + deletedAt: null, + }, + }) + + expect(response.success).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 14e0b57481f..dd218c30302 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -47,24 +47,29 @@ import { const PAGED_LISTS = [ 'GET /api/v2/audit-logs', 'GET /api/v2/billing/logs', + 'GET /api/v2/blocks', + 'GET /api/v2/chat-deployments', 'GET /api/v2/credentials', 'GET /api/v2/custom-tools', 'GET /api/v2/files', 'GET /api/v2/knowledge', - 'GET /api/v2/knowledge/[id]/connectors', - 'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents', - 'GET /api/v2/knowledge/[id]/documents', + 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors', + 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents', + 'GET /api/v2/knowledge/[knowledgeBaseId]/documents', + 'GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', 'GET /api/v2/logs', 'GET /api/v2/mcp-servers', 'GET /api/v2/secrets', 'GET /api/v2/skills', - 'GET /api/v2/skills/[id]/editors', + 'GET /api/v2/skills/[skillId]/editors', 'GET /api/v2/tables', 'GET /api/v2/tables/[tableId]/rows', 'POST /api/v2/tables/[tableId]/query', + 'GET /api/v2/tools', 'GET /api/v2/workflows', - 'GET /api/v2/workflows/[id]/runs', - 'GET /api/v2/workflows/[id]/versions', + 'GET /api/v2/workflows/[workflowId]/runs', + 'GET /api/v2/workflows/[workflowId]/versions', + 'GET /api/v2/workflow-mcp-servers', 'GET /api/v2/workspaces/[workspaceId]/members', 'GET /api/v2/workspaces', ] as const @@ -84,16 +89,29 @@ const PAGED_LISTS = [ * not appear here. * - The credential-provider catalog is bounded by the code-defined OAuth and * service-account registries. - * - A knowledge base has a fixed number of tag slots, so its tag vocabulary - * cannot grow past them. + * - The connector-type catalog is bounded the same way, by the code-defined + * connector-meta registry. The block and tool + * catalogs are NOT — a workspace adds blocks by deploying workflows as blocks, + * and there are ~5,000 tool ids — which is why those two are paged and do not + * appear here. + * - A knowledge base has a fixed number of tag slots, so neither its tag + * vocabulary nor the usage counts derived from it can grow past them. * - A table's saved views and its dispatchable groups are capped per table. + * - A table's ACTIVE run dispatches are capped by the dispatcher itself: it + * keeps at most a handful in flight per table and cancels the rest, so the + * set cannot grow with a workspace's size. Settled dispatches are read by id, + * never listed. */ const FULL_SET_LISTS = [ + 'GET /api/v2/connector-types', 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', - 'GET /api/v2/knowledge/[id]/tags', + 'GET /api/v2/knowledge/[knowledgeBaseId]/tags', + 'GET /api/v2/knowledge/[knowledgeBaseId]/tags/usage', 'GET /api/v2/knowledge/folders', - 'GET /api/v2/mcp-servers/[id]/tools', + 'GET /api/v2/mcp-servers/[mcpServerId]/tools', + 'GET /api/v2/workflow-mcp-servers/[serverId]/tools', + 'GET /api/v2/tables/[tableId]/dispatches', 'GET /api/v2/tables/[tableId]/groups', 'GET /api/v2/tables/[tableId]/views', 'GET /api/v2/tables/folders', @@ -134,6 +152,15 @@ const CURSOR_BINDINGS: Record = { 'endDate', ], 'GET /api/v2/billing/logs': ['source', 'workspaceId', 'period', 'startDate', 'endDate'], + 'GET /api/v2/blocks': [ + 'workspaceId', + 'search', + 'category', + 'capability', + 'source', + 'sortBy', + 'sortOrder', + ], 'GET /api/v2/credentials': ['workspaceId', 'type', 'providerId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/custom-tools': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/files': [ @@ -146,13 +173,17 @@ const CURSOR_BINDINGS: Record = { /** Decides whether `folderPath` covers one folder or its whole subtree. */ 'recursive', ], - 'GET /api/v2/knowledge': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], - 'GET /api/v2/knowledge/[id]/connectors': ['workspaceId', 'sortBy', 'sortOrder'], - 'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents': [ + 'GET /api/v2/knowledge': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors': ['workspaceId', 'sortBy', 'sortOrder'], + 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents': [ 'workspaceId', 'includeExcluded', ], - 'GET /api/v2/knowledge/[id]/documents': [ + 'GET /api/v2/knowledge/[knowledgeBaseId]/documents': [ + // Asserted scope rather than a filter, but this list shipped before the + // distinction was drawn. The value is constant for any one sequence, so + // keeping it costs nothing; removing it would refuse every cursor already + // in flight. The chunks list below is new, so it starts out unbound. 'workspaceId', 'enabledFilter', 'search', @@ -160,6 +191,12 @@ const CURSOR_BINDINGS: Record = { 'sortBy', 'sortOrder', ], + 'GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks': [ + 'enabled', + 'search', + 'sortBy', + 'sortOrder', + ], 'GET /api/v2/logs': [ 'workspaceId', 'workflowIds', @@ -174,25 +211,41 @@ const CURSOR_BINDINGS: Record = { 'maxCost', 'model', 'folderPaths', - 'order', + 'sortBy', + 'sortOrder', + 'status', + 'workflowName', + /** Decides whether the job-run branch is part of the sequence at all. */ + 'includeJobRuns', ], 'GET /api/v2/mcp-servers': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/secrets': ['workspaceId', 'scope', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/skills': ['workspaceId', 'search', 'sortBy', 'sortOrder'], - 'GET /api/v2/skills/[id]/editors': ['workspaceId', 'sortBy', 'sortOrder'], - 'GET /api/v2/tables': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/tables': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/skills/[skillId]/editors': ['workspaceId', 'sortBy', 'sortOrder'], 'GET /api/v2/tables/[tableId]/rows': [], 'POST /api/v2/tables/[tableId]/query': ['predicate', 'sort'], + 'GET /api/v2/tools': [ + 'workspaceId', + 'search', + 'hostedApiKey', + 'oauthProvider', + 'sortBy', + 'sortOrder', + ], 'GET /api/v2/workflows': [ 'workspaceId', 'folderPath', + 'scope', 'deployedOnly', 'search', 'sortBy', 'sortOrder', ], - 'GET /api/v2/workflows/[id]/runs': ['status', 'trigger', 'startDate', 'endDate', 'order'], - 'GET /api/v2/workflows/[id]/versions': [], + 'GET /api/v2/workflows/[workflowId]/runs': ['status', 'trigger', 'startDate', 'endDate', 'order'], + 'GET /api/v2/workflows/[workflowId]/versions': [], + 'GET /api/v2/workflow-mcp-servers': ['workspaceId', 'sortBy', 'sortOrder'], + 'GET /api/v2/chat-deployments': ['workspaceId', 'workflowId', 'isActive', 'sortBy', 'sortOrder'], 'GET /api/v2/workspaces/[workspaceId]/members': [], 'GET /api/v2/workspaces': ['sortBy', 'sortOrder'], } @@ -204,7 +257,7 @@ const CURSOR_BINDINGS: Record = { * {@link CURSOR_BINDINGS} covers only what a contract accepts as query or body, * so a nested list's parent id is invisible to it: an empty binding there reads * the same whether the list genuinely has no filters or whether its parent was - * forgotten. Both readings were true at once — `GET /workflows/[id]/versions` + * forgotten. Both readings were true at once — `GET /workflows/[workflowId]/versions` * and `GET /workspaces/[workspaceId]/members` declared `[]`, accepted a sibling * parent's token, and answered 200 from a position in a sequence the caller * never walked. @@ -218,14 +271,21 @@ const CURSOR_BINDINGS: Record = { * resolves the path before fingerprinting it. */ const CURSOR_BOUND_PATH_PARAMS: Record = { - 'GET /api/v2/knowledge/[id]/connectors': ['id'], - 'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents': ['id', 'connectorId'], - 'GET /api/v2/knowledge/[id]/documents': ['id'], - 'GET /api/v2/skills/[id]/editors': ['id'], + 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors': ['knowledgeBaseId'], + 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents': [ + 'knowledgeBaseId', + 'connectorId', + ], + 'GET /api/v2/knowledge/[knowledgeBaseId]/documents': ['knowledgeBaseId'], + 'GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks': [ + 'knowledgeBaseId', + 'documentId', + ], + 'GET /api/v2/skills/[skillId]/editors': ['skillId'], 'GET /api/v2/tables/[tableId]/rows': ['tableId'], 'POST /api/v2/tables/[tableId]/query': ['tableId'], - 'GET /api/v2/workflows/[id]/runs': ['id'], - 'GET /api/v2/workflows/[id]/versions': ['id'], + 'GET /api/v2/workflows/[workflowId]/runs': ['workflowId'], + 'GET /api/v2/workflows/[workflowId]/versions': ['workflowId'], 'GET /api/v2/workspaces/[workspaceId]/members': ['workspaceId'], } @@ -240,6 +300,10 @@ const CURSOR_BOUND_PATH_PARAMS: Record = { * correctness gain. */ const UNBOUND_PARAMS: Record> = { + 'GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks': { + workspaceId: + 'Asserted scope, not a filter: the sequence is one document, named by the path. A mismatched workspace is refused by authorization before paging.', + }, 'GET /api/v2/logs': { details: 'Selects how much of each row is rendered, not which rows are in the sequence.', includeTraceSpans: 'Response shaping only; the row set and its order are unchanged.', @@ -248,10 +312,12 @@ const UNBOUND_PARAMS: Record> = { 'GET /api/v2/tables/[tableId]/rows': { workspaceId: 'Asserted scope, not a filter: the sequence is one table, named by the path. A mismatched workspace is refused by authorization before paging.', + includeRunState: 'Response shaping only; the row set and its order are unchanged.', }, 'POST /api/v2/tables/[tableId]/query': { workspaceId: 'Asserted scope, not a filter: the sequence is one table, named by the path. A mismatched workspace is refused by authorization before paging.', + includeRunState: 'Response shaping only; the row set and its order are unchanged.', }, } diff --git a/apps/sim/lib/api/contracts/v2/__tests__/logs-stats.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/logs-stats.test.ts new file mode 100644 index 00000000000..2e29ce72eb7 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/logs-stats.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { statsQueryParamsSchema } from '@/lib/api/contracts/logs' +import { v2LogStatsQuerySchema } from '@/lib/api/contracts/v2/logs-stats' + +const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' + +/** + * `segmentCount` reaches the aggregator as an array length and as a divisor, so + * every value the boundary lets through has to be a whole number inside the + * cap. Each case below produced a 500 before the bounds landed: `0` divided by + * zero, `1e9` allocated two billion-element arrays, and a fractional value + * indexed between buckets. + */ +describe.each([ + ['the public contract', v2LogStatsQuerySchema], + ['the first-party contract', statsQueryParamsSchema], +])('%s bounds segmentCount', (_label, schema) => { + it('rejects zero, which would divide by zero deriving the bucket width', () => { + expect(schema.safeParse({ workspaceId: WORKSPACE_ID, segmentCount: '0' }).success).toBe(false) + }) + + it('rejects a fractional count, which indexes between buckets', () => { + expect(schema.safeParse({ workspaceId: WORKSPACE_ID, segmentCount: '1.5' }).success).toBe(false) + }) + + it('rejects a count that would allocate unbounded arrays', () => { + expect(schema.safeParse({ workspaceId: WORKSPACE_ID, segmentCount: '1e9' }).success).toBe(false) + }) + + it('rejects a negative count', () => { + expect(schema.safeParse({ workspaceId: WORKSPACE_ID, segmentCount: '-1' }).success).toBe(false) + }) + + it('accepts the bounds themselves and defaults when omitted', () => { + expect(schema.safeParse({ workspaceId: WORKSPACE_ID, segmentCount: '1' }).success).toBe(true) + expect(schema.safeParse({ workspaceId: WORKSPACE_ID, segmentCount: '500' }).success).toBe(true) + + const defaulted = schema.parse({ workspaceId: WORKSPACE_ID }) + expect(defaulted.segmentCount).toBe(72) + }) +}) + +describe('v2LogStatsQuerySchema', () => { + it('names the failing field and the bound', () => { + const parsed = v2LogStatsQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + segmentCount: '501', + }) + + expect(parsed.success).toBe(false) + expect(parsed.error?.issues[0].message).toBe('segmentCount cannot exceed 500') + }) + + it('rejects an unknown query param rather than silently dropping it', () => { + expect(v2LogStatsQuerySchema.safeParse({ workspaceId: WORKSPACE_ID, bogus: '1' }).success).toBe( + false + ) + }) + + it('rejects an inverted date window instead of answering an empty summary', () => { + const parsed = v2LogStatsQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + startDate: '2026-02-01T00:00:00Z', + endDate: '2026-01-01T00:00:00Z', + }) + + expect(parsed.success).toBe(false) + expect(parsed.error?.issues[0].message).toBe('startDate must be before or equal to endDate') + }) + + it('normalizes folder paths and rejects an empty entry', () => { + expect( + v2LogStatsQuerySchema.parse({ workspaceId: WORKSPACE_ID, folderPaths: '/prod' }).folderPaths + ).toBe('/prod') + expect( + v2LogStatsQuerySchema.safeParse({ workspaceId: WORKSPACE_ID, folderPaths: '/prod,' }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts index d053463c45e..c3757ac6833 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts @@ -13,7 +13,7 @@ import { rejectsUnknownKeys } from '@/lib/api/contracts/v2/__tests__/schema-intr * * `parseRequest` validates the query slice only when the contract declares one * (`contract.query ? validate : skip`). A contract with no `query` therefore - * never validates the query string at all: `GET /api/v2/workflows/{id}?bogus=1` + * never validates the query string at all: `GET /api/v2/workflows/{workflowId}?bogus=1` * answered 200 while every v2 list answered 400 for the same shape. The caller * learns nothing about the param the server ignored, which is the failure the * lists' `.strict()` rule already exists to prevent — a request the server did diff --git a/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts index 7a97aab0d1e..5590dc9f449 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts @@ -14,7 +14,7 @@ import type { OpenApiDocumentDefinition } from '@/lib/api/openapi/types' * * `runCount` is a monotonic column on the workflow row, incremented only for a * run that finished successfully and was not left paused, and never decremented - * by log retention. `GET /workflows/{id}/runs` reads the execution-log table, + * by log retention. `GET /workflows/{workflowId}/runs` reads the execution-log table, * which lists every recorded run *and* is hard-deleted on the workspace's * retention window. The two therefore disagree in both directions, and each * operation has to say so where a caller reads it. diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index 54defc303fa..456456365e3 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -12,25 +12,34 @@ import * as tableContracts from '@/lib/api/contracts/v2/tables' import { V2_TABLE_IMPORT_OPTIONS_MAX_BYTES, v2AddWorkflowGroupBodySchema, + v2ApiRowSchema, v2ApiTableSchema, + v2BulkDeleteTablesBodySchema, + v2BulkUpdateRowsBodySchema, v2CreateTableBodySchema, v2CreateTableColumnBodySchema, v2CreateTableImportBodySchema, v2CreateTableRowsBodySchema, v2CsvImportCreateColumnsSchema, v2CsvImportMappingSchema, - v2FindRowsBodySchema, - v2FindRowsDataSchema, + v2GetTableDispatchContract, v2GetTableImportContract, + v2GetTableRowQuerySchema, + v2ListTablesQuerySchema, + v2MoveTablesBodySchema, v2QueryRowsBodySchema, v2QueryRowsCountBodySchema, + v2RestoreTableContract, + v2SearchRowsBodySchema, + v2SearchRowsDataSchema, v2TableImportStatusSchema, + v2TableRowsQuerySchema, v2TableUploadImportSourceSchema, v2UpdateTableColumnBodySchema, v2UpdateWorkflowGroupBodySchema, } from '@/lib/api/contracts/v2/tables' import { getValidationErrorMessage } from '@/lib/api/server/validation' -import { MAX_RUN_TARGET_ROW_IDS, TABLE_LIMITS } from '@/lib/table/constants' +import { MAX_RUN_TARGET_ROW_IDS, MAX_TABLE_BATCH_ITEMS, TABLE_LIMITS } from '@/lib/table/constants' import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -438,21 +447,22 @@ describe('v2 table import lifecycle surface', () => { * surprise, or not at all. */ describe('v2 table request bounds', () => { - const findBody = { workspaceId: WORKSPACE_ID, q: 'x' } + const searchBody = { workspaceId: WORKSPACE_ID, q: 'x' } - it('caps the Find search term at the shared v2 search length', () => { + it('caps the Search search term at the shared v2 search length', () => { expect( - v2FindRowsBodySchema.safeParse({ ...findBody, q: 'a'.repeat(V2_SEARCH_MAX_LENGTH) }).success + v2SearchRowsBodySchema.safeParse({ ...searchBody, q: 'a'.repeat(V2_SEARCH_MAX_LENGTH) }) + .success ).toBe(true) expect( - v2FindRowsBodySchema.safeParse({ ...findBody, q: 'a'.repeat(V2_SEARCH_MAX_LENGTH + 1) }) + v2SearchRowsBodySchema.safeParse({ ...searchBody, q: 'a'.repeat(V2_SEARCH_MAX_LENGTH + 1) }) .success ).toBe(false) }) - it('publishes the Find match cap the truncated flag is derived from', () => { + it('publishes the Search match cap the truncated flag is derived from', () => { expect( - v2FindRowsDataSchema.safeParse({ + v2SearchRowsDataSchema.safeParse({ matches: Array.from({ length: TABLE_LIMITS.MAX_FIND_MATCHES + 1 }, () => ({ ordinal: 0, rowId: 'row-1', @@ -461,7 +471,7 @@ describe('v2 table request bounds', () => { truncated: true, }).success ).toBe(false) - expect(JSON.stringify(z.toJSONSchema(v2FindRowsDataSchema))).toContain( + expect(JSON.stringify(z.toJSONSchema(v2SearchRowsDataSchema))).toContain( String(TABLE_LIMITS.MAX_FIND_MATCHES) ) }) @@ -518,3 +528,285 @@ describe('v2 table request bounds', () => { expect(published).toContain('isEmpty') }) }) + +describe('v2 table run dispatch contract', () => { + const DISPATCH = { + id: 'dispatch-1', + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + status: 'dispatching', + mode: 'all', + scope: { groupIds: ['group-1'] }, + limit: null, + processedCount: 0, + isManualRun: true, + requestedAt: '2026-01-01T00:00:00.000Z', + completedAt: null, + canceledAt: null, + } + + /** + * The whole point of declaring this enum rather than reusing the first-party + * active-dispatch one: v2 response schemas are parsed on the way out, so a + * status set that stopped at the in-flight states would make polling a run to + * completion — the only reason to poll — a 500. + */ + it.each(['pending', 'dispatching', 'complete', 'canceled'] as const)( + 'publishes %s as a readable dispatch status', + (status) => { + expect( + v2GetTableDispatchContract.response.schema.safeParse({ + data: { ...DISPATCH, status }, + }).success + ).toBe(true) + } + ) + + it('rejects a status outside the column domain', () => { + expect( + v2GetTableDispatchContract.response.schema.safeParse({ + data: { ...DISPATCH, status: 'finished' }, + }).success + ).toBe(false) + }) + + it('does not publish the scheduler cursor, which a caller would read as a page token', () => { + const parsed = v2GetTableDispatchContract.response.schema.parse({ + data: { ...DISPATCH, cursor: 42 }, + }) + expect(parsed.data).not.toHaveProperty('cursor') + }) +}) + +describe('v2 opt-in row run state', () => { + it('omits runState from a row by default', () => { + const parsed = v2ApiRowSchema.parse({ + id: 'row-1', + data: { name: 'Ada' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + expect(parsed).not.toHaveProperty('runState') + }) + + it('defaults includeRunState off on every read that accepts it', () => { + expect(v2TableRowsQuerySchema.parse({ workspaceId: WORKSPACE_ID }).includeRunState).toBe(false) + expect(v2QueryRowsBodySchema.parse({ workspaceId: WORKSPACE_ID }).includeRunState).toBe(false) + expect(v2GetTableRowQuerySchema.parse({ workspaceId: WORKSPACE_ID }).includeRunState).toBe( + false + ) + }) + + it('coerces the querystring spelling of the flag rather than demanding an enum', () => { + expect( + v2TableRowsQuerySchema.parse({ workspaceId: WORKSPACE_ID, includeRunState: '1' }) + .includeRunState + ).toBe(true) + }) + + it('carries every published run-state field, including a terminal cancellation', () => { + const parsed = v2ApiRowSchema.parse({ + id: 'row-1', + data: {}, + runState: { + 'group-1': { + status: 'canceled', + executionId: null, + workflowId: 'workflow-1', + error: null, + runningBlockIds: [], + blockErrors: {}, + canceledAt: '2026-01-02T00:00:00.000Z', + }, + }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + expect(parsed.runState?.['group-1'].status).toBe('canceled') + }) + + /** + * `limit: 0` is the unbounded form. Paired with the sidecar it reads the whole + * table AND its run state before anything can refuse the result, so the pair + * is refused at the contract — the only place it costs nothing. + */ + it('refuses the unbounded query form together with the run-state sidecar', () => { + const result = v2QueryRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + limit: 0, + includeRunState: true, + }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]).toMatchObject({ + path: ['limit'], + message: expect.stringContaining('includeRunState'), + }) + }) + + it('caps the page a run-state read may ask for', () => { + expect( + v2QueryRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + limit: tableContracts.V2_MAX_RUN_STATE_ROW_LIMIT + 1, + includeRunState: true, + }).success + ).toBe(false) + expect( + v2QueryRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + limit: tableContracts.V2_MAX_RUN_STATE_ROW_LIMIT, + includeRunState: true, + }).success + ).toBe(true) + }) + + /** + * One flag, one ceiling. The list read cannot express the unbounded form, so + * it has no `limit: 0` pair to refuse — but its page cap has to match the + * query read's or a caller learns the difference from a 400. + */ + it('caps the list read at the same page size as the query read', () => { + expect( + v2TableRowsQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + limit: tableContracts.V2_MAX_RUN_STATE_ROW_LIMIT + 1, + includeRunState: 'true', + }).success + ).toBe(false) + expect( + v2TableRowsQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + limit: tableContracts.V2_MAX_RUN_STATE_ROW_LIMIT, + includeRunState: 'true', + }).success + ).toBe(true) + }) + + it('leaves a full-size list page alone without the sidecar', () => { + expect( + v2TableRowsQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + limit: tableContracts.V2_MAX_ROW_LIMIT, + }).success + ).toBe(true) + }) + + it('leaves the unbounded and full-size page forms alone without the sidecar', () => { + expect(v2QueryRowsBodySchema.safeParse({ workspaceId: WORKSPACE_ID, limit: 0 }).success).toBe( + true + ) + expect( + v2QueryRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + limit: tableContracts.V2_MAX_ROW_LIMIT, + }).success + ).toBe(true) + }) +}) + +describe('v2 bulk row update contract', () => { + it('refuses an empty bulk update with a message naming the field', () => { + const parsed = v2BulkUpdateRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + updates: [], + }) + expect(parsed.success).toBe(false) + expect(getValidationErrorMessage(parsed.error!, '')).toContain( + 'updates must contain at least one row' + ) + }) + + /** + * The domain backstop sits at the looser Copilot ceiling, so this contract is + * the only thing that tells a v2 caller the bound that actually applies to + * it. The message has to name that number, not the backstop's. + */ + it('refuses a bulk update past the bulk ceiling, naming the ceiling it enforces', () => { + const parsed = v2BulkUpdateRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + updates: Array.from( + { length: TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + 1 }, + (_unused, index) => ({ rowId: `row-${index}`, data: {} }) + ), + }) + expect(parsed.success).toBe(false) + expect(getValidationErrorMessage(parsed.error!, '')).toContain( + `Cannot update more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per batch` + ) + }) + + /** + * Two patches for one row have no defined precedence, and the primitive + * applies them in array order — so the caller's second patch silently wins. + */ + it('refuses a bulk update naming the same row twice', () => { + const parsed = v2BulkUpdateRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + updates: [ + { rowId: 'row-1', data: { name: 'Ada' } }, + { rowId: 'row-1', data: { name: 'Grace' } }, + ], + }) + expect(parsed.success).toBe(false) + expect(getValidationErrorMessage(parsed.error!, '')).toContain('Duplicate rowId') + }) +}) + +describe('v2 table archive lifecycle', () => { + it('lists active tables unless the caller asks otherwise', () => { + expect(v2ListTablesQuerySchema.parse({ workspaceId: WORKSPACE_ID }).scope).toBe('active') + }) + + it('accepts the archived scope and rejects anything else', () => { + expect( + v2ListTablesQuerySchema.parse({ workspaceId: WORKSPACE_ID, scope: 'archived' }).scope + ).toBe('archived') + expect( + v2ListTablesQuerySchema.safeParse({ workspaceId: WORKSPACE_ID, scope: 'all' }).success + ).toBe(false) + }) + + it('scopes restore to the workspace that owns the archived table', () => { + expect(v2RestoreTableContract.body?.safeParse({}).success).toBe(false) + expect(v2RestoreTableContract.body?.safeParse({ workspaceId: WORKSPACE_ID }).success).toBe(true) + }) +}) + +describe('v2 bulk table selection contracts', () => { + it('requires at least one table or folder path', () => { + const parsed = v2BulkDeleteTablesBodySchema.safeParse({ workspaceId: WORKSPACE_ID }) + expect(parsed.success).toBe(false) + expect(getValidationErrorMessage(parsed.error!, '')).toContain( + 'At least one table or folder path must be selected' + ) + }) + + it('bounds the combined selection', () => { + expect( + v2MoveTablesBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + tableIds: Array.from({ length: MAX_TABLE_BATCH_ITEMS }, (_unused, i) => `table-${i}`), + folderPaths: ['/Sales'], + targetFolderPath: '/', + }).success + ).toBe(false) + }) + + /** Omission is the root, as on `POST /api/v2/files/move`; `null` is not a second spelling. */ + it('treats an omitted destination as the workspace root and rejects an explicit null', () => { + expect( + v2MoveTablesBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + tableIds: ['table-1'], + }).success + ).toBe(true) + expect( + v2MoveTablesBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + tableIds: ['table-1'], + targetFolderPath: null, + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph-variables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph-variables.test.ts new file mode 100644 index 00000000000..e0b3db72aea --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph-variables.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { v2WorkflowVariableSchema } from '@/lib/api/contracts/v2/workflows' + +/** + * The response schema is parsed outbound, so every assertion it makes about a + * stored variable is a claim the column has to be able to honour. It cannot + * honour these two: the realtime `variable.add` op types `type` as `z.any()`, + * and the variables parser writes `name` through verbatim. Declaring the input + * bounds here would turn a stored workflow into a 500 on the read that opens it. + */ +describe('stored workflow variable response schema', () => { + it('coerces a stored type outside the published enum instead of throwing', () => { + const parsed = v2WorkflowVariableSchema.parse({ + id: 'var-1', + name: 'region', + type: 'not-a-type', + value: 'eu', + }) + expect(parsed.type).toBe('string') + }) + + it('accepts an empty stored name', () => { + expect(() => + v2WorkflowVariableSchema.parse({ id: 'var-1', name: '', type: 'string', value: null }) + ).not.toThrow() + }) + + it('accepts a stored name past the input bound', () => { + expect(() => + v2WorkflowVariableSchema.parse({ + id: 'var-1', + name: 'x'.repeat(300), + type: 'string', + value: null, + }) + ).not.toThrow() + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts new file mode 100644 index 00000000000..9b2114581f3 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts @@ -0,0 +1,236 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + v2ApplyWorkflowOperationsDataSchema, + v2ReplaceWorkflowStateBodySchema, + v2WorkflowGraphSchema, +} from '@/lib/api/contracts/v2/workflows' +import { WORKFLOW_SKIPPED_ITEM_TYPES } from '@/lib/workflows/editing/types' + +const STORED_GRAPH = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { 'sub-1': { id: 'sub-1', type: 'oauth-input', value: 'credential-1' } }, + outputs: { result: { type: 'string' } }, + enabled: true, + horizontalHandles: true, + height: 0, + data: { parentId: 'loop-1', extent: 'parent' }, + /** A stored key this surface does not publish. */ + layout: { measured: true }, + }, + }, + edges: [ + { + id: 'edge-1', + source: 'block-1', + target: 'block-2', + sourceHandle: null, + targetHandle: null, + /** Reactflow rendering members the stored row carries. */ + animated: true, + style: { stroke: '#000' }, + }, + ], + loops: { + 'loop-1': { id: 'loop-1', nodes: ['block-1'], iterations: 3, loopType: 'for', enabled: true }, + }, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'region', + type: 'string', + value: 'eu', + /** Server-stamped for the client's global variables store; not part of this surface. */ + workflowId: 'workflow-1', + }, + }, +} + +describe('the read shape tolerates what the write path never bounded', () => { + /** + * `workflow_blocks.name` and `.type` are bare `text()`, and the realtime + * rename op accepts `z.string()`, so a block renamed past 255 characters on + * the canvas is legal stored data. Asserting the input bound on the way out + * made that workflow a `500` on the one endpoint that opens it — and since + * `PUT /state` needs the `GET` round trip, unrepairable over v2. + */ + it('reads a block whose stored name exceeds the write-side bound', () => { + const longName = 'x'.repeat(300) + const graph = structuredClone(STORED_GRAPH) + graph.blocks['block-1'].name = longName + + expect(v2WorkflowGraphSchema.parse(graph).blocks['block-1'].name).toBe(longName) + }) + + it('still holds a write to the bound', () => { + const body = { + workspaceId: 'workspace-1', + blocks: { 'block-1': { ...STORED_GRAPH.blocks['block-1'], name: 'x'.repeat(300) } }, + edges: [], + } + + expect(v2ReplaceWorkflowStateBodySchema.safeParse(body).success).toBe(false) + }) +}) + +describe('v2WorkflowGraphSchema', () => { + /** + * A v2 response schema is `.parse`d on the way out, so a stored member the + * surface has not published must be stripped rather than rejected — a throw + * here would be a 500 on a plain read. + */ + it('canonicalizes a stored graph instead of rejecting its unpublished members', () => { + const parsed = v2WorkflowGraphSchema.parse(STORED_GRAPH) + + expect(parsed.blocks['block-1']).not.toHaveProperty('layout') + expect(parsed.edges[0]).not.toHaveProperty('animated') + expect(parsed.variables['var-1']).not.toHaveProperty('workflowId') + expect(parsed.blocks['block-1'].subBlocks['sub-1'].value).toBe('credential-1') + expect(parsed.loops['loop-1'].iterations).toBe(3) + }) + + /** The round trip has to close: what the read emits is what the write accepts. */ + it('accepts its own output as a replacement body', () => { + const parsed = v2WorkflowGraphSchema.parse(STORED_GRAPH) + + expect(v2ReplaceWorkflowStateBodySchema.safeParse(parsed).success).toBe(true) + }) + + it('rejects an unknown top-level member on the write body', () => { + const parsed = v2WorkflowGraphSchema.parse(STORED_GRAPH) + + expect( + v2ReplaceWorkflowStateBodySchema.safeParse({ ...parsed, lastSaved: Date.now() }).success + ).toBe(false) + }) +}) + +/** An empty report carrying every field the lint schema requires. */ +const EMPTY_LINT = { + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + fieldIssues: [], + unresolvedReferences: [], + notes: [], +} + +/** A report exercising every finding kind the lint schema publishes. */ +const FULL_LINT = { + sources: [{ blockId: 'block-1', blockName: 'Start', blockType: 'starter' }], + sinks: [{ blockId: 'block-2', blockName: 'Triage', blockType: 'agent' }], + orphanBlocks: [{ blockId: 'block-3', blockName: null, blockType: null }], + emptyOutgoingPorts: [ + { + blockId: 'loop-1', + blockName: 'Loop', + blockType: 'loop', + handle: 'loop-start-source', + label: 'loop-start-source', + }, + ], + invalidBranchPorts: [ + { + blockId: 'cond-1', + blockName: 'Check', + blockType: 'condition', + sourceHandle: 'condition-gone', + reason: 'No such branch', + }, + ], + invalidConnectionTargets: [ + { + sourceBlockId: 'block-1', + sourceBlockName: 'Start', + sourceHandle: null, + targetBlockId: 'block-9', + reason: 'Target is inside a container', + }, + ], + fieldIssues: [ + { + blockId: 'block-2', + blockName: 'Triage', + blockType: 'agent', + missingRequiredFields: ['systemPrompt'], + inactiveModeValues: [ + { + canonicalId: 'model', + activeMemberId: 'model', + inactiveMemberId: 'modelAdvanced', + kind: 'other', + }, + ], + }, + ], + unresolvedReferences: [ + { + blockId: 'block-2', + blockName: 'Triage', + blockType: 'agent', + field: 'credential', + value: 'cred-9', + kind: 'credential', + reason: 'Not accessible', + }, + ], + notes: ['lint note'], +} + +describe('v2ApplyWorkflowOperationsDataSchema', () => { + /** The published skip vocabulary is the engine's, so a new reason cannot ship undocumented. */ + it('publishes every reason the engine can decline an operation for', () => { + for (const type of WORKFLOW_SKIPPED_ITEM_TYPES) { + const result = v2ApplyWorkflowOperationsDataSchema.safeParse({ + id: 'workflow-1', + warnings: [], + needsRedeployment: false, + applied: 0, + skipped: [{ type, operationType: 'add', blockId: 'block-1', reason: 'because' }], + deferred: [], + inputValidationErrors: [], + mintedBlockIds: {}, + lint: EMPTY_LINT, + dryRun: false, + }) + expect(result.success, `skip type ${type} is not published`).toBe(true) + } + }) + + /** + * The lint report is what a headless builder acts on. Publishing only + * `unresolvedReferences` dropped ~75% of it, `fieldIssues` — the blocks + * missing a required field — most of all. + */ + it('publishes every field of the lint report the use case produces', () => { + const result = v2ApplyWorkflowOperationsDataSchema.safeParse({ + id: 'workflow-1', + warnings: [], + needsRedeployment: false, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + mintedBlockIds: {}, + lint: FULL_LINT, + dryRun: false, + }) + + expect(result.error?.issues ?? []).toEqual([]) + // The response schema is `.parse`d on the way out, so a field it does not + // declare is silently stripped rather than rejected. Assert the parsed + // output, not just that the input was accepted. + expect(result.data?.lint).toEqual(FULL_LINT) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts index ebca7ba3915..6246db1ee10 100644 --- a/apps/sim/lib/api/contracts/v2/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -56,10 +56,19 @@ export const v2AuditLogEntrySchema = z .string() .describe('Type of resource affected by the action.') .meta({ examples: ['file'] }), - resourceId: z.string().nullable().describe('Identifier of the affected resource.'), + resourceId: z + .string() + .nullable() + .describe( + 'Identifier of the affected resource. Always null when `resourceType` is `folder`: folders are addressed by canonical path on this API, so their internal identifiers are withheld rather than published as an id no other endpoint accepts.' + ), resourceName: z.string().nullable().describe('Display name of the affected resource.'), description: z.string().nullable().describe('Human-readable description of the action.'), - metadata: z.unknown().describe('Arbitrary per-action JSON metadata.'), + metadata: z + .unknown() + .describe( + 'Arbitrary per-action JSON metadata. Internal folder identifiers are stripped at every nesting level, for the same reason `resourceId` is null on a folder entry.' + ), createdAt: z .string() .describe('ISO 8601 timestamp when the action occurred.') @@ -94,7 +103,7 @@ export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema * accepts partial and locale-dependent forms whose meaning varies by * runtime. Both bounds are turned into `Date`s before they reach the query, * so the strict UTC form is what keeps an unrepresentable value a 400 - * instead of a driver-level 500. `GET /logs` and `GET /workflows/{id}/runs` + * instead of a driver-level 500. `GET /logs` and `GET /workflows/{workflowId}/runs` * already share it, and an audit trail is read alongside them. */ startDate: v2RunWindowBoundSchema('startDate').optional(), @@ -118,8 +127,8 @@ export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema }) .strict() -export const v2AuditLogParamsSchema = v1AuditLogParamsSchema.extend({ - id: v1AuditLogParamsSchema.shape.id.describe('Audit-log entry identifier.'), +export const v2AuditLogParamsSchema = v1AuditLogParamsSchema.omit({ id: true }).extend({ + auditLogId: v1AuditLogParamsSchema.shape.id.describe('Audit-log entry identifier.'), }) export const v2GetAuditLogQuerySchema = z @@ -142,7 +151,7 @@ export const v2ListAuditLogsContract = defineRouteContract({ export const v2GetAuditLogContract = defineRouteContract({ method: 'GET', - path: '/api/v2/audit-logs/[id]', + path: '/api/v2/audit-logs/[auditLogId]', params: v2AuditLogParamsSchema, query: v2GetAuditLogQuerySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index a9dc48e60e6..71f32c93e42 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -34,12 +34,15 @@ export const v2BillingStatusQuerySchema = z .object({ /** * Resolve status against one workspace's payer. A workspace-scoped API key - * is always pinned to its own workspace; passing a different id returns 403. + * is always pinned to its own workspace; passing a different id is concealed + * as `404 Workspace not found`, indistinguishable from an id that does not + * exist — the cross-tenant concealment every v2 resource read applies, not a + * 403. */ workspaceId: workspaceIdSchema .optional() .describe( - 'Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.' + 'Workspace whose payer should be resolved. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.' ), }) .strict() @@ -195,7 +198,7 @@ export const v2BillingLogsQuerySchema = z } }) /** - * Parity with `GET /logs` and `GET /workflows/{id}/runs`, which reject an + * Parity with `GET /logs` and `GET /workflows/{workflowId}/runs`, which reject an * inverted window rather than answering with the empty page an unsatisfiable * `createdAt >= start AND createdAt <= end` produces. */ diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts new file mode 100644 index 00000000000..cd7cdbfcc25 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -0,0 +1,749 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' + +/** + * v2 catalog contracts: the code-defined blocks, tools, and connector types a + * caller can build with. + * + * These read like static reference data and are not: what a caller may place is + * decided per workspace by its permission-group integration allowlist, per + * organization by which unreleased blocks have been revealed, per deployment by + * `ALLOWED_INTEGRATIONS`, and per workspace again by the workflows it has + * deployed as blocks. So every operation takes a `workspaceId` and every + * response is `Cache-Control: private, no-store` like the rest of v2 — an + * unrevealed preview block's existence must not leak across organizations + * through a shared cache. + * + * The list/detail split is what keeps the lists bounded: a block summary names + * its tools and operations by id, and resolving one is a second call. Projecting + * all 300-odd blocks with every field, operation, and tool schema would be + * several megabytes. + */ + +const catalogIdSchema = z + .string() + .trim() + .min(1, 'id cannot be empty') + .max(255, 'id must be at most 255 characters') + +/** Workspace whose availability rules are applied to every catalog read. */ +const catalogWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.' + ), + }) + .strict() + +const catalogConditionValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.union([z.string(), z.number(), z.boolean()])), +]) + +/** + * When a configuration field applies: "the field named by `field` holds + * `value`". `not` inverts the match, and `and` adds a second clause that must + * hold as well. + */ +export const v2CatalogConditionSchema = z + .object({ + field: z.string().describe('Sibling field id whose value decides this condition.'), + value: catalogConditionValueSchema.describe( + 'Value, or set of accepted values, the named field must hold.' + ), + not: z.boolean().optional().describe('Invert the match: every value EXCEPT `value`.'), + and: z + .object({ + field: z.string().describe('Sibling field id for the second clause.'), + value: catalogConditionValueSchema + .optional() + .describe('Value the second clause matches. Absent means "holds any value".'), + not: z.boolean().optional().describe('Invert the second clause.'), + }) + .optional() + .describe('A second clause that must hold as well.'), + }) + .meta({ + id: 'V2CatalogCondition', + title: 'Catalog condition', + description: 'When a configuration field applies, expressed against a sibling field.', + }) +export type V2CatalogCondition = z.output + +const catalogDependsOnSchema = z.union([ + z.array(z.string()), + z.object({ + all: z.array(z.string()).optional().describe('Every listed field must hold a value.'), + any: z.array(z.string()).optional().describe('At least one listed field must hold a value.'), + }), +]) + +/** One configuration field on a block. */ +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`.'), + title: z.string().optional().describe('Human-readable label.'), + required: z + .boolean() + .optional() + .describe( + 'Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.' + ), + requiredWhen: v2CatalogConditionSchema + .optional() + .describe('Condition under which the field is required.'), + description: z.string().optional().describe('Authored explanation of the field.'), + placeholder: z.string().optional().describe('Placeholder shown in the editor.'), + mode: z + .string() + .optional() + .describe( + 'Where the field renders: `basic`, `advanced`, `both`, `trigger`, or `trigger-advanced`.' + ), + hidden: z.boolean().optional().describe('Whether the field is hidden in the editor.'), + condition: v2CatalogConditionSchema + .optional() + .describe('Condition under which the field applies at all.'), + options: z + .array( + z.object({ + id: z.string().describe('Value stored when this option is selected.'), + label: z.string().optional().describe('Human-readable option label.'), + hasIcon: z + .boolean() + .optional() + .describe('Whether the option renders with an icon. The icon itself is not published.'), + }) + ) + .optional() + .describe( + 'Selectable options. Absent on fields whose options are fetched per workspace at edit time.' + ), + min: z.number().optional().describe('Minimum accepted numeric value.'), + max: z.number().optional().describe('Maximum accepted numeric value.'), + step: z.number().optional().describe('Increment for numeric controls.'), + integer: z.boolean().optional().describe('Whether the numeric value must be a whole number.'), + rows: z.number().optional().describe('Visible row count for multi-line text.'), + password: z.boolean().optional().describe('Whether the stored value is masked in the editor.'), + multiSelect: z.boolean().optional().describe('Whether more than one option may be selected.'), + language: z.string().optional().describe('Language of a code field.'), + generationType: z.string().optional().describe('Kind of content AI assistance generates here.'), + serviceId: z.string().optional().describe('OAuth service this credential field authenticates.'), + requiredScopes: z + .array(z.string()) + .optional() + .describe('OAuth scopes the credential selected here must carry.'), + mimeType: z.string().optional().describe('MIME type filter applied to a file picker.'), + acceptedTypes: z.string().optional().describe('Accepted file extensions for an upload field.'), + multiple: z.boolean().optional().describe('Whether more than one file may be supplied.'), + maxSize: z.number().optional().describe('Maximum upload size in megabytes.'), + connectionDroppable: z + .boolean() + .optional() + .describe('Whether another block’s output can be dropped onto this field.'), + columns: z.array(z.string()).optional().describe('Column headings for a table field.'), + dependsOn: catalogDependsOnSchema + .optional() + .describe('Sibling fields this field is cleared by when they change.'), + canonicalParamId: z + .string() + .optional() + .describe( + 'Shared key for a picker/manual-entry pair. Both fields write the same value, so supply exactly one of the pair.' + ), + defaultValue: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.record( + z.string(), + z.unknown().describe('Member of an object-valued default. Shape varies by field type.') + ), + z.array( + z.unknown().describe('Element of an array-valued default. Shape varies by field type.') + ), + ]) + .optional() + .describe('Value used when the field is left unset.'), + hasComputedDefault: z + .boolean() + .optional() + .describe( + 'Whether the field derives its value from the block’s other values. The deriving function is not published.' + ), + }) + .meta({ + id: 'V2BlockField', + title: 'Block field', + description: 'One configuration field on a block.', + }) +export type V2BlockField = z.output + +const catalogBlockSourceSchema = z + .enum(['builtin', 'custom']) + .describe( + 'Where the block comes from: `builtin` is the shipped registry, `custom` is a workflow this workspace deployed as a block.' + ) + +/** Summary view of a block. */ +export const v2BlockSummarySchema = z + .object({ + id: z.string().describe('Block type identifier, used as a workflow block’s `type`.'), + name: z.string().describe('Display name.'), + description: z.string().describe('One-line summary of what the block does.'), + longDescription: z + .string() + .optional() + .describe('Extended explanation, when the block has one.'), + category: z.string().describe('Toolbar category: `blocks`, `tools`, or `triggers`.'), + integrationType: z + .string() + .optional() + .describe('Integration category, e.g. `communication`, `databases`.'), + source: catalogBlockSourceSchema, + authMode: z + .string() + .optional() + .describe('How the block authenticates: `oauth`, `api_key`, or `bot_token`.'), + triggerAllowed: z.boolean().describe('Whether the block declares itself usable as a trigger.'), + triggerCapable: z + .boolean() + .describe( + 'Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields.' + ), + triggerIds: z.array(z.string()).describe('Identifiers of the triggers this block supports.'), + toolIds: z + .array(z.string()) + .describe( + 'Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`.' + ), + operationIds: z + .array(z.string()) + .describe( + 'Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`.' + ), + preview: z + .boolean() + .describe('Whether the block is unreleased and revealed only to this caller.'), + sunset: z + .object({ + status: z + .enum(['legacy', 'deprecated']) + .describe('`legacy` is superseded but supported; `deprecated` is slated for removal.'), + replacedBy: z.string().optional().describe('Block type to migrate to, when one exists.'), + }) + .optional() + .describe('Post-release lifecycle state. Absent for a block in normal support.'), + docsLink: z.string().optional().describe('Sim documentation page for the integration.'), + tags: z.array(z.string()).describe('Catalog tags, e.g. `messaging`, `version-control`.'), + }) + .meta({ + id: 'V2BlockSummary', + title: 'Block summary', + description: 'List view of a block: what it is and what it references, by id.', + }) +export type V2BlockSummary = z.output + +const v2BlockOutputSchema = z.object({ + type: z.string().describe('Value type of the output.'), + description: z.string().optional().describe('What the output holds.'), +}) + +/** Block-level input definition. */ +const v2BlockInputDefinitionSchema = z.object({ + type: z + .string() + .describe('Value type: `string`, `number`, `boolean`, `json`, `array`, or `file`.'), + description: z.string().optional().describe('What the input means.'), + // untyped-response: block input JSON Schema is authored per block and arbitrarily nested + schema: z + .unknown() + .optional() + .describe('JSON-Schema-shaped structure for object and array inputs.'), +}) + +const v2ToolParamSchema = z + .object({ + type: z.string().describe('Parameter value type.'), + required: z.boolean().optional().describe('Whether the parameter must be supplied.'), + visibility: z + .string() + .optional() + .describe('Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.'), + description: z.string().optional().describe('What the parameter means.'), + default: z.unknown().optional().describe('Value used when the parameter is omitted.'), + // untyped-response: tool param JSON Schema is provider-defined and arbitrarily nested + items: z.unknown().optional().describe('JSON-Schema-shaped constraints for structured params.'), + }) + .meta({ + id: 'V2ToolParam', + title: 'Tool parameter', + description: 'One declared parameter of a built-in tool.', + }) +export type V2ToolParam = z.output + +/** + * One declared output field of a tool. + * + * An object output's members and an array output's element shape are published + * open rather than as a self-referential schema. That is the same treatment + * `V2McpTool.inputSchema` gives a server-authored argument schema, and it is + * what keeps the generated document free of anonymous recursive components: a + * `z.lazy` cycle publishes as an unnamed `$ref` that no generated client can + * name. The nesting is still returned in full — only its schema is open. + */ +export const v2ToolOutputSchema = z + .object({ + type: z.string().describe('Value type of the output field.'), + description: z.string().optional().describe('What the field holds.'), + optional: z.boolean().optional().describe('Whether the field may be absent.'), + nullable: z.boolean().optional().describe('Whether the field may be null.'), + properties: z + .record( + z.string(), + // untyped-response: a nested output field has the same open shape as this one + z + .unknown() + .describe('Nested output field, in this same shape.') + ) + .optional() + .describe('Members of an object-typed output, keyed by field name.'), + items: z + .object({ + type: z.string().describe('Element value type.'), + description: z.string().optional().describe('What an element holds.'), + properties: z + .record( + z.string(), + // untyped-response: a nested output field has the same open shape as this one + z + .unknown() + .describe('Nested output field, in this same shape.') + ) + .optional() + .describe('Members of an object-typed element, keyed by field name.'), + }) + .optional() + .describe('Element shape of an array-typed output.'), + fileConfig: z + .object({ + mimeType: z.string().optional().describe('MIME type of the produced file.'), + extension: z.string().optional().describe('File extension of the produced file.'), + }) + .optional() + .describe('File metadata for a file-typed output.'), + }) + .meta({ + id: 'V2ToolOutput', + title: 'Tool output', + description: 'One declared output field of a built-in tool.', + }) +export type V2ToolOutput = z.output + +const v2HostedApiKeySchema = z + .enum(['always', 'conditional', 'none']) + .describe( + 'Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares.' + ) + +const v2ToolOAuthSchema = z.object({ + required: z.boolean().describe('Whether the tool cannot run without an OAuth credential.'), + provider: z.string().describe('OAuth service the credential must authenticate.'), + requiredScopes: z.array(z.string()).optional().describe('Scopes the credential must carry.'), +}) + +/** Summary view of a built-in tool. */ +export const v2ToolSummarySchema = z + .object({ + id: z.string().describe('Registered tool identifier, including its version suffix.'), + name: z.string().describe('Display name.'), + description: z.string().describe('What the tool does.'), + version: z.string().optional().describe('Tool version.'), + hostedApiKey: v2HostedApiKeySchema, + oauth: v2ToolOAuthSchema.optional().describe('OAuth requirement, when the tool has one.'), + }) + .meta({ + id: 'V2ToolSummary', + title: 'Tool summary', + description: 'List view of a built-in tool: identity, auth, and key hosting.', + }) +export type V2ToolSummary = z.output + +/** Detail view of a built-in tool. */ +export const v2ToolDetailSchema = v2ToolSummarySchema + .extend({ + params: z.record(z.string(), v2ToolParamSchema).describe('Parameters the tool accepts.'), + outputs: z.record(z.string(), v2ToolOutputSchema).describe('Fields the tool produces.'), + }) + .meta({ + id: 'V2ToolDetail', + title: 'Tool', + description: 'A built-in tool with its declared parameters and outputs.', + }) +export type V2ToolDetail = z.output + +/** + * One value an operation needs. + * + * An operation's inputs come from two places — the tool's own declared params + * and the block's operation-scoped input definitions — and the two carry + * different structure keys (`items` versus `schema`). This is one schema with + * both rather than a union of the two, because a union of two open object + * shapes resolves to whichever member matches first and silently strips the key + * that distinguished them: a block input's `schema` disappeared into the tool + * param member, which validated fine and published an incomplete field. + */ +const v2OperationInputSchema = z + .object({ + type: z.string().describe('Value type.'), + required: z.boolean().optional().describe('Whether the value must be supplied.'), + visibility: z + .string() + .optional() + .describe('Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.'), + description: z.string().optional().describe('What the value means.'), + default: z.unknown().optional().describe('Value used when this input is omitted.'), + // untyped-response: tool param JSON Schema is provider-defined and arbitrarily nested + items: z + .unknown() + .optional() + .describe('JSON-Schema-shaped constraints declared by the tool parameter.'), + // untyped-response: block input JSON Schema is authored per block and arbitrarily nested + schema: z + .unknown() + .optional() + .describe('JSON-Schema-shaped structure declared by the block input.'), + }) + .meta({ + id: 'V2OperationInput', + title: 'Operation input', + description: 'One value a block operation needs, from its tool or its block-level inputs.', + }) + +const v2BlockOperationSchema = z.object({ + toolId: z.string().optional().describe('Built-in tool that performs this operation.'), + toolName: z.string().optional().describe('Display name of that tool.'), + description: z.string().optional().describe('What the operation does.'), + inputs: z + .record(z.string(), v2OperationInputSchema) + .describe( + 'Values this operation needs, excluding the ones the block supplies from its own block-level inputs.' + ), + outputs: z.record(z.string(), v2ToolOutputSchema).describe('Fields the operation produces.'), + inputSchema: z + .array(v2BlockFieldSchema) + .describe('Configuration fields that appear when this operation is selected.'), +}) + +const v2BlockTriggerSchema = z.object({ + id: z.string().describe('Trigger identifier.'), + outputs: z + .record(z.string(), v2BlockOutputSchema) + .describe('Top-level fields the trigger event delivers.'), + configFields: z + .record( + z.string(), + z.object({ + type: z.string().describe('Editor control the field renders as.'), + required: z.boolean().describe('Whether a value must be supplied.'), + title: z.string().optional().describe('Human-readable label.'), + description: z.string().optional().describe('Authored explanation of the field.'), + placeholder: z.string().optional().describe('Placeholder shown in the editor.'), + default: z.unknown().optional().describe('Value used when the field is left unset.'), + options: z + .array( + z.object({ + id: z.string().describe('Value stored when this option is selected.'), + label: z.string().describe('Human-readable option label.'), + }) + ) + .optional() + .describe('Selectable options.'), + condition: v2CatalogConditionSchema + .optional() + .describe('Condition under which the field applies.'), + }) + ) + .describe('Fields that configure the trigger, keyed by field id.'), +}) + +/** Detail view of a block: the summary plus everything needed to configure one. */ +export const v2BlockDetailSchema = v2BlockSummarySchema + .extend({ + bestPractices: z + .string() + .optional() + .describe('Authored guidance on using the block correctly.'), + inputSchema: z + .array(v2BlockFieldSchema) + .describe('Configuration fields that apply regardless of the selected operation.'), + operationInputSchema: z + .record(z.string(), z.array(v2BlockFieldSchema)) + .describe('Configuration fields keyed by the operation that reveals them.'), + inputDefinitions: z + .record(z.string(), v2BlockInputDefinitionSchema) + .describe('Block-level input definitions, keyed by parameter name.'), + operations: z + .record(z.string(), v2BlockOperationSchema) + .describe('Operations the block exposes, keyed by operation id.'), + tools: z + .array(v2ToolDetailSchema) + .describe('Every built-in tool the block can run, with parameters and outputs.'), + triggers: z.array(v2BlockTriggerSchema).describe('Triggers the block can run on.'), + outputs: z.record(z.string(), v2BlockOutputSchema).describe('Fields the block produces.'), + }) + .meta({ + id: 'V2BlockDetail', + title: 'Block', + description: 'A block with its configuration fields, operations, tools, and triggers.', + }) +export type V2BlockDetail = z.output + +/** One field of a connector's `sourceConfig`. */ +export const v2ConnectorConfigFieldSchema = z + .object({ + id: z.string().describe('Field identifier.'), + title: z.string().describe('Human-readable label.'), + type: z + .enum(['short-input', 'dropdown', 'selector']) + .describe( + 'Control the field renders as. A `selector` fetches its options from the connected account.' + ), + placeholder: z.string().optional().describe('Placeholder shown in the editor.'), + required: z.boolean().optional().describe('Whether a value must be supplied.'), + description: z.string().optional().describe('Authored explanation of the field.'), + options: z + .array( + z.object({ + id: z.string().describe('Value stored when this option is selected.'), + label: z.string().describe('Human-readable option label.'), + }) + ) + .optional() + .describe('Static options, for a `dropdown` field.'), + selectorKey: z + .string() + .optional() + .describe( + 'Names the picker a `selector` field renders. Its options are fetched per workspace.' + ), + mimeType: z.string().optional().describe('MIME type filter applied to the picker.'), + dependsOn: catalogDependsOnSchema + .optional() + .describe('Sibling fields this field is cleared by when they change.'), + mode: z + .enum(['basic', 'advanced']) + .optional() + .describe( + 'Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.' + ), + canonicalParamId: z + .string() + .optional() + .describe( + 'Shared `sourceConfig` key for a picker/manual-entry pair. Send exactly one of the pair, keyed by this value rather than by the field’s own `id`.' + ), + multi: z + .boolean() + .optional() + .describe( + 'When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.' + ), + }) + .meta({ + id: 'V2ConnectorConfigField', + title: 'Connector config field', + description: 'One field of a knowledge-base connector’s source configuration.', + }) +export type V2ConnectorConfigField = z.output + +/** A knowledge-base connector type. */ +export const v2ConnectorTypeSchema = z + .object({ + connectorType: z + .string() + .describe('Exact identifier to send when creating a connector of this type.'), + name: z.string().describe('Display name.'), + description: z.string().describe('What the connector syncs.'), + version: z.string().describe('Connector version.'), + auth: z + .discriminatedUnion('mode', [ + z.object({ + mode: z.literal('oauth').describe('Authenticates with an OAuth credential.'), + provider: z.string().describe('OAuth service the credential must authenticate.'), + requiredScopes: z + .array(z.string()) + .optional() + .describe('Scopes the credential must carry.'), + }), + z.object({ + mode: z.literal('apiKey').describe('Authenticates with a stored API key.'), + label: z.string().optional().describe('Label shown above the key field.'), + placeholder: z.string().optional().describe('Placeholder shown in the key field.'), + optional: z + .boolean() + .describe( + 'Whether the key may be left blank, for a source reachable without authentication.' + ), + }), + ]) + .describe('How the connector authenticates against its source.'), + configFields: z + .array(v2ConnectorConfigFieldSchema) + .describe('Fields that make up the connector’s `sourceConfig`.'), + supportsIncrementalSync: z + .boolean() + .describe('Whether syncs after the first fetch only what changed.'), + tagDefinitions: z + .array( + z.object({ + id: z.string().describe('Semantic tag identifier the connector populates.'), + displayName: z.string().describe('Human-readable tag name.'), + fieldType: z + .enum(['text', 'number', 'date', 'boolean']) + .describe('Value type, which decides the tag slot pool it draws from.'), + }) + ) + .describe('Tags this connector writes onto the documents it syncs.'), + }) + .meta({ + id: 'V2ConnectorType', + title: 'Connector type', + description: 'A knowledge-base connector type and the configuration it accepts.', + }) +export type V2ConnectorType = z.output + +export const v2BlockSortFields = ['id', 'name', 'category'] as const +export const v2ToolSortFields = ['id', 'name'] as const + +export const v2ListBlocksQuerySchema = catalogWorkspaceQuerySchema + .extend({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the block id, name, and description.' + ), + category: z + .enum(['blocks', 'tools', 'triggers']) + .optional() + .describe('Restrict to one toolbar category.'), + capability: z + .enum(['trigger']) + .optional() + .describe( + 'Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields.' + ), + source: z + .enum(['builtin', 'custom']) + .optional() + .describe('Restrict to shipped blocks or to this workspace’s deployed custom blocks.'), + ...v2SortFields(v2BlockSortFields, { sortBy: 'id', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum blocks to return per page.' }), + }) + .strict() +export type V2ListBlocksQuery = z.output + +export const v2ListToolsQuerySchema = catalogWorkspaceQuerySchema + .extend({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the tool id, name, and description.' + ), + hostedApiKey: z + .enum(['always', 'conditional', 'none']) + .optional() + .describe('Restrict to tools by how their API key is supplied.'), + oauthProvider: z + .string() + .trim() + .min(1, 'oauthProvider cannot be empty') + .max(255, 'oauthProvider must be at most 255 characters') + .optional() + .describe('Restrict to tools that authenticate against this OAuth service.'), + ...v2SortFields(v2ToolSortFields, { sortBy: 'id', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum tools to return per page.' }), + }) + .strict() +export type V2ListToolsQuery = z.output + +export const v2GetBlockParamsSchema = z.object({ + blockId: catalogIdSchema.describe( + 'Block type identifier. An unversioned base type resolves to the newest version, and the response echoes the resolved id.' + ), +}) +export type V2GetBlockParams = z.output + +export const v2GetToolParamsSchema = z.object({ + toolId: catalogIdSchema.describe( + 'Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id.' + ), +}) +export type V2GetToolParams = z.output + +export const v2ListConnectorTypesQuerySchema = catalogWorkspaceQuerySchema + .extend({ + search: v2SearchSchema.describe('Case-insensitive substring match against the connector name.'), + }) + .strict() +export type V2ListConnectorTypesQuery = z.output + +/** + * Block list, paginated by an opaque offset cursor rather than the keyset most + * v2 lists use — the same case as `GET /api/v2/skills`. The sequence merges the + * static code registry with the workspace’s deployed custom blocks, filters it + * against the caller’s visibility, and sorts it in memory, so there is no + * ordered SQL read for a keyset predicate to act on. + */ +export const v2ListBlocksContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/blocks', + query: v2ListBlocksQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2BlockSummarySchema) }, +}) + +export const v2GetBlockContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/blocks/[blockId]', + params: v2GetBlockParamsSchema, + query: catalogWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2BlockDetailSchema) }, +}) + +/** + * Tool list, paginated by the same offset cursor and for the same reason: the + * catalog is a code-defined id set narrowed against the caller’s workspace + * visibility entirely in memory. + */ +export const v2ListToolsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tools', + query: v2ListToolsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2ToolSummarySchema) }, +}) + +export const v2GetToolContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tools/[toolId]', + params: v2GetToolParamsSchema, + query: catalogWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2ToolDetailSchema) }, +}) + +export const v2ListConnectorTypesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/connector-types', + query: v2ListConnectorTypesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ConnectorTypeSchema, { paged: false }), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/chat-deployments.ts b/apps/sim/lib/api/contracts/v2/chat-deployments.ts new file mode 100644 index 00000000000..223e7256fef --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chat-deployments.ts @@ -0,0 +1,463 @@ +import { z } from 'zod' +import { chatAuthTypeSchema, chatDeploymentPasswordSchema } from '@/lib/api/contracts/chats' +import { + booleanQueryFlagSchema, + noInputSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { v2WorkflowIdParamsSchema } from '@/lib/api/contracts/v2/workflows' + +/** + * v2 chat-deployment contracts. + * + * A chat deployment publishes one workflow as a hosted conversation. Two things + * about the resource shape a caller would otherwise guess wrong: + * + * 1. **There is no chat subdomain.** Despite what some product copy says, the + * proxy routes deployed chats purely by the `/chat/` path, so a deployment is + * identified by `identifier` and reachable at `url`. Nothing here publishes a + * host a caller could point DNS at. + * 2. **A password is never readable back.** `password` is write-only; reads + * carry `hasPassword` and nothing else. Publishing a decrypt on an API-key + * surface would turn a stored secret into a fetchable one, so the existing + * session-only reveal endpoint deliberately has no v2 counterpart. + * 3. **It is a singleton of its workflow, not a resource with its own id.** A + * chat is strictly 1:1 with the workflow it publishes — `workflowId` is + * `NOT NULL`, cascades, and cannot be re-pointed — so the workflow already + * addresses the chat uniquely and it lives at + * `/api/v2/workflows/{workflowId}/deployments/chat`. A singleton has no separate + * create verb, so `PUT` is create-or-replace and is the only write. The + * deployment's `id` is still published, because audit records and the + * internal editor name it, but no v2 path takes one. + */ + +export const V2_CHAT_DEPLOYMENT_TITLE_MAX = 200 +export const V2_CHAT_DEPLOYMENT_DESCRIPTION_MAX = 2000 +export const V2_CHAT_DEPLOYMENT_ALLOWED_EMAILS_MAX = 500 +export const V2_CHAT_DEPLOYMENT_OUTPUT_CONFIGS_MAX = 100 + +/** + * Strict on the nested object, not only on the body: `.strict()` binds one + * level, so a misspelled customization key would otherwise be dropped silently + * and the deployment would render with the default the caller thought it had + * overridden. + * + * Request-side only. `chat.customizations` is schemaless JSONB written by + * surfaces with wider shapes than this one — the internal editor stores + * `logoUrl` and `headerText`, and the Copilot tool stores whatever it is given — + * so parsing a stored row against these bounds would turn a legitimate row into + * a `500`. The response declares {@link v2StoredChatDeploymentCustomizationsSchema} + * instead, and `toV2ChatDeployment` projects the blob onto it. + */ +export const v2ChatDeploymentCustomizationsSchema = z + .object({ + primaryColor: z + .string() + .min(1, 'customizations.primaryColor cannot be empty') + .max(64, 'customizations.primaryColor must be at most 64 characters') + .optional() + .describe('CSS color used for the chat accent.'), + welcomeMessage: z + .string() + .max(2000, 'customizations.welcomeMessage must be at most 2000 characters') + .optional() + .describe('First message shown to a visitor.'), + imageUrl: z + .string() + .max(2048, 'customizations.imageUrl must be at most 2048 characters') + .optional() + .describe('Avatar image shown beside assistant messages.'), + }) + .strict() + .meta({ + id: 'ChatDeploymentCustomizations', + title: 'Chat deployment customizations', + description: 'Presentation overrides for the deployed chat.', + }) + +/** The customization keys a stored blob may contribute to a v2 read. */ +export const V2_CHAT_DEPLOYMENT_CUSTOMIZATION_KEYS = [ + 'primaryColor', + 'welcomeMessage', + 'imageUrl', +] as const satisfies readonly (keyof z.output)[] + +/** + * The read shape of {@link v2ChatDeploymentCustomizationsSchema}: same keys, no + * bounds and no `.strict()`. A stored value only has to be a string to be + * publishable, and a key this surface does not declare is dropped rather than + * rejected. + */ +export const v2StoredChatDeploymentCustomizationsSchema = z + .object({ + primaryColor: z.string().optional().describe('CSS color used for the chat accent.'), + welcomeMessage: z.string().optional().describe('First message shown to a visitor.'), + imageUrl: z.string().optional().describe('Avatar image shown beside assistant messages.'), + }) + .meta({ + id: 'StoredChatDeploymentCustomizations', + title: 'Stored chat deployment customizations', + description: 'Presentation overrides currently stored on the deployed chat.', + }) + +export const v2ChatDeploymentOutputConfigSchema = z + .object({ + blockId: z + .string() + .min(1, 'outputConfigs[].blockId cannot be empty') + .describe('Block whose output the chat streams.'), + path: z + .string() + .min(1, 'outputConfigs[].path cannot be empty') + .describe('Path within that block output.'), + }) + .strict() + .meta({ + id: 'ChatDeploymentOutputConfig', + title: 'Chat deployment output config', + description: 'One block output surfaced to chat visitors.', + }) + +/** + * The read shape of {@link v2ChatDeploymentOutputConfigSchema}. + * + * `path` carries no `.min(1)`: the create path accepts an empty path — it means + * "the whole block output" — and `chat.output_configs` is schemaless JSONB, so + * requiring one on the way out would `500` every read of a deployment the + * create path legitimately wrote. + */ +export const v2StoredChatDeploymentOutputConfigSchema = z + .object({ + blockId: z.string().describe('Block whose output the chat streams.'), + path: z.string().describe('Path within that block output. Empty means the whole output.'), + }) + .meta({ + id: 'StoredChatDeploymentOutputConfig', + title: 'Stored chat deployment output config', + description: 'One block output currently surfaced to chat visitors.', + }) + +export const v2ChatDeploymentSchema = z + .object({ + id: z.string().describe('Unique chat deployment identifier.'), + workflowId: z.string().describe('Workflow this deployment publishes.'), + workspaceId: z + .string() + .describe('Workspace the deployment belongs to, derived from its workflow.'), + identifier: z + .string() + .describe('URL slug the deployed chat answers on. Unique across live deployments.'), + url: z + .string() + .describe( + 'Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.' + ) + .meta({ examples: ['https://sim.ai/chat/support'] }), + title: z.string().describe('Title shown to visitors.'), + description: z.string().describe('Description shown to visitors. Empty when unset.'), + isActive: z.boolean().describe('Whether the deployment answers requests.'), + authType: chatAuthTypeSchema.describe( + 'How visitors are gated: `public` (no gate), `password`, `email`, or `sso`.' + ), + hasPassword: z + .boolean() + .describe('Whether a password is stored. The password itself is never readable.'), + allowedEmails: z + .array(z.string()) + .describe( + 'Email addresses or domains admitted under `email` and `sso` gating. Empty otherwise.' + ), + customizations: v2StoredChatDeploymentCustomizationsSchema.describe( + 'Presentation overrides. Unset fields fall back to platform defaults.' + ), + outputConfigs: z + .array(v2StoredChatDeploymentOutputConfigSchema) + .describe('Block outputs surfaced to visitors.'), + includeThinking: z + .boolean() + .describe( + 'Whether visitors may receive provider thinking events. They must also opt into the streaming protocol.' + ), + includeToolCalls: z + .boolean() + .describe( + 'Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol.' + ), + createdAt: z + .string() + .describe('ISO 8601 timestamp when the deployment was created.') + .meta({ format: 'date-time' }), + updatedAt: z + .string() + .describe('ISO 8601 timestamp when the deployment was last modified.') + .meta({ format: 'date-time' }), + }) + .meta({ + id: 'ChatDeployment', + title: 'Chat deployment', + description: 'A workflow published as a hosted chat.', + }) +export type V2ChatDeployment = z.output + +/** + * The fields a workspace-wide read does not carry. + * + * `allowedEmails` is an access-control list and `hasPassword` is an auth-posture + * signal, so neither belongs on a list any workspace member — or any workspace + * API key — can call. `customizations` follows them because it is deployment + * configuration rather than something a caller needs to find a deployment. + * + * They stay available on `GET /api/v2/workflows/{workflowId}/deployments/chat`, which is + * gated at workspace `admin`. Narrowing the projection is what lets the list stay + * a `read` operation, and reachable by a workspace key, without the singleton + * read's gate being routable around. + */ +const V2_CHAT_DEPLOYMENT_GATED_FIELDS = { + allowedEmails: true, + hasPassword: true, + customizations: true, +} as const + +/** + * One entry in a chat-deployment list: enough to find a deployment and decide + * whether to fetch it, and nothing the detail read gates. + */ +export const v2ChatDeploymentListItemSchema = v2ChatDeploymentSchema + .omit(V2_CHAT_DEPLOYMENT_GATED_FIELDS) + .meta({ + id: 'ChatDeploymentListItem', + title: 'Chat deployment list entry', + description: 'A workflow published as a hosted chat, without the fields the detail read gates.', + }) +export type V2ChatDeploymentListItem = z.output + +export const v2ChatDeploymentSortFields = ['identifier', 'createdAt', 'updatedAt'] as const +export type V2ChatDeploymentSortBy = (typeof v2ChatDeploymentSortFields)[number] + +export const v2ListChatDeploymentsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace whose chat deployments to list.'), + workflowId: workflowIdSchema.optional().describe('Restrict to deployments of one workflow.'), + isActive: booleanQueryFlagSchema + .optional() + .describe('Restrict to active or inactive deployments.'), + ...v2SortFields(v2ChatDeploymentSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), + ...v2PaginationFields({ description: 'Maximum chat deployments to return per page.' }), + }) + .strict() + .meta({ + id: 'ListChatDeploymentsQuery', + title: 'List chat deployments query', + description: 'Workspace scope, filters, ordering, and pagination for chat deployments.', + }) +export type V2ListChatDeploymentsQuery = z.output + +const chatIdentifierSchema = z + .string() + .min(1, 'identifier cannot be empty') + .max(128, 'identifier must be at most 128 characters') + .regex(/^[a-z0-9-]+$/, 'identifier can only contain lowercase letters, numbers, and hyphens') + +const chatAllowedEmailsSchema = z + .array(z.string().min(1, 'allowedEmails[] cannot be empty')) + .max( + V2_CHAT_DEPLOYMENT_ALLOWED_EMAILS_MAX, + `allowedEmails must contain at most ${V2_CHAT_DEPLOYMENT_ALLOWED_EMAILS_MAX} entries` + ) + +const chatOutputConfigsSchema = z + .array(v2ChatDeploymentOutputConfigSchema) + .max( + V2_CHAT_DEPLOYMENT_OUTPUT_CONFIGS_MAX, + `outputConfigs must contain at most ${V2_CHAT_DEPLOYMENT_OUTPUT_CONFIGS_MAX} entries` + ) + +/** + * The full representation of a workflow's chat. + * + * `PUT` is create-or-replace, so this is the whole resource rather than a set of + * changes: the deployment ends up as exactly what this body describes, and an + * omitted optional field takes its platform default rather than whatever the + * previous deployment carried. There is no `workflowId` — the path names it, and + * a deployment is bound to its workflow for its whole life. + * + * Replacing also deploys the workflow, because a chat serves the live version. + */ +export const v2ReplaceChatDeploymentBodySchema = z + .object({ + identifier: chatIdentifierSchema.describe( + 'URL slug the deployed chat answers on. Must be free across live deployments.' + ), + title: z + .string() + .min(1, 'title cannot be empty') + .max( + V2_CHAT_DEPLOYMENT_TITLE_MAX, + `title must be at most ${V2_CHAT_DEPLOYMENT_TITLE_MAX} characters` + ) + .describe('Title shown to visitors.'), + description: z + .string() + .max( + V2_CHAT_DEPLOYMENT_DESCRIPTION_MAX, + `description must be at most ${V2_CHAT_DEPLOYMENT_DESCRIPTION_MAX} characters` + ) + .optional() + .describe('Description shown to visitors. Omitted clears it.'), + customizations: v2ChatDeploymentCustomizationsSchema + .optional() + .describe('Presentation overrides. Omitted fields take platform defaults.'), + authType: chatAuthTypeSchema + .optional() + .describe('How visitors are gated. `public` leaves the chat open to anyone holding the URL.') + .meta({ default: 'public' }), + /** + * Write-only, and required rather than carried over. + * + * Reads publish `hasPassword` and never the password, so a caller cannot read + * one back to re-send it. Carrying the stored password over implicitly would + * be the one place a replace quietly stopped meaning replace, and it would + * make the verb non-idempotent from the caller's point of view — so a + * password-gated result must state its password every time. + */ + password: chatDeploymentPasswordSchema + .min(1, 'password cannot be empty') + .optional() + .describe( + 'Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back.' + ), + allowedEmails: chatAllowedEmailsSchema + .optional() + .describe( + 'Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes.' + ), + outputConfigs: chatOutputConfigsSchema + .optional() + .describe('Block outputs to surface to visitors. Omitted surfaces none.'), + includeThinking: z + .boolean() + .optional() + .describe('Allow visitors to receive provider thinking events.') + .meta({ default: false }), + includeToolCalls: z + .boolean() + .optional() + .describe('Allow visitors to receive tool lifecycle events.') + .meta({ default: false }), + }) + .strict() + .superRefine((body, ctx) => { + const authType = body.authType ?? 'public' + /** + * Each mode owns exactly one gate column, so a body naming the wrong one is + * refused rather than silently dropped. Under merge-patch semantics a stray + * `password` was ignorable; under replace it would read as configuration the + * caller believes is stored. + */ + if (authType === 'password' && !body.password) { + ctx.addIssue({ + code: 'custom', + path: ['password'], + message: 'password is required when authType is "password"', + }) + } + if (authType !== 'password' && body.password !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['password'], + message: `password cannot be set when authType is "${authType}"; only "password" gating stores one`, + }) + } + if ((authType === 'email' || authType === 'sso') && (body.allowedEmails ?? []).length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['allowedEmails'], + message: `allowedEmails must contain at least one email or domain when authType is "${authType}"`, + }) + } + if (authType !== 'email' && authType !== 'sso' && (body.allowedEmails ?? []).length > 0) { + ctx.addIssue({ + code: 'custom', + path: ['allowedEmails'], + message: `allowedEmails cannot be set when authType is "${authType}"; only "email" and "sso" gating admit an allow-list`, + }) + } + }) + .meta({ + id: 'ReplaceChatDeploymentRequest', + title: 'Replace chat deployment request', + description: "The complete desired state of a workflow's chat.", + examples: [{ identifier: 'support', title: 'Support chat' }], + }) +export type V2ReplaceChatDeploymentBody = z.input + +export const v2DeleteChatDeploymentDataSchema = z + .object({ + id: z.string().describe('Identifier of the removed chat deployment.'), + deleted: z.literal(true).describe('Whether the deployment was removed.'), + }) + .meta({ + id: 'DeleteChatDeploymentResult', + title: 'Delete chat deployment result', + description: 'Chat deployment removal acknowledgement.', + }) +export type V2DeleteChatDeploymentData = z.output + +/** + * The cross-parent discovery collection. + * + * Every write addresses one workflow's chat, but "what does this workspace + * serve" is a question no per-workflow path can answer, so the list stays + * workspace-scoped and keeps its own cursor binding. + */ +export const v2ListChatDeploymentsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/chat-deployments', + query: v2ListChatDeploymentsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ChatDeploymentListItemSchema), + }, +}) + +export const v2GetWorkflowChatDeploymentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[workflowId]/deployments/chat', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ChatDeploymentSchema), + }, +}) + +export const v2ReplaceWorkflowChatDeploymentContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/workflows/[workflowId]/deployments/chat', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + body: v2ReplaceChatDeploymentBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ChatDeploymentSchema), + }, +}) + +export const v2DeleteWorkflowChatDeploymentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[workflowId]/deployments/chat', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteChatDeploymentDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 851165c3920..a4f0091a8cf 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -469,7 +469,7 @@ export const v2CreateServiceAccountCredentialContract = defineRouteContract({ export const v2CredentialParamsSchema = z .object({ - credentialId: nonEmptyIdSchema.max(255).describe('Credential to disconnect.'), + credentialId: nonEmptyIdSchema.max(255).describe('Credential to update or disconnect.'), }) .strict() @@ -490,6 +490,143 @@ export const v2CredentialDeleteDataSchema = z description: 'Credential disconnection acknowledgement.', }) +export const v2UpdateCredentialQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + }) + .strict() +export type V2UpdateCredentialQuery = z.output + +/** + * Merge-patch semantics: an absent field is left unchanged, and `description: + * null` clears the stored description. Deliberately `PATCH` rather than `PUT` — + * omitting a secret field leaves the stored secret in place rather than clearing + * it, so the body is never a complete representation of the credential. + * + * Reuses the create body's secret shape so a rotation cannot accept a field a + * creation rejects, and so every secret member keeps its `writeOnly` marking. + * `providerId` is deliberately absent: the provider is a property of the stored + * credential, and changing it would describe a different credential. + * + * Whether the secret fields are *applicable* cannot be decided here, and this + * schema deliberately does not try: only a service-account credential stores a + * rotatable secret, and the credential's type is known only once the row is + * loaded, so there is no discriminant in the request to key a union on. The + * refusal lives in `updateCredentialRecord`, which answers a `validation` + * failure — a `400` on every surface — rather than dropping the field. + */ +const v2ServiceAccountSecretFieldsShape = { + serviceAccountJson: z + .string() + .min(1) + .max(65_536) + .optional() + .describe('Write-only Google service-account JSON key.') + .meta({ writeOnly: true }), + apiToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only provider API token.') + .meta({ writeOnly: true }), + domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + signingSecret: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only webhook signing secret.') + .meta({ writeOnly: true }), + botToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only bot token.') + .meta({ writeOnly: true }), + clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'), + clientSecret: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe('Write-only OAuth client secret.') + .meta({ writeOnly: true }), + certificateId: z + .string() + .trim() + .min(1) + .max(512) + .optional() + .describe('Provider certificate mapping identifier.'), + orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'), + dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'), + authMethod: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe('Provider authentication method.'), + privateKey: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only PEM private key.') + .meta({ writeOnly: true }), + username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), +} as const + +export const v2UpdateCredentialBodySchema = z + .object({ + displayName: z + .string() + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .optional() + .describe('New name shown for the credential in Sim.'), + description: z + .string() + .trim() + .max(500, 'description must be at most 500 characters') + .nullable() + .optional() + .describe('New credential description. Send null to clear the stored one.'), + ...v2ServiceAccountSecretFieldsShape, + }) + .strict() + .superRefine((body, ctx) => { + if (Object.values(body).every((value) => value === undefined)) { + ctx.addIssue({ + code: 'custom', + message: + 'Provide at least one of displayName, description, or a service-account secret field', + }) + } + }) +export type V2UpdateCredentialBody = z.input + +/** Rotates secret material or renames a credential while preserving its ID. */ +export const v2UpdateCredentialContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/credentials/[credentialId]', + params: v2CredentialParamsSchema, + query: v2UpdateCredentialQuerySchema, + body: v2UpdateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialSchema), + }, +}) + export const v2DeleteCredentialContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/credentials/[credentialId]', diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index 601b301c789..9d19dc29947 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -106,7 +106,7 @@ export const v2CustomToolDeleteDataSchema = z export type V2CustomToolDeleteData = z.output export const v2CustomToolParamsSchema = z.object({ - id: nonEmptyIdSchema.describe('Unique custom tool identifier.'), + customToolId: nonEmptyIdSchema.describe('Unique custom tool identifier.'), }) export type V2CustomToolParams = z.output @@ -193,7 +193,7 @@ export const v2CreateCustomToolContract = defineRouteContract({ export const v2GetCustomToolContract = defineRouteContract({ method: 'GET', - path: '/api/v2/custom-tools/[id]', + path: '/api/v2/custom-tools/[customToolId]', params: v2CustomToolParamsSchema, query: v2CustomToolWorkspaceQuerySchema, response: { @@ -204,7 +204,7 @@ export const v2GetCustomToolContract = defineRouteContract({ export const v2UpdateCustomToolContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/custom-tools/[id]', + path: '/api/v2/custom-tools/[customToolId]', query: noInputSchema, params: v2CustomToolParamsSchema, body: v2UpdateCustomToolBodySchema, @@ -216,7 +216,7 @@ export const v2UpdateCustomToolContract = defineRouteContract({ export const v2DeleteCustomToolContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/custom-tools/[id]', + path: '/api/v2/custom-tools/[customToolId]', params: v2CustomToolParamsSchema, query: v2CustomToolWorkspaceQuerySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/error-codes.ts b/apps/sim/lib/api/contracts/v2/error-codes.ts index 38f00240bc5..72ba8dfbe59 100644 --- a/apps/sim/lib/api/contracts/v2/error-codes.ts +++ b/apps/sim/lib/api/contracts/v2/error-codes.ts @@ -54,7 +54,7 @@ export const V2_ERROR_STATUS_BY_CODE: Record = { * example name the wrong code. * * This describes how a code chooses its status, not every status the surface can send. A - * route may pass an explicit status alongside a code — `POST /workflows/{id}/execute` answers + * route may pass an explicit status alongside a code — `POST /workflows/{workflowId}/execute` answers * `408` with `BAD_REQUEST` — so a status absent from this map is one no documented error * response may claim, which is what the OpenAPI layer enforces. */ diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 8b0ba62b39b..52ca4853e6b 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -20,6 +20,7 @@ import { v2FolderPathSchema, v2FolderSchema, v2ListFoldersQuerySchema, + v2NonRootFolderPathInputSchema, v2PaginationFields, v2RelocateFolderBodySchema, v2SearchSchema, @@ -34,6 +35,8 @@ import { v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { MAX_TEXT_EXTRACTION_BYTES } from '@/lib/uploads/utils/file-utils' +import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' /** * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in @@ -490,13 +493,80 @@ export const v2DeleteFileFolderDataSchema = z description: 'File-folder deletion acknowledgement and deletion counts.', }) +/** + * Extends the shared folder query with a lifecycle selector. + * + * Only workspace files have an archived folder set — tables, workflows, and + * knowledge folders do not — so `scope` is added here rather than to the shared + * schema, which would give three other surfaces a parameter they ignore. + */ +export const v2ListFileFoldersQuerySchema = v2ListFoldersQuerySchema.extend({ + scope: v2FileScopeSchema + .default('active') + .describe( + 'Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.' + ), +}) +export type V2ListFileFoldersQuery = z.output + export const v2ListFileFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/files/folders', - query: v2ListFoldersQuerySchema, + query: v2ListFileFoldersQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema, { paged: false }) }, }) +export const v2RestoreFileFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the archived folder.'), + path: v2NonRootFolderPathInputSchema.describe( + 'Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.' + ), + }) + .strict() +export type V2RestoreFileFolderBody = z.input + +export const v2RestoreFileFolderDataSchema = z + .object({ + folder: v2FolderSchema.describe('The restored folder.'), + restoredItems: z + .object({ + files: z.number().int().nonnegative().describe('Files restored inside the folder tree.'), + folders: z + .number() + .int() + .nonnegative() + .describe('Folders restored, including the one addressed.'), + }) + .strict() + .describe('What the restore brought back.'), + }) + .strict() + .meta({ + id: 'V2FileFolderRestore', + title: 'Folder restore result', + description: 'The restored folder and the counts of items it brought back.', + }) +export type V2FileFolderRestore = z.output + +/** + * Restores a soft-deleted folder tree. + * + * `DELETE /api/v2/files/folders` archives recursively, so without this the + * archived children were visible through `GET /api/v2/files?scope=archived` + * but the folder structure itself was unrecoverable over the API. + */ +export const v2RestoreFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/folders/restore', + query: noInputSchema, + body: v2RestoreFileFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RestoreFileFolderDataSchema), + }, +}) + export const v2CreateFileFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/folders', @@ -628,6 +698,20 @@ export const v2CreateFileUploadContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema), status: 201 }, }) +/** + * Reads an upload session's current state so a caller can resume or abandon a + * transfer it did not finish. Carries the same signed control token as the + * other control legs: a session read is re-authorized exactly like a mutation. + */ +export const v2GetFileUploadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + export const v2AbortFileUploadContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/files/uploads/[uploadId]', @@ -666,6 +750,74 @@ export const v2DownloadFileContract = defineRouteContract({ }, }) +export const v2ReadFileTextQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), + maxBytes: z.coerce + .number() + .int() + .min(1, 'maxBytes must be at least 1') + .max(MAX_TEXT_EXTRACTION_BYTES, `maxBytes cannot exceed ${MAX_TEXT_EXTRACTION_BYTES}`) + .optional() + .describe( + 'Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.' + ), + }) + .strict() +export type V2ReadFileTextQuery = z.output + +export const v2FileTextSchema = z + .object({ + fileId: workspaceFileIdSchema.describe('File the text was extracted from.'), + name: z.string().describe('File name, including its extension.'), + type: z.string().describe('Stored MIME type of the source file.'), + text: z.string().describe('Extracted text.'), + truncated: z + .boolean() + .describe('True when a parser limit stopped extraction before the input was exhausted.'), + degraded: z + .boolean() + .describe( + 'True when text extraction did not fully succeed and `text` may be incomplete or synthesized from the raw bytes rather than read from the document. Never treat degraded text as authoritative content.' + ), + degradedReason: z + .string() + .nullable() + .describe('Why extraction degraded, or null when it did not.'), + charCount: z.number().int().nonnegative().describe('Length of `text` in characters.'), + byteCount: z + .number() + .int() + .nonnegative() + .describe('Source bytes read from storage before extraction.'), + }) + .strict() + .meta({ + id: 'V2FileText', + title: 'Extracted file text', + description: 'Text extracted from a workspace file, with extraction-quality flags.', + }) +export type V2FileText = z.output + +/** + * Returns a file's text content, parsed out of the stored bytes. + * + * `degraded` is a required, non-optional boolean rather than an optional flag: + * the legacy `doc` and `ppt` parsers return best-effort or placeholder content + * instead of throwing, and a client that never checks an omittable field would + * silently treat guessed text as extracted text. + */ +export const v2ReadFileTextContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]/text', + params: v2FileParamsSchema, + query: v2ReadFileTextQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileTextSchema), + }, +}) + export const v2GetFileContract = defineRouteContract({ method: 'GET', path: '/api/v2/files/[fileId]/metadata', @@ -700,6 +852,68 @@ export const v2DeleteFileContract = defineRouteContract({ }, }) +export const v2UnzipFileBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the archive.'), + }) + .strict() +export type V2UnzipFileBody = z.input + +/** + * Counts plus the destination path, deliberately not the unpacked files. + * + * A large archive would otherwise materialize thousands of file objects into + * one response body — the same unbounded-materialization hazard the list + * endpoints exist to avoid. The caller pages + * `GET /api/v2/files?folderPath=...` instead. + */ +export const v2UnzipFileDataSchema = z + .object({ + folderPath: v2FolderPathSchema.describe( + 'Canonical path of the folder the archive was unpacked into. May differ from the archive name when a sibling folder already claimed it.' + ), + extractedFileCount: z + .number() + .int() + .nonnegative() + .describe('Number of files written into the destination folder.'), + skippedFileCount: z + .number() + .int() + .nonnegative() + .describe('Number of archive entries skipped as unsafe, empty, or noise.'), + }) + .strict() + .meta({ + id: 'V2FileUnzipResult', + title: 'Unzip result', + description: 'Outcome of unzipping a workspace archive into a folder.', + }) +export type V2FileUnzipResult = z.output + +/** + * Unzips an archive into a new folder beside it. + * + * Named `unzip` because both other candidates are already taken on this + * resource. `extract` reads as "extract text", which is what the sibling + * `GET /api/v2/files/[fileId]/text` does. `unarchive` reads as the inverse of + * `DELETE` + `POST /api/v2/files/[fileId]/restore`, since a soft-deleted file + * is an *archived* file here and `GET /api/v2/files?scope=archived` lists them. + * `unzip` collides with neither, and it is what the implementation calls + * itself — the format is `.zip` and nothing else. + */ +export const v2UnzipFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/[fileId]/unzip', + query: noInputSchema, + params: v2FileParamsSchema, + body: v2UnzipFileBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UnzipFileDataSchema), + }, +}) + export const v2RestoreFileContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/[fileId]/restore', @@ -723,6 +937,66 @@ export const v2MoveFileItemsContract = defineRouteContract({ }, }) +/** + * Comma-separated query list, bounded by the same ceiling the resolved + * selection is held to. A looser cap here was a contract lie: a selection above + * `MAX_ZIP_DOWNLOAD_FILES` passed validation, resolved, and only then answered + * `400`, and a thousand comma-joined identifiers is a query string long enough + * that a proxy answers `414` with a body that never reaches the v2 error + * envelope. + * + * Comma-separated only: v2 rejects a query parameter sent more than once, so a + * repeated-parameter form would never reach this schema. + */ +function v2QuerySelectionListSchema(field: string) { + return z + .string() + .optional() + .transform((value) => + (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + ) + .pipe( + z + .array(z.string().min(1)) + .max( + MAX_ZIP_DOWNLOAD_FILES, + `${field} cannot contain more than ${MAX_ZIP_DOWNLOAD_FILES} entries; a bulk download is limited to ${MAX_ZIP_DOWNLOAD_FILES} files.` + ) + ) +} + +export const v2BulkDownloadFilesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace containing the selection.'), + fileIds: v2QuerySelectionListSchema('fileIds').describe( + `File identifiers to include, comma-separated. At most ${MAX_ZIP_DOWNLOAD_FILES} entries.` + ), + folderPaths: v2QuerySelectionListSchema('folderPaths').describe( + `Folder paths to include with all their descendants, comma-separated. At most ${MAX_ZIP_DOWNLOAD_FILES} entries, and the files they resolve to count against the same ${MAX_ZIP_DOWNLOAD_FILES}-file download ceiling. A path that matches no folder is rejected rather than ignored.` + ), + }) + .strict() +export type V2BulkDownloadFilesQuery = z.output + +/** + * Streams a selection of workspace files as one zip. + * + * Named `bulk-download` to match the existing `bulk-delete` sibling of the + * `[fileId]` segment. A static segment here permanently shadows a file whose id + * equals it, and `workspaceFileIdSchema` does accept `[A-Za-z0-9_-]+`; the + * hyphenated form is chosen because neither minted id shape — UUID v4 or + * `wf_` — can ever produce it. + */ +export const v2BulkDownloadFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/bulk-download', + query: v2BulkDownloadFilesQuerySchema, + response: { mode: 'binary' }, +}) + export const v2BulkDeleteFilesContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/bulk-delete', diff --git a/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts b/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts new file mode 100644 index 00000000000..6acc5d22138 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts @@ -0,0 +1,311 @@ +import { z } from 'zod' +import { knowledgeChunkParamsSchema } from '@/lib/api/contracts/knowledge/shared' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2KnowledgeDeleteDataSchema, + v2KnowledgeDocumentParamsSchema, +} from '@/lib/api/contracts/v2/knowledge' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SearchSchema, + v2SortFields, + v2TimestampSchema, +} from '@/lib/api/contracts/v2/shared' +import { CHUNK_SORT_FIELDS } from '@/lib/knowledge/chunks/types' + +/** + * v2 knowledge chunk contracts. + * + * A chunk is the unit a search actually matches, so reading and editing chunks + * is how a caller inspects why a document answers the way it does and corrects + * an extraction the processing pipeline got wrong. + * + * Chunks belonging to a connector-synced document are read-only: they are + * owned by the upstream source, and a write answers `403` with + * `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`. + */ + +/** Longest chunk content a caller may write, matching the internal surface. */ +export const MAX_V2_KNOWLEDGE_CHUNK_CONTENT_LENGTH = 10_000 + +/** Maximum chunks addressable by identifier in one bulk request. */ +export const MAX_V2_BULK_KNOWLEDGE_CHUNKS = 100 + +const v2KnowledgeChunkTagValueSchema = z + .string() + .nullable() + .describe('Text tag value inherited from the document, or null when the slot is unset.') + +/** + * A chunk, with its tag slots projected as slots rather than display names. + * + * That is the opposite of a document read, and deliberate: a chunk's tags are + * copies of the document's, so the display-name map is already available one + * level up, and projecting slots here keeps the shape stable when a definition + * is renamed mid-page. Resolve slots to names with + * `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. + */ +export const v2KnowledgeChunkSchema = z + .object({ + id: z + .string() + .describe('Unique chunk identifier.') + .meta({ examples: ['4c1f9e77-2b3a-4f8d-9e10-6a2c8d4b1e05'] }), + chunkIndex: z + .number() + .int() + .nonnegative() + .describe('Zero-based position of the chunk within its document.') + .meta({ examples: [3] }), + content: z + .string() + .describe('Text content of the chunk, exactly as it was embedded.') + .meta({ examples: ['To reset your password, open Settings and choose Security.'] }), + contentLength: z + .number() + .int() + .nonnegative() + .describe('Character count of `content`.') + .meta({ examples: [58] }), + tokenCount: z + .number() + .int() + .nonnegative() + .describe('Tokens the chunk consumed when embedded.') + .meta({ examples: [14] }), + enabled: z + .boolean() + .describe('Whether the chunk participates in search. A disabled chunk stays indexed.'), + startOffset: z + .number() + .int() + .nonnegative() + .describe('Character offset of the chunk within the extracted document text.'), + endOffset: z + .number() + .int() + .nonnegative() + .describe('Character offset just past the end of the chunk.'), + tag1: v2KnowledgeChunkTagValueSchema, + tag2: v2KnowledgeChunkTagValueSchema, + tag3: v2KnowledgeChunkTagValueSchema, + tag4: v2KnowledgeChunkTagValueSchema, + tag5: v2KnowledgeChunkTagValueSchema, + tag6: v2KnowledgeChunkTagValueSchema, + tag7: v2KnowledgeChunkTagValueSchema, + createdAt: v2TimestampSchema.describe('ISO 8601 timestamp when the chunk was created.'), + updatedAt: v2TimestampSchema.describe('ISO 8601 timestamp when the chunk was last modified.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeChunk', + title: 'Knowledge chunk', + description: 'One embedded passage of a knowledge document.', + }) +export type V2KnowledgeChunk = z.output + +export const v2KnowledgeChunkParamsSchema = knowledgeChunkParamsSchema.omit({ id: true }).extend({ + knowledgeBaseId: knowledgeChunkParamsSchema.shape.id.describe( + 'Unique knowledge base identifier.' + ), + documentId: knowledgeChunkParamsSchema.shape.documentId.describe( + 'Unique knowledge document identifier.' + ), + chunkId: knowledgeChunkParamsSchema.shape.chunkId.describe('Unique chunk identifier.'), +}) +export type V2KnowledgeChunkParams = z.output + +/** + * `enabled` is a tri-state filter rather than a boolean flag: `all` is the + * default and is a third selection, not the absence of one, so + * `booleanQueryFlagSchema` cannot express it. + */ +export const v2ListKnowledgeChunksQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + search: v2SearchSchema.describe('Case-insensitive substring match against chunk content.'), + enabled: z + .enum(['true', 'false', 'all'], { + error: 'enabled: expected one of "true" | "false" | "all"', + }) + .default('all') + .describe('Restrict to enabled or disabled chunks. `all` returns both.'), + ...v2SortFields(CHUNK_SORT_FIELDS, { sortBy: 'chunkIndex', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum chunks to return per page.' }), + }) + .strict() +export type V2ListKnowledgeChunksQuery = z.output + +const v2KnowledgeChunkContentSchema = z + .string() + .min(1, 'content cannot be empty') + .max( + MAX_V2_KNOWLEDGE_CHUNK_CONTENT_LENGTH, + `content cannot exceed ${MAX_V2_KNOWLEDGE_CHUNK_CONTENT_LENGTH} characters` + ) + .describe('Text to embed. It is embedded on write, so the chunk is searchable immediately.') + +/** + * A created chunk is appended: its `chunkIndex` is assigned server-side as one + * past the document's current maximum, and it inherits the document's tag + * values. The document must have finished processing. + */ +export const v2CreateKnowledgeChunkBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + content: v2KnowledgeChunkContentSchema, + enabled: z.boolean().default(true).describe('Whether the new chunk participates in search.'), + }) + .strict() +export type V2CreateKnowledgeChunkBody = z.input + +export const v2UpdateKnowledgeChunkBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + content: v2KnowledgeChunkContentSchema + .optional() + .describe( + 'Replacement text. Changing it re-embeds the chunk and re-derives its token and character counts.' + ), + enabled: z + .boolean() + .optional() + .describe('Whether the chunk participates in search. Disabling keeps it indexed.'), + }) + .strict() + .superRefine((body, ctx) => { + if (body.content === undefined && body.enabled === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'At least one of content or enabled is required', + }) + } + }) +export type V2UpdateKnowledgeChunkBody = z.input + +export const v2BulkKnowledgeChunksBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + operation: z + .enum(['enable', 'disable', 'delete'], { + error: 'operation: expected one of "enable" | "disable" | "delete"', + }) + .describe('What to do with the selected chunks.'), + chunkIds: z + .array(z.string().min(1, 'chunkIds entries cannot be empty')) + .min(1, 'chunkIds cannot be empty') + .max( + MAX_V2_BULK_KNOWLEDGE_CHUNKS, + `chunkIds cannot contain more than ${MAX_V2_BULK_KNOWLEDGE_CHUNKS} chunks` + ) + .describe('Chunks to operate on, by identifier. Ids outside the document are ignored.'), + }) + .strict() +export type V2BulkKnowledgeChunksBody = z.input + +/** + * Bulk chunk outcome. Unlike the per-chunk operations this is best-effort: an + * identifier naming no chunk in the document is skipped rather than failing the + * request, so `processed` is the authoritative count. + */ +export const v2BulkKnowledgeChunksDataSchema = z + .object({ + operation: z.enum(['enable', 'disable', 'delete']).describe('Operation that was applied.'), + processed: z + .number() + .int() + .nonnegative() + .describe('Number of chunks the operation changed.') + .meta({ examples: [12] }), + errors: z + .array(z.string()) + .describe('Per-chunk failures. A populated array still answers 200.'), + }) + .strict() + .meta({ + id: 'V2BulkKnowledgeChunksData', + title: 'Bulk knowledge chunk update data', + description: 'Outcome of a bulk enable, disable, or delete across knowledge chunks.', + }) + +export const v2ListKnowledgeChunksContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', + params: v2KnowledgeDocumentParamsSchema, + query: v2ListKnowledgeChunksQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeChunkSchema), + }, +}) + +export const v2CreateKnowledgeChunkContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', + query: noInputSchema, + params: v2KnowledgeDocumentParamsSchema, + body: v2CreateKnowledgeChunkBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeChunkSchema), + status: 201, + }, +}) + +export const v2BulkUpdateKnowledgeChunksContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', + query: noInputSchema, + params: v2KnowledgeDocumentParamsSchema, + body: v2BulkKnowledgeChunksBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2BulkKnowledgeChunksDataSchema), + }, +}) + +export const v2GetKnowledgeChunkContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]', + params: v2KnowledgeChunkParamsSchema, + query: z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + }) + .strict(), + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeChunkSchema), + }, +}) + +export const v2UpdateKnowledgeChunkContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]', + query: noInputSchema, + params: v2KnowledgeChunkParamsSchema, + body: v2UpdateKnowledgeChunkBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeChunkSchema), + }, +}) + +export const v2DeleteKnowledgeChunkContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]', + params: v2KnowledgeChunkParamsSchema, + query: z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + }) + .strict(), + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge-tags.ts b/apps/sim/lib/api/contracts/v2/knowledge-tags.ts new file mode 100644 index 00000000000..5e953493976 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge-tags.ts @@ -0,0 +1,420 @@ +import { z } from 'zod' +import { knowledgeTagParamsSchema } from '@/lib/api/contracts/knowledge/shared' +import { + booleanQueryFlagSchema, + noInputSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2KnowledgeBaseParamsSchema, v2KnowledgeTagSchema } from '@/lib/api/contracts/v2/knowledge' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + ALL_TAG_SLOTS, + KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, + SUPPORTED_FIELD_TYPES, + TAG_SLOT_CONFIG, +} from '@/lib/knowledge/constants' + +/** + * v2 knowledge tag-definition writes. + * + * All of them are knowledge-base scoped, because a tag definition is: the rows + * live in `knowledge_base_tag_definitions`, keyed by knowledge base and slot, + * and every document in the base reads the same vocabulary. + * + * The read half (`GET /api/v2/knowledge/{knowledgeBaseId}/tags`) shipped first, which left + * the tag loop unbuildable end-to-end: a caller could set a tag *value* by slot + * on a document, but had no way to name that slot, and both the document-list + * and search tag filters resolve by display name and reject a name no + * definition declares. These operations close it — create a definition, write + * its slot on a document, then filter by its display name. + * + * Definitions are addressed by id. The slot is where the value is stored and + * the display name is how it is filtered; neither is a stable identifier, since + * a display name is unique only per knowledge base and may be renamed. + */ + +const fieldTypeValues = SUPPORTED_FIELD_TYPES as [string, ...string[]] + +/** + * Field type on a write. An enum here, unlike on the response, because an input + * set can be closed at the boundary: both write paths already reject anything + * outside it in the domain, so publishing the enum only moves that refusal to a + * 400 that names the valid set. + */ +const v2KnowledgeTagFieldTypeSchema = z + .enum(fieldTypeValues, { + error: `fieldType: expected one of ${fieldTypeValues.map((type) => `"${type}"`).join(' | ')}`, + }) + .describe( + `Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: ${SUPPORTED_FIELD_TYPES.map( + (type) => `${type} ${TAG_SLOT_CONFIG[type].maxSlots}` + ).join(', ')}.` + ) + .meta({ examples: ['text'] }) + +const v2KnowledgeTagSlotSchema = z + .enum(ALL_TAG_SLOTS, { + error: `tagSlot: expected one of ${ALL_TAG_SLOTS.map((slot) => `"${slot}"`).join(' | ')}`, + }) + .describe('Storage slot the tag occupies. It must belong to the tag’s `fieldType`.') + .meta({ examples: ['tag1'] }) + +const v2KnowledgeTagDisplayNameSchema = z + .string() + .trim() + .min(1, 'displayName cannot be empty') + .max( + KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, + `displayName cannot exceed ${KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH} characters` + ) + .describe('Name tag filters and document reads use for this tag.') + .meta({ examples: ['category'] }) + +export const v2KnowledgeTagParamsSchema = knowledgeTagParamsSchema.omit({ id: true }).extend({ + knowledgeBaseId: knowledgeTagParamsSchema.shape.id.describe('Unique knowledge base identifier.'), + tagId: knowledgeTagParamsSchema.shape.tagId.describe('Unique tag definition identifier.'), +}) +export type V2KnowledgeTagParams = z.output + +const v2KnowledgeWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + }) + .strict() + +/** + * `tagSlot` is optional: omitting it assigns the next free slot for the field + * type, which is what a caller that does not care about storage layout wants. + * Exhausting the type's slots is a `400` naming the type — the remedy is + * choosing a different `fieldType` or deleting a definition, not retrying. + */ +export const v2CreateKnowledgeTagBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + displayName: v2KnowledgeTagDisplayNameSchema, + fieldType: v2KnowledgeTagFieldTypeSchema.optional().default('text'), + tagSlot: v2KnowledgeTagSlotSchema + .optional() + .describe( + 'Slot to store the tag in. Omit to take the next free slot for the field type; a slot that does not belong to the field type, or one already in use, is rejected.' + ), + }) + .strict() +export type V2CreateKnowledgeTagBody = z.input + +export const v2UpdateKnowledgeTagBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + displayName: v2KnowledgeTagDisplayNameSchema.optional().describe('New tag display name.'), + fieldType: v2KnowledgeTagFieldTypeSchema.optional().describe('New value type for the tag.'), + }) + .strict() + .superRefine((body, ctx) => { + if (body.displayName === undefined && body.fieldType === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['displayName'], + message: 'At least one of displayName or fieldType is required', + }) + } + }) +export type V2UpdateKnowledgeTagBody = z.input + +/** + * Deleting a definition also clears the slot's values on every document and + * chunk in the knowledge base — the definition is what gives the slot meaning, + * so leaving the values would strand them under a raw slot name. + */ +export const v2DeleteKnowledgeTagDataSchema = z + .object({ + id: z.string().describe('Identifier of the deleted tag definition.'), + tagSlot: z.string().describe('Slot the deleted tag occupied; its values are now cleared.'), + displayName: z.string().describe('Display name the deleted tag carried.'), + deleted: z.literal(true).describe('Confirms that the tag definition was deleted.'), + }) + .strict() + .meta({ + id: 'V2DeleteKnowledgeTagData', + title: 'Delete knowledge tag data', + description: 'Acknowledgement for a deleted tag definition.', + }) + +export const v2NextKnowledgeTagSlotQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + fieldType: v2KnowledgeTagFieldTypeSchema, + }) + .strict() +export type V2NextKnowledgeTagSlotQuery = z.output + +export const v2NextKnowledgeTagSlotDataSchema = z + .object({ + nextAvailableSlot: z + .string() + .nullable() + .describe('The free slot a create would take, or null when the field type is exhausted.') + .meta({ examples: ['tag3'] }), + fieldType: z.string().describe('Field type the slots were counted for.'), + usedSlots: z.array(z.string()).describe('Slots of this field type already holding a tag.'), + totalSlots: z + .number() + .int() + .positive() + .describe( + `Total slots this field type has: ${SUPPORTED_FIELD_TYPES.map( + (fieldType) => `${TAG_SLOT_CONFIG[fieldType].maxSlots} for ${fieldType}` + ).join(', ')}.` + ), + availableSlots: z + .number() + .int() + .nonnegative() + .describe('Slots of this field type still free, or 0 when the field type is exhausted.'), + }) + .strict() + .meta({ + id: 'V2NextKnowledgeTagSlotData', + title: 'Next knowledge tag slot', + description: 'Slot availability for one tag field type.', + }) + +export const v2KnowledgeTagUsageSchema = z + .object({ + id: z + .string() + .describe( + 'Tag definition identifier. Published for the same reason the vocabulary read publishes it: `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by id, so without it a usage row cannot be acted on without a second read and a slot join.' + ) + .meta({ examples: ['7c9e6679-7425-40de-944b-e07fc1f90ae7'] }), + tagSlot: z + .string() + .describe('Slot the tag occupies.') + .meta({ examples: ['tag1'] }), + displayName: z + .string() + .describe('Tag display name.') + .meta({ examples: ['category'] }), + fieldType: z + .string() + .describe('Value type stored in the slot.') + .meta({ examples: ['text'] }), + documentCount: z + .number() + .int() + .nonnegative() + .describe('Documents in the knowledge base carrying a value in this slot.'), + chunkCount: z + .number() + .int() + .nonnegative() + .describe('Indexed chunks carrying a value in this slot.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeTagUsage', + title: 'Knowledge tag usage', + description: 'How widely one tag is populated across a knowledge base.', + }) + +/** + * One tag definition in a bulk save. + * + * `originalDisplayName` names the definition being renamed. It is how a bulk + * save distinguishes "rename the tag in this slot" from "define a new one", and + * a slot already holding a definition under a different name is updated rather + * than duplicated. + */ +export const v2BulkSaveKnowledgeTagDefinitionSchema = z + .object({ + tagSlot: v2KnowledgeTagSlotSchema, + displayName: v2KnowledgeTagDisplayNameSchema, + fieldType: v2KnowledgeTagFieldTypeSchema, + originalDisplayName: v2KnowledgeTagDisplayNameSchema + .optional() + .describe('Previous display name, when this entry renames an existing definition.'), + }) + .strict() + .meta({ + id: 'V2BulkSaveKnowledgeTagDefinition', + title: 'Knowledge tag definition input', + description: 'One tag definition declared in a bulk save.', + }) + +export const v2BulkSaveKnowledgeTagDefinitionsBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + definitions: z + .array(v2BulkSaveKnowledgeTagDefinitionSchema) + .min(1, 'definitions must contain at least one tag definition') + .max( + ALL_TAG_SLOTS.length, + `definitions cannot contain more than ${ALL_TAG_SLOTS.length} entries — one per slot` + ) + .describe('Tag definitions to create or update on the knowledge base.'), + }) + .strict() +export type V2BulkSaveKnowledgeTagDefinitionsBody = z.input< + typeof v2BulkSaveKnowledgeTagDefinitionsBodySchema +> + +export const v2BulkSaveKnowledgeTagDefinitionsDataSchema = z + .object({ + created: z.array(v2KnowledgeTagSchema).describe('Definitions that did not previously exist.'), + updated: z.array(v2KnowledgeTagSchema).describe('Definitions whose slot was already defined.'), + errors: z + .array(z.string()) + .describe('Per-definition failures. A populated array still answers 200.'), + }) + .strict() + .meta({ + id: 'V2BulkSaveKnowledgeTagDefinitionsData', + title: 'Bulk save knowledge tag definitions data', + description: 'Definitions created and updated by a bulk tag-definition save.', + }) + +/** + * `unused` selects how much of the vocabulary the delete removes. + * + * It defaults to `true` — remove only the definitions no document still carries + * a value for — because that is the recoverable half: a definition with no + * values behind it can be recreated at no cost. `unused=false` deletes **every** + * definition on the knowledge base and clears its slot on every document and + * chunk, so it is stated explicitly or not at all. + * + * A real boolean rather than the `action` string literal this replaced. That + * literal was a guard, not a parameter: the delete used to hang off a + * document-scoped path where a whole-vocabulary wipe was reachable from a URL + * that named one document. The path now names the knowledge base the delete + * actually acts on, so both halves are legitimate and the guard is gone. + */ +export const v2DeleteKnowledgeTagDefinitionsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + unused: booleanQueryFlagSchema + .default(true) + .describe( + 'Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable.' + ), + }) + .strict() +export type V2DeleteKnowledgeTagDefinitionsQuery = z.output< + typeof v2DeleteKnowledgeTagDefinitionsQuerySchema +> + +export const v2DeleteKnowledgeTagDefinitionsDataSchema = z + .object({ + unused: z + .boolean() + .describe('Whether the delete was restricted to definitions no document still uses.'), + count: z.number().int().nonnegative().describe('Number of tag definitions removed.'), + }) + .strict() + .meta({ + id: 'V2DeleteKnowledgeTagDefinitionsData', + title: 'Delete knowledge tag definitions data', + description: 'Outcome of a knowledge-base tag-definition delete.', + }) + +export const v2CreateKnowledgeTagContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[knowledgeBaseId]/tags', + query: noInputSchema, + params: v2KnowledgeBaseParamsSchema, + body: v2CreateKnowledgeTagBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeTagSchema), + status: 201, + }, +}) + +export const v2UpdateKnowledgeTagContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]', + query: noInputSchema, + params: v2KnowledgeTagParamsSchema, + body: v2UpdateKnowledgeTagBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeTagSchema), + }, +}) + +export const v2DeleteKnowledgeTagContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]', + params: v2KnowledgeTagParamsSchema, + query: v2KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteKnowledgeTagDataSchema), + }, +}) + +export const v2GetNextKnowledgeTagSlotContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot', + params: v2KnowledgeBaseParamsSchema, + query: v2NextKnowledgeTagSlotQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2NextKnowledgeTagSlotDataSchema), + }, +}) + +/** + * Tag usage is a full-set list for the same reason the vocabulary is: one row + * per definition, and the fixed slot table bounds how many definitions exist. + */ +export const v2ListKnowledgeTagUsageContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[knowledgeBaseId]/tags/usage', + params: v2KnowledgeBaseParamsSchema, + query: v2KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeTagUsageSchema, { paged: false }), + }, +}) + +/** + * Bulk upsert of the knowledge base's tag vocabulary. + * + * On the knowledge base rather than a document: the write targets + * `knowledge_base_tag_definitions`, keyed by knowledge base and slot, and every + * document in the base sees the result. It used to hang off + * `PUT /knowledge/{knowledgeBaseId}/documents/{documentId}/tags`, where the document id was + * read only to find the knowledge base behind it and the path promised a + * document-scoped write it never performed. Tag *values* on one document are + * written by `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}` through its + * tag slots. + * + * `PUT`, not `PATCH`: every named slot is written to the body's declaration. + * Slots the body does not name are left alone, so this replaces per slot rather + * than across the vocabulary — which is also why it is a second verb on this + * path rather than a repeated `POST`, which defines exactly one. + */ +export const v2BulkSaveKnowledgeTagDefinitionsContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/knowledge/[knowledgeBaseId]/tags', + query: noInputSchema, + params: v2KnowledgeBaseParamsSchema, + body: v2BulkSaveKnowledgeTagDefinitionsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2BulkSaveKnowledgeTagDefinitionsDataSchema), + }, +}) + +/** Collection delete over the same vocabulary the bulk save writes. */ +export const v2DeleteKnowledgeTagDefinitionsContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[knowledgeBaseId]/tags', + params: v2KnowledgeBaseParamsSchema, + query: v2DeleteKnowledgeTagDefinitionsQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteKnowledgeTagDefinitionsDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index a45f021cb03..c0a3826d215 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { knowledgeBaseDataSchema } from '@/lib/api/contracts/knowledge/base' +import { + chunkingConfigFieldsSchema, + knowledgeBaseDataSchema, + withChunkingConfigRules, +} from '@/lib/api/contracts/knowledge/base' import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents' import { knowledgeBaseParamsSchema, @@ -15,7 +19,6 @@ import { import { defineRouteContract } from '@/lib/api/contracts/types' import { KNOWLEDGE_TAG_FILTER_OPERATORS_BY_FIELD_TYPE, - v1ChunkingConfigSchema, v1CreateKnowledgeBaseBodySchema, v1KnowledgeSearchBodySchema, v1KnowledgeWorkspaceQuerySchema, @@ -157,8 +160,17 @@ export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema .describe('Current email address of the knowledge base owner.') .meta({ examples: ['owner@example.com'] }), folderPath: v2FolderPathSchema - .describe('Canonical containing-folder path; `/` is the workspace root.') + .describe( + 'Canonical containing-folder path; `/` is the workspace root. Resolved against active folders only, so an archived knowledge base whose containing folder was archived with it reports `/`.' + ) .meta({ examples: ['/Product'] }), + /** Non-null only for a knowledge base a `DELETE` archived; see `scope` on the list. */ + deletedAt: v2TimestampSchema + .nullable() + .describe( + 'ISO 8601 timestamp when the knowledge base was archived by `DELETE /knowledge/{knowledgeBaseId}`, or null while the knowledge base is active. Only `GET /knowledge?scope=archived` returns knowledge bases with a non-null value.' + ) + .meta({ format: 'date-time', examples: ['2026-01-16T09:00:00Z'] }), }) .strict() .meta({ @@ -252,7 +264,7 @@ export type V2KnowledgeDocumentSummary = z.output -export const v2KnowledgeBaseParamsSchema = knowledgeBaseParamsSchema.extend({ - id: knowledgeBaseParamsSchema.shape.id.describe('Unique knowledge base identifier.'), +export const v2KnowledgeBaseParamsSchema = knowledgeBaseParamsSchema.omit({ id: true }).extend({ + knowledgeBaseId: knowledgeBaseParamsSchema.shape.id.describe('Unique knowledge base identifier.'), }) export type V2KnowledgeBaseParams = z.output -export const v2KnowledgeDocumentParamsSchema = knowledgeDocumentParamsSchema.extend({ - id: knowledgeDocumentParamsSchema.shape.id.describe('Unique knowledge base identifier.'), - documentId: knowledgeDocumentParamsSchema.shape.documentId.describe( - 'Unique knowledge document identifier.' - ), -}) +export const v2KnowledgeDocumentParamsSchema = knowledgeDocumentParamsSchema + .omit({ id: true }) + .extend({ + knowledgeBaseId: knowledgeDocumentParamsSchema.shape.id.describe( + 'Unique knowledge base identifier.' + ), + documentId: knowledgeDocumentParamsSchema.shape.documentId.describe( + 'Unique knowledge document identifier.' + ), + }) export type V2KnowledgeDocumentParams = z.output export const v2KnowledgeDocumentUploadParamsSchema = v2KnowledgeBaseParamsSchema.extend({ @@ -580,13 +596,30 @@ export const v2KnowledgeBaseSortFields = ['name', 'createdAt', 'updatedAt'] as c export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] /** - * KB list query: v1's workspace scope plus the v2 search/sort convention and a - * folder filter. v1's own list query stays untouched — it does not implement - * these, and advertising a param a route ignores is worse than not having it. + * Listing scopes. Two-valued, mirroring `v2FileScopeSchema`, `v2TableScopeSchema` + * and `v2WorkflowScopeSchema` rather than the three-valued internal + * `KnowledgeBaseScope`: `all` drops the `deleted_at` predicate entirely and + * degrades to a full workspace scan, and a caller that wants both sets can walk + * two pages. + */ +export const v2KnowledgeBaseScopeSchema = z.enum(['active', 'archived']) + +export type V2KnowledgeBaseScope = z.output + +/** + * KB list query: v1's workspace scope plus the v2 search/sort convention, a + * lifecycle scope, and a folder filter. v1's own list query stays untouched — it + * does not implement these, and advertising a param a route ignores is worse than + * not having it. */ export const v2ListKnowledgeBasesQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace whose knowledge bases should be listed.'), + scope: v2KnowledgeBaseScopeSchema + .default('active') + .describe( + 'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' + ), folderPath: v2FolderPathInputSchema .optional() .describe(`Restrict results to knowledge bases in this folder. ${V2_FOLDER_FILTER_MISS}`), @@ -598,26 +631,38 @@ export const v2ListKnowledgeBasesQuerySchema = z export type V2ListKnowledgeBasesQuery = z.output -const v2KnowledgeChunkingConfigInputSchema = v1ChunkingConfigSchema - .extend({ - maxSize: v1ChunkingConfigSchema.shape.maxSize - .describe('Maximum chunk size in tokens.') - .meta({ examples: [1024] }), - minSize: v1ChunkingConfigSchema.shape.minSize - .describe('Minimum chunk size in characters.') - .meta({ examples: [100] }), - overlap: v1ChunkingConfigSchema.shape.overlap - .describe('Number of overlapping characters between adjacent chunks.') - .meta({ examples: [200] }), - }) - .meta({ - id: 'V2KnowledgeChunkingConfigInput', - title: 'Knowledge chunking configuration input', - description: 'Chunking configuration applied when processing documents.', - }) +const v2KnowledgeChunkingConfigInputSchema = withChunkingConfigRules( + chunkingConfigFieldsSchema + .extend({ + maxSize: chunkingConfigFieldsSchema.shape.maxSize + .default(DEFAULT_CHUNKING_CONFIG.maxSize) + .describe('Maximum chunk size in tokens.') + .meta({ examples: [1024] }), + minSize: chunkingConfigFieldsSchema.shape.minSize + .default(DEFAULT_CHUNKING_CONFIG.minSize) + .describe('Minimum chunk size in characters.') + .meta({ examples: [100] }), + overlap: chunkingConfigFieldsSchema.shape.overlap + .default(DEFAULT_CHUNKING_CONFIG.overlap) + .describe('Number of overlapping characters between adjacent chunks.') + .meta({ examples: [200] }), + strategy: chunkingConfigFieldsSchema.shape.strategy.describe( + 'Chunking strategy applied during document processing. `regex` additionally requires `strategyOptions.pattern`.' + ), + strategyOptions: chunkingConfigFieldsSchema.shape.strategyOptions.describe( + 'Strategy-specific tuning options. `strictBoundaries` is accepted only with `strategy: "regex"`.' + ), + }) + .strict() +).meta({ + id: 'V2KnowledgeChunkingConfigInput', + title: 'Knowledge chunking configuration input', + description: + 'Chunking configuration applied when processing documents. On update this object is replaced wholesale rather than merged, so a caller preserving one key must read, modify, and write the whole object back.', +}) export const v2CreateKnowledgeBaseBodySchema = v1CreateKnowledgeBaseBodySchema - .safeExtend({ + .extend({ workspaceId: workspaceIdSchema.describe('Workspace in which to create the knowledge base.'), name: v1CreateKnowledgeBaseBodySchema.shape.name .describe('Human-readable knowledge base name.') @@ -667,8 +712,13 @@ export const v2UpdateKnowledgeBaseBodySchema = z }) /** - * KB list, keyset-paginated over the active sort. Search, folder filter, and - * sort all run in the query, not over its result. + * KB list, keyset-paginated over the active sort. Lifecycle scope, search, folder + * filter, and sort all run in the query, not over its result. + * + * Archived knowledge bases are `scope=archived` on this list rather than a + * sibling path, matching files, tables, and workflows. The two reads bind one + * semantic operation — the archived set is the same rows under a different + * `deleted_at` predicate, not a different resource. * * Before pagination this list returned `nextCursor` while rejecting `limit` and * `cursor` outright, so it advertised a pagination scheme no caller could @@ -698,7 +748,7 @@ export const v2CreateKnowledgeBaseContract = defineRouteContract({ export const v2GetKnowledgeBaseContract = defineRouteContract({ method: 'GET', - path: '/api/v2/knowledge/[id]', + path: '/api/v2/knowledge/[knowledgeBaseId]', params: v2KnowledgeBaseParamsSchema, query: v1KnowledgeWorkspaceQuerySchema .extend({ @@ -719,7 +769,7 @@ export const v2GetKnowledgeBaseContract = defineRouteContract({ */ export const v2UpdateKnowledgeBaseContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/knowledge/[id]', + path: '/api/v2/knowledge/[knowledgeBaseId]', query: noInputSchema, params: v2KnowledgeBaseParamsSchema, body: v2UpdateKnowledgeBaseBodySchema, @@ -731,7 +781,7 @@ export const v2UpdateKnowledgeBaseContract = defineRouteContract({ export const v2DeleteKnowledgeBaseContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/knowledge/[id]', + path: '/api/v2/knowledge/[knowledgeBaseId]', params: v2KnowledgeBaseParamsSchema, query: v1KnowledgeWorkspaceQuerySchema .extend({ @@ -887,7 +937,7 @@ export const v2KnowledgeSearchBodySchema = z ) .optional() .describe( - `Structured tag filters, at most ${MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS} of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching \`GET /api/v2/knowledge/{id}/documents\`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with \`GET /api/v2/knowledge/{id}/tags\`.` + `Structured tag filters, at most ${MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS} of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching \`GET /api/v2/knowledge/{knowledgeBaseId}/documents\`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with \`GET /api/v2/knowledge/{knowledgeBaseId}/tags\`.` ), searchMode: v1KnowledgeSearchBodySchema.shape.searchMode.describe( 'Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.' @@ -1044,7 +1094,7 @@ export type V2ListKnowledgeDocumentsQuery = z.output */ export const v2ListKnowledgeTagsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/knowledge/[id]/tags', + path: '/api/v2/knowledge/[knowledgeBaseId]/tags', params: v2KnowledgeBaseParamsSchema, query: v1KnowledgeWorkspaceQuerySchema .extend({ @@ -1342,7 +1397,7 @@ const v2UpdateKnowledgeDocumentDataSchema = z.union([ export const v2UpdateKnowledgeDocumentContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/knowledge/[id]/documents/[documentId]', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]', query: noInputSchema, params: v2KnowledgeDocumentParamsSchema, body: v2UpdateKnowledgeDocumentBodySchema, @@ -1361,7 +1416,7 @@ export const MAX_V2_BULK_KNOWLEDGE_DOCUMENTS = 100 * `enable` and `disable` only. A bulk `delete` is deliberately absent: the * underlying bulk operation records no semantic audit, so a public bulk delete * would remove a knowledge base's documents leaving no `DOCUMENT_DELETED` - * entries, while `DELETE /api/v2/knowledge/{id}/documents/{documentId}` audits + * entries, while `DELETE /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}` audits * every single deletion. Delete documents one request at a time. */ export const v2BulkKnowledgeDocumentsBodySchema = z @@ -1445,7 +1500,7 @@ export const v2BulkKnowledgeDocumentsDataSchema = z export const v2BulkUpdateKnowledgeDocumentsContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/knowledge/[id]/documents', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents', query: noInputSchema, params: v2KnowledgeBaseParamsSchema, body: v2BulkKnowledgeDocumentsBodySchema, @@ -1457,7 +1512,7 @@ export const v2BulkUpdateKnowledgeDocumentsContract = defineRouteContract({ export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/knowledge/[id]/documents/[documentId]', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]', params: v2KnowledgeDocumentParamsSchema, query: v1KnowledgeWorkspaceQuerySchema .extend({ @@ -1584,8 +1639,11 @@ export const v2KnowledgeConnectorDocumentSchema = z export type V2KnowledgeConnectorDocument = z.output export const v2KnowledgeConnectorParamsSchema = knowledgeConnectorParamsSchema + .omit({ id: true }) .extend({ - id: knowledgeConnectorParamsSchema.shape.id.describe('Knowledge base that owns the connector.'), + knowledgeBaseId: knowledgeConnectorParamsSchema.shape.id.describe( + 'Knowledge base that owns the connector.' + ), connectorId: knowledgeConnectorParamsSchema.shape.connectorId.describe( 'Connector selected for the operation.' ), @@ -1778,7 +1836,7 @@ export type V2KnowledgeConnectorDocumentsUpdateData = z.output< export const v2ListKnowledgeConnectorsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/knowledge/[id]/connectors', + path: '/api/v2/knowledge/[knowledgeBaseId]/connectors', params: v2KnowledgeBaseParamsSchema, query: v2ListKnowledgeConnectorsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeConnectorSchema) }, @@ -1786,7 +1844,7 @@ export const v2ListKnowledgeConnectorsContract = defineRouteContract({ export const v2CreateKnowledgeConnectorContract = defineRouteContract({ method: 'POST', - path: '/api/v2/knowledge/[id]/connectors', + path: '/api/v2/knowledge/[knowledgeBaseId]/connectors', params: v2KnowledgeBaseParamsSchema, query: noInputSchema, body: v2CreateKnowledgeConnectorBodySchema, @@ -1795,7 +1853,7 @@ export const v2CreateKnowledgeConnectorContract = defineRouteContract({ export const v2GetKnowledgeConnectorContract = defineRouteContract({ method: 'GET', - path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + path: '/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]', params: v2KnowledgeConnectorParamsSchema, query: v2KnowledgeConnectorWorkspaceQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorDetailSchema) }, @@ -1803,7 +1861,7 @@ export const v2GetKnowledgeConnectorContract = defineRouteContract({ export const v2UpdateKnowledgeConnectorContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + path: '/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]', params: v2KnowledgeConnectorParamsSchema, query: noInputSchema, body: v2UpdateKnowledgeConnectorBodySchema, @@ -1812,7 +1870,7 @@ export const v2UpdateKnowledgeConnectorContract = defineRouteContract({ export const v2DeleteKnowledgeConnectorContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + path: '/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]', params: v2KnowledgeConnectorParamsSchema, query: v2DeleteKnowledgeConnectorQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorDeleteDataSchema) }, @@ -1820,7 +1878,7 @@ export const v2DeleteKnowledgeConnectorContract = defineRouteContract({ export const v2SyncKnowledgeConnectorContract = defineRouteContract({ method: 'POST', - path: '/api/v2/knowledge/[id]/connectors/[connectorId]/sync', + path: '/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/sync', params: v2KnowledgeConnectorParamsSchema, query: noInputSchema, body: v2SyncKnowledgeConnectorBodySchema, @@ -1829,7 +1887,7 @@ export const v2SyncKnowledgeConnectorContract = defineRouteContract({ export const v2ListKnowledgeConnectorDocumentsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents', + path: '/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents', params: v2KnowledgeConnectorParamsSchema, query: v2ListKnowledgeConnectorDocumentsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeConnectorDocumentSchema) }, @@ -1837,7 +1895,7 @@ export const v2ListKnowledgeConnectorDocumentsContract = defineRouteContract({ export const v2UpdateKnowledgeConnectorDocumentsContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents', + path: '/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents', params: v2KnowledgeConnectorParamsSchema, query: noInputSchema, body: v2UpdateKnowledgeConnectorDocumentsBodySchema, @@ -1846,3 +1904,107 @@ export const v2UpdateKnowledgeConnectorDocumentsContract = defineRouteContract({ schema: v2DataResponse(v2KnowledgeConnectorDocumentsUpdateDataSchema), }, }) + +export const v2RestoreKnowledgeBaseBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + }) + .strict() + +export type V2RestoreKnowledgeBaseBody = z.input + +/** + * Restore is idempotent: restoring a knowledge base that is already active + * answers `200` with its current representation rather than `409`, so a retry + * after a dropped response cannot look like a failure. No audit entry is + * recorded for that no-op. + */ +export const v2RestoreKnowledgeBaseContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[knowledgeBaseId]/restore', + query: noInputSchema, + params: v2KnowledgeBaseParamsSchema, + body: v2RestoreKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseSchema), + }, +}) + +/** + * Indexes files already in workspace storage. + * + * Without it a file the server already holds has to be downloaded and + * re-uploaded byte-for-byte through `POST /api/v2/knowledge/{knowledgeBaseId}/documents` + * purely to be indexed. Each reference is authorized against the *file's* own + * canonical context, so naming a file the caller cannot read fails that entry + * rather than the request. + */ +export const v2AddWorkspaceFilesToKnowledgeBaseBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns both the files and the base.'), + fileReferences: z + .array(z.string().min(1, 'fileReferences entries cannot be empty')) + .min(1, 'fileReferences must contain at least one file') + .max( + MAX_V2_BULK_KNOWLEDGE_DOCUMENTS, + `fileReferences cannot contain more than ${MAX_V2_BULK_KNOWLEDGE_DOCUMENTS} files` + ) + .describe( + 'Workspace file identifiers or storage keys to index. Duplicates resolving to the same file are indexed once.' + ), + }) + .strict() + +export type V2AddWorkspaceFilesToKnowledgeBaseBody = z.input< + typeof v2AddWorkspaceFilesToKnowledgeBaseBodySchema +> + +export const v2AddedWorkspaceFileDocumentSchema = z + .object({ + documentId: z.string().describe('Identifier of the queued knowledge document.'), + filename: z.string().describe('Filename recorded on the knowledge document.'), + mimeType: z.string().describe('MIME type of the source workspace file.'), + fileSize: z.number().int().nonnegative().describe('File size in bytes.'), + }) + .strict() + .meta({ + id: 'V2AddedWorkspaceFileDocument', + title: 'Indexed workspace file', + description: 'A workspace file that was queued for indexing into a knowledge base.', + }) + +/** + * Partial success is a `200` with a populated `failed` array, not a `207`: v2 + * has exactly two body shapes and a multi-status is neither. A reference lands + * in `failed` when it names no readable file, exceeds the size limit, carries an + * unsupported type, or carries secret provenance that blocks ingestion. + */ +export const v2AddWorkspaceFilesToKnowledgeBaseDataSchema = z + .object({ + knowledgeBaseId: z.string().describe('Knowledge base the files were added to.'), + added: z + .array(v2AddedWorkspaceFileDocumentSchema) + .describe('Files queued for indexing, in request order.'), + failed: z + .array(z.string()) + .describe('References that could not be indexed, echoed exactly as they were sent.'), + }) + .strict() + .meta({ + id: 'V2AddWorkspaceFilesToKnowledgeBaseData', + title: 'Add workspace files data', + description: 'Outcome of indexing workspace files into a knowledge base.', + }) + +export const v2AddWorkspaceFilesToKnowledgeBaseContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files', + query: noInputSchema, + params: v2KnowledgeBaseParamsSchema, + body: v2AddWorkspaceFilesToKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2AddWorkspaceFilesToKnowledgeBaseDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/logs-stats.ts b/apps/sim/lib/api/contracts/v2/logs-stats.ts new file mode 100644 index 00000000000..e201d5de7df --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/logs-stats.ts @@ -0,0 +1,207 @@ +import { z } from 'zod' +import { MAX_STATS_SEGMENT_COUNT, MAX_STATS_WORKFLOWS } from '@/lib/api/contracts/logs' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + V2_FOLDER_FILTER_MISS, + v2DataResponse, + v2FolderPathInputSchema, + v2RunWindowBoundSchema, + v2TimestampSchema, +} from '@/lib/api/contracts/v2/shared' + +/** + * The default number of buckets, matching the first-party dashboard so the two + * surfaces summarize a workspace the same way by default. + */ +const DEFAULT_SEGMENT_COUNT = 72 + +/** + * Number of time buckets the window is divided into. + * + * Bounded on every side, and the bounds are the point. Unbounded, this value is + * the length of two densely materialized arrays — one per workflow series, one + * for the aggregate — so `1e9` allocates two billion-element arrays; `0` divides + * by zero deriving the bucket width; and a fractional value indexes between + * buckets. All three were reachable from the query string on the first-party + * schema this replaces, and each produced a 500 for a well-formed request. + */ +const v2SegmentCountSchema = z.coerce + .number() + .int('segmentCount must be a whole number') + .min(1, 'segmentCount must be at least 1') + .max(MAX_STATS_SEGMENT_COUNT, `segmentCount cannot exceed ${MAX_STATS_SEGMENT_COUNT}`) + .optional() + .default(DEFAULT_SEGMENT_COUNT) + .describe( + `Number of equal time buckets to divide the window into, from 1 to ${MAX_STATS_SEGMENT_COUNT}. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.` + ) + +const v2LogSegmentSchema = z + .object({ + timestamp: v2TimestampSchema.describe('ISO 8601 start of the bucket.'), + totalExecutions: z.number().describe('Runs that started inside the bucket.'), + successfulExecutions: z.number().describe('Runs in the bucket that did not error.'), + avgDurationMs: z + .number() + .describe( + "Mean duration of the bucket's runs in milliseconds, weighted by run count. Zero when no run in the bucket recorded a duration." + ), + }) + .meta({ + id: 'V2LogStatsSegment', + title: 'Log stats bucket', + description: 'Run counts and mean latency for one time bucket.', + }) + +const v2WorkflowLogStatsSchema = z + .object({ + workflowId: z + .string() + .describe( + 'Workflow identifier, or the literal `deleted` for the single series that collects runs whose workflow no longer exists.' + ), + workflowName: z.string().describe('Workflow name, or `Deleted Workflow`.'), + segments: z + .array(v2LogSegmentSchema) + .describe('One entry per bucket, in order, including buckets with no runs.'), + totalExecutions: z.number().describe('Runs for this workflow across the window.'), + totalSuccessful: z.number().describe('Runs for this workflow that did not error.'), + overallSuccessRate: z + .number() + .describe( + 'Percentage of runs that did not error, from 0 to 100. 100 when there were no runs.' + ), + }) + .meta({ + id: 'V2WorkflowLogStats', + title: 'Per-workflow log stats', + description: 'Bucketed run counts and success rate for one workflow.', + }) + +export const v2LogStatsSchema = z + .object({ + workflows: z + .array(v2WorkflowLogStatsSchema) + .describe( + `Per-workflow series, ordered by error rate descending then by name, capped at ${MAX_STATS_WORKFLOWS} entries.` + ), + workflowsTruncated: z + .boolean() + .describe( + `Whether \`workflows\` was cut to ${MAX_STATS_WORKFLOWS} entries. The workspace totals and \`aggregateSegments\` are computed from every workflow before the cut, so they stay exact either way.` + ), + aggregateSegments: z + .array(v2LogSegmentSchema) + .describe('Workspace-wide totals per bucket, in the same order as each workflow series.'), + totalRuns: z.number().describe('Runs in the window across the whole workspace.'), + totalErrors: z.number().describe('Runs in the window that errored.'), + avgLatency: z + .number() + .describe('Mean run duration in milliseconds across the window, weighted by run count.'), + timeBounds: z + .object({ + start: v2TimestampSchema.describe('ISO 8601 start of the window.'), + end: v2TimestampSchema.describe('ISO 8601 end of the window.'), + }) + .describe( + 'The window the buckets span: the oldest matching run through the later of the newest matching run and now. A workspace with no matching runs reports the trailing 24 hours.' + ), + segmentMs: z.number().describe('Width of one bucket in milliseconds.'), + }) + .meta({ + id: 'V2LogStats', + title: 'Execution log statistics', + description: + 'Bucketed success rate, error count, and latency for a workspace and each of its workflows.', + }) + +export type V2LogStats = z.output + +import { + V2_LOG_FOLDER_PATHS_MAX, + V2_LOG_TRIGGERS_MAX, + V2_LOG_WORKFLOW_IDS_MAX, +} from '@/lib/api/contracts/v2/logs' + +export const v2LogStatsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace whose execution statistics to summarize.'), + workflowIds: z + .string() + .describe( + `Comma-separated workflow identifiers to include. At most ${V2_LOG_WORKFLOW_IDS_MAX} entries. An empty entry is rejected.` + ) + .refine((value) => value.split(',').every((entry) => entry.length > 0), { + error: 'workflowIds must not contain an empty entry', + }) + .refine((value) => value.split(',').length <= V2_LOG_WORKFLOW_IDS_MAX, { + error: `workflowIds cannot contain more than ${V2_LOG_WORKFLOW_IDS_MAX} entries`, + }) + .optional(), + folderPaths: z + .string() + .describe( + `Comma-separated workflow folder paths to include. At most ${V2_LOG_FOLDER_PATHS_MAX} entries. A path covers its whole subtree. ${V2_FOLDER_FILTER_MISS}` + ) + .optional() + .transform((value, ctx) => { + if (value === undefined) return undefined + const paths = value.split(',') + if (paths.length === 0 || paths.some((path) => path.length === 0)) { + ctx.addIssue({ code: 'custom', message: 'folderPaths must contain valid paths' }) + return z.NEVER + } + if (paths.length > V2_LOG_FOLDER_PATHS_MAX) { + ctx.addIssue({ + code: 'custom', + message: `folderPaths cannot contain more than ${V2_LOG_FOLDER_PATHS_MAX} entries`, + }) + return z.NEVER + } + const normalized: string[] = [] + for (const path of paths) { + const parsed = v2FolderPathInputSchema.safeParse(path) + if (!parsed.success) { + ctx.addIssue({ code: 'custom', message: 'folderPaths must contain valid paths' }) + return z.NEVER + } + normalized.push(parsed.data) + } + return normalized.join(',') + }), + triggers: z + .string() + .describe( + 'Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter.' + ) + .refine((value) => value.split(',').every((entry) => entry.length > 0), { + error: 'triggers must not contain an empty entry', + }) + .refine((value) => value.split(',').length <= V2_LOG_TRIGGERS_MAX, { + error: `triggers cannot contain more than ${V2_LOG_TRIGGERS_MAX} entries`, + }) + .optional(), + level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), + startDate: v2RunWindowBoundSchema('startDate').optional(), + endDate: v2RunWindowBoundSchema('endDate').optional(), + segmentCount: v2SegmentCountSchema, + }) + .strict() + .refine( + (query) => + !query.startDate || + !query.endDate || + Date.parse(query.startDate) <= Date.parse(query.endDate), + { error: 'startDate must be before or equal to endDate', path: ['startDate'] } + ) + +export const v2GetLogStatsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/stats', + query: v2LogStatsQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2LogStatsSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 669969b01d4..fa685ebd789 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -10,15 +10,17 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { v1ListLogsQuerySchema } from '@/lib/api/contracts/v1/logs' import { V2_FOLDER_FILTER_MISS, + V2_SEARCH_MAX_LENGTH, v2CursorListResponse, v2DataResponse, v2FolderPathInputSchema, v2FolderPathSchema, v2PaginationFields, - v2RunOrderSchema, v2RunWindowBoundSchema, + v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' +import { v2RunFileSchema } from '@/lib/api/contracts/v2/workflows' import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types' /** @@ -30,6 +32,52 @@ import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types' const v2LogCostSchema = z .object({ total: z.number().describe('Total execution cost in USD.') }) .nullable() + .describe( + 'Cost charged for the run, or null when the run has neither a recorded total nor an itemized ledger.' + ) + +const v2CostLedgerItemSchema = z + .object({ + category: z + .enum(['fixed', 'model', 'tool']) + .describe( + "What the line is for: the run's base fee (`fixed`), one model's inference (`model`), or one metered tool or integration call (`tool`)." + ), + description: z + .string() + .describe('Human-readable name of the billed item, such as the model or tool id.'), + cost: z.number().describe('Amount billed for this line, in USD.'), + inputTokens: z + .number() + .optional() + .describe('Input tokens attributed to this line. Absent for lines that do not bill tokens.'), + outputTokens: z + .number() + .optional() + .describe('Output tokens attributed to this line. Absent for lines that do not bill tokens.'), + }) + .describe('One billed line of a run, folded across every event that billed it.') + +/** + * The run's cost, itemized. + * + * `items: null` and `items: []` are different answers and both are reachable, so + * a caller must not read one as the other. `null` means no ledger exists for the + * run — it predates the ledger, or it is a job run, whose costs are not recorded + * under the workflow source the ledger reads. An empty array would claim a + * ledger that itemizes to nothing. + */ +const v2LogDetailCostSchema = z + .object({ + total: z.number().describe('Total execution cost in USD.'), + items: z + .array(v2CostLedgerItemSchema) + .nullable() + .describe( + 'Billed lines reconciling to `total`, or null when no itemized ledger exists for the run.' + ), + }) + .nullable() .describe('Cost charged for the run, or null when unavailable.') /** * Both log endpoints pass `workflow_execution_logs.status` through verbatim, so the @@ -55,11 +103,45 @@ export const v2LogStatusSchema = z 'Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters.' ) -/** Execution `files` is a per-run jsonb array of attachment metadata. */ +/** + * One file a run produced, as the log surface publishes it. + * + * Exactly the run resource's own file projection minus `base64` — this read + * never inlines bytes — rather than a parallel shape, because the two describe + * the same objects and a caller addresses them through the same + * `downloadPath`. Reusing it also carries the reason the storage `key` is + * absent: a caller names a file by `id`, the key is re-derived server side from + * the run's recording on every request, and publishing it would let a request + * name bytes the run did not produce. + */ +const v2LogFileSchema = v2RunFileSchema.omit({ base64: true }).meta({ + id: 'V2LogFile', + title: 'Execution log file', + description: 'A file produced by the run this log records.', +}) + +/** + * Files the run produced. + * + * Projected from `workflow_execution_logs.files` rather than passed through. + * That column is a recording, not a manifest: the start block copies every + * caller-supplied input field verbatim into its output, so the blob carries + * input attachments and can carry a `UserFile` naming any storage key at all. + * Only entries whose key sits under this run's own + * `execution////…` prefix survive + * (`isRunOutputFileKey`), so a recorded entry that names another run's — or + * another tenant's — bytes is dropped rather than published. + * + * `null` and `[]` mean different things and both are reachable: `null` is a run + * that recorded no files at all, `[]` a run whose recorded entries were all + * input files or otherwise outside its own output scope. + */ const v2LogFilesSchema = z - .array(z.unknown().describe('Attachment metadata captured for the execution.')) + .array(v2LogFileSchema) .nullable() - .describe('Files attached to the run, or null when none are recorded.') + .describe( + "Files the run produced, or null when none are recorded. Only the run's own output files appear; input attachments a caller supplied are addressed through the files API instead." + ) /** * The graph as executed, sourced from the run's snapshot row. Declared loose because the @@ -94,6 +176,19 @@ const v2LogWorkflowSummarySchema = z.object({ export const v2LogListItemSchema = z .object({ + /** + * Which sequence the row came from. + * + * Load-bearing rather than decorative: a job run and a workflow run whose + * workflow was deleted both report `workflowId: null`, so without this a + * caller cannot tell "this run never had a workflow" from "its workflow is + * gone" — two different answers to the same field. + */ + kind: z + .enum(['workflow', 'job']) + .describe( + 'Whether the run executed a workflow or a Chat / Sim-agent job. Job runs appear only when `includeJobRuns=true`.' + ), runId: z.string().describe('Unique run identifier.'), workflowId: z.string().nullable().describe('Workflow identifier, or null when unavailable.'), deploymentVersionId: z @@ -180,13 +275,27 @@ export const v2LogDetailSchema = z workflowState: v2LogWorkflowStateSchema, /** Materialized block-level execution trace spans. */ traceSpans: traceSpansSchema.describe('Materialized block-level execution trace spans.'), - /** Materialized final output, when the execution produced one. */ + /** + * Both `.describe()` calls survive and both are required: the inner one + * documents the `unknown` branch of the nullable union, which the OpenAPI + * generator refuses to emit undescribed, and the outer one documents the + * union itself. Collapsing them to one fails `generate:openapi`. + */ finalOutput: z .unknown() .describe('Materialized final workflow output value.') .nullable() .describe('Materialized final workflow output, or null when none was produced.'), - cost: v2LogCostSchema, + cost: v2LogDetailCostSchema, + // untyped-response: workflow input is the caller-supplied trigger payload, which has no server-side schema + /** Doubly described for the reason `finalOutput` above is. */ + workflowInput: z + .unknown() + .describe('Caller-supplied trigger payload for the run.') + .nullable() + .describe( + 'Input the run was triggered with, or null when the run recorded none. Credential-bearing and PII-masked values are redacted the same way `finalOutput` is.' + ), createdAt: v2TimestampSchema.describe('ISO 8601 log creation timestamp.'), }) .meta({ @@ -277,22 +386,117 @@ function v2CostBoundSchema(field: 'minCost' | 'maxCost', bound: 'Minimum' | 'Max * malformed list into a narrower filter and reports nothing, which on a log * search reads as "those runs do not exist". */ -function v2CommaListSchema(field: 'workflowIds' | 'triggers', description: string) { +function v2CommaListSchema(field: 'workflowIds' | 'triggers', description: string, max: number) { return z .string() - .describe(description) + .describe(`${description} At most ${max} entries.`) .refine((value) => value.split(',').every((entry) => entry.length > 0), { error: `${field} must not contain an empty entry`, }) + .refine((value) => value.split(',').length <= max, { + error: `${field} cannot contain more than ${max} entries`, + }) } +/** + * Ceilings on the comma-separated filter lists. + * + * An id list compiles to `IN (...)`, so an unbounded one is an unbounded query + * string, an unbounded bind-parameter list, and a plan whose cost the caller + * rather than the server chooses. + * + * The numbers came from a JSON-body variant of this read that existed only + * inside the change that added them and never reached the wire, so do not go + * looking for a shipped endpoint that enforced them — these ceilings are this + * list's own, and `GET /logs/stats` reuses them so the two filter dialects over + * the same rows cannot drift. + */ +export const V2_LOG_WORKFLOW_IDS_MAX = 200 +export const V2_LOG_FOLDER_PATHS_MAX = 100 +export const V2_LOG_TRIGGERS_MAX = 100 + +/** + * The `status` filter: a comma-separated list of persisted execution statuses. + * + * Matched against exactly the column the responses report, rather than being + * derived from `level` + `ended_at` the way the first-party list's + * `running`/`pending` pseudo-levels are. A filter that selected on a different + * rule than the field it names would hand back rows whose reported `status` is + * not the one asked for — a wrong answer rather than a missing feature. `level` + * stays accepted and orthogonal: it is severity, this is lifecycle, and the two + * are ANDed. + */ +const v2LogStatusFilterSchema = z + .string() + .describe( + `Comma-separated execution statuses to include, from ${PERSISTED_WORKFLOW_EXECUTION_STATUSES.map((status) => `\`${status}\``).join(' | ')}. An empty entry is rejected. ANDed with \`level\`, which reports severity rather than lifecycle.` + ) + .refine((value) => value.split(',').every((entry) => entry.length > 0), { + error: 'status must not contain an empty entry', + }) + .refine((value) => value.split(',').length <= PERSISTED_WORKFLOW_EXECUTION_STATUSES.length, { + error: `status cannot contain more than ${PERSISTED_WORKFLOW_EXECUTION_STATUSES.length} entries`, + }) + .refine( + (value) => + value + .split(',') + .every((entry) => + (PERSISTED_WORKFLOW_EXECUTION_STATUSES as readonly string[]).includes(entry) + ), + { + error: `status: expected one or more of ${PERSISTED_WORKFLOW_EXECUTION_STATUSES.map((status) => `"${status}"`).join(' | ')}`, + } + ) + +/** + * The `workflowName` filter: a bounded, case-insensitive substring of the run's + * workflow name. + * + * Bounded for the reason every v2 `search` term is — it compiles to an unindexed + * `ILIKE` — and spelled `workflowName` rather than `search` because that is what + * it matches. The first-party `search` param matches an execution-id substring, + * which is not a search anyone would ask for over opaque identifiers and which + * `runId` already answers exactly, so it is deliberately not published here. + */ +const v2WorkflowNameFilterSchema = z + .string() + .trim() + .min(1, 'workflowName cannot be empty') + .max(V2_SEARCH_MAX_LENGTH, 'workflowName is too long') + .describe( + "Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable." + ) + +/** + * The columns `GET /api/v2/logs` can order by. + * + * Kept in step with `PUBLIC_LOG_SORT_FIELDS` in `lib/logs/public-queries.ts`, + * which turns each of these into a keyset; a member here with no keyset there + * is a sort the read cannot express. + */ +const v2LogSortFields = ['startedAt', 'durationMs', 'cost', 'status'] as const + +/** The shared `sortBy` + `sortOrder` pair, at this resource's defaults. */ +const v2LogSortFieldSchemas = v2SortFields(v2LogSortFields, { + sortBy: 'startedAt', + sortOrder: 'desc', +}) + export const v2ListLogsQuerySchema = v1ListLogsQuerySchema - .omit({ executionId: true, folderIds: true }) + /** + * `order` is dropped in favour of the surface-wide `sortBy` + `sortOrder` + * pair. v2 logs now sort by four columns, so a lone direction param cannot + * express the ordering, and carrying both would be two spellings of one thing + * with undefined precedence when both arrive. + */ + .omit({ executionId: true, folderIds: true, order: true }) .extend({ workspaceId: workspaceIdSchema.describe('Workspace whose execution logs should be returned.'), workflowIds: v2CommaListSchema( 'workflowIds', - 'Comma-separated workflow identifiers to include. An empty entry is rejected.' + 'Comma-separated workflow identifiers to include. An empty entry is rejected.', + V2_LOG_WORKFLOW_IDS_MAX ).optional(), /** * Not a closed enum, which is why an unrecognized member is not a 400. @@ -314,9 +518,18 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema */ triggers: v2CommaListSchema( 'triggers', - 'Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.' + 'Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.', + V2_LOG_TRIGGERS_MAX ).optional(), level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), + status: v2LogStatusFilterSchema.optional(), + workflowName: v2WorkflowNameFilterSchema.optional(), + includeJobRuns: booleanQueryFlagSchema + .describe( + 'Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.' + ) + .optional() + .default(false), startDate: v2RunWindowBoundSchema('startDate').optional(), endDate: v2RunWindowBoundSchema('endDate').optional(), runId: runIdSchema.describe('Exact run identifier to match.').optional(), @@ -328,7 +541,7 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema details: z .enum(['basic', 'full']) .describe( - 'Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.' + 'Response detail level. `full` adds the `workflow` summary to every workflow run; a job run never carries one, whatever this is set to. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.' ) .optional() .default('basic'), @@ -350,22 +563,20 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema outOfRange: 'clamp', description: 'Maximum log entries per page.', }), + ...v2LogSortFieldSchemas, /** - * Deliberate deviation from the v2 `sortBy` + `sortOrder` convention, and - * the same one `GET /workflows/{id}/runs` makes for the same reason: logs - * have exactly one sortable column (execution start time), so there is no - * `sortBy` to pair with. `order` is the published name and renaming it - * would break every caller, while accepting `sortOrder` as an alias would - * add a second spelling of one thing with undefined precedence when both - * arrive — so the split is documented rather than papered over. - * - * Shared with `GET /workflows/{id}/runs` so the two spell the enum the same - * way in the generated specs. + * Re-described rather than re-declared: the pair itself comes from the + * shared {@link v2SortFields} helper, and only the null-ordering caveat is + * local to this resource. */ - order: v2RunOrderSchema('execution'), + sortBy: v2LogSortFieldSchemas.sortBy.describe( + 'Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`.' + ), folderPaths: z .string() - .describe(`Comma-separated workflow folder paths to include. ${V2_FOLDER_FILTER_MISS}`) + .describe( + `Comma-separated workflow folder paths to include. At most ${V2_LOG_FOLDER_PATHS_MAX} entries. A path covers its whole subtree, so \`/prod\` also selects runs in \`/prod/nested\`. ${V2_FOLDER_FILTER_MISS}` + ) .optional() .transform((value, ctx) => { if (value === undefined) return undefined @@ -374,6 +585,13 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema ctx.addIssue({ code: 'custom', message: 'folderPaths must contain valid paths' }) return z.NEVER } + if (paths.length > V2_LOG_FOLDER_PATHS_MAX) { + ctx.addIssue({ + code: 'custom', + message: `folderPaths cannot contain more than ${V2_LOG_FOLDER_PATHS_MAX} entries`, + }) + return z.NEVER + } const normalizedPaths: string[] = [] for (const path of paths) { @@ -434,6 +652,21 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema path: ['minDurationMs'], }) } + /** + * `job_execution_logs` stores cost as a jsonb document and records no + * comparable persisted status, so ordering the two tables together on + * `durationMs`, `cost`, or `status` would compare values that do not mean + * the same thing. Silently dropping the job branch would answer a request + * the caller made with a sequence it did not ask for, so the combination is + * refused and the message names the way out. + */ + if (query.includeJobRuns && query.sortBy !== 'startedAt') { + ctx.addIssue({ + code: 'custom', + message: `sortBy: only "startedAt" can order job runs; drop includeJobRuns or sort by "startedAt"`, + path: ['sortBy'], + }) + } }) export const v2ListLogsContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index ce89b5e1bf1..a6e3e3329f9 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -177,7 +177,7 @@ export const v2McpServerDeleteDataSchema = z export type V2McpServerDeleteData = z.output export const v2McpServerParamsSchema = z.object({ - id: nonEmptyIdSchema.describe('Unique MCP server identifier.'), + mcpServerId: nonEmptyIdSchema.describe('Unique MCP server identifier.'), }) export type V2McpServerParams = z.output @@ -404,7 +404,7 @@ export const v2CreateMcpServerContract = defineRouteContract({ export const v2GetMcpServerContract = defineRouteContract({ method: 'GET', - path: '/api/v2/mcp-servers/[id]', + path: '/api/v2/mcp-servers/[mcpServerId]', params: v2McpServerParamsSchema, query: v2McpServerWorkspaceQuerySchema, response: { @@ -415,7 +415,7 @@ export const v2GetMcpServerContract = defineRouteContract({ export const v2UpdateMcpServerContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/mcp-servers/[id]', + path: '/api/v2/mcp-servers/[mcpServerId]', query: noInputSchema, params: v2McpServerParamsSchema, body: v2UpdateMcpServerBodySchema, @@ -427,7 +427,7 @@ export const v2UpdateMcpServerContract = defineRouteContract({ export const v2DeleteMcpServerContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/mcp-servers/[id]', + path: '/api/v2/mcp-servers/[mcpServerId]', params: v2McpServerParamsSchema, query: v2McpServerWorkspaceQuerySchema, response: { @@ -444,7 +444,7 @@ export const v2DeleteMcpServerContract = defineRouteContract({ */ export const v2ListMcpServerToolsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/mcp-servers/[id]/tools', + path: '/api/v2/mcp-servers/[mcpServerId]/tools', params: v2McpServerParamsSchema, query: v2ListMcpServerToolsQuerySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/meta.ts b/apps/sim/lib/api/contracts/v2/meta.ts new file mode 100644 index 00000000000..3405ad570ef --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/meta.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse, v2TimestampSchema } from '@/lib/api/contracts/v2/shared' + +export const v2ApiKeyTypeSchema = z + .enum(['personal', 'workspace']) + .describe( + 'Whether the calling key carries the full authority of its owner across their workspaces, or is scoped to one workspace.' + ) +export type V2ApiKeyType = z.output + +/** Facts about the calling credential itself. Nothing here is workspace data. */ +export const v2MetaSchema = z + .object({ + v2Enabled: z + .boolean() + .describe( + 'Whether this credential is in the v2 rollout cohort. When false, every other v2 endpoint answers 404 for this credential.' + ), + keyType: v2ApiKeyTypeSchema, + expiresAt: v2TimestampSchema + .nullable() + .describe('ISO 8601 timestamp when the calling key expires, or null when it never does.'), + }) + .meta({ + id: 'V2Meta', + title: 'API capabilities', + description: 'Rollout and lifecycle facts about the calling API key.', + }) +export type V2Meta = z.output + +export const v2GetMetaContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/meta', + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2MetaSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 26182ca344e..c5242b3e521 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -1,8 +1,8 @@ -import { z } from 'zod' import { v2GetAuditLogContract, v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' import { v2AbortFileUploadContract, v2BulkDeleteFilesContract, + v2BulkDownloadFilesContract, v2CompleteFileUploadContract, v2CreateFileContract, v2CreateFileFolderContract, @@ -13,12 +13,16 @@ import { v2DownloadFileContract, v2GetFileContract, v2GetFileShareContract, + v2GetFileUploadContract, v2ListFileFoldersContract, v2ListFilesContract, v2MoveFileItemsContract, + v2ReadFileTextContract, v2RelocateFileFolderContract, v2RenameFileContract, v2RestoreFileContract, + v2RestoreFileFolderContract, + v2UnzipFileContract, v2UpdateFileContentContract, v2UpsertFileShareContract, } from '@/lib/api/contracts/v2/files' @@ -34,6 +38,7 @@ import { RESOURCE_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, + V2_BINARY_DOWNLOAD_HEADERS, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, @@ -47,6 +52,7 @@ import { type OpenApiOperationMetadata, type OpenApiSuccessMetadata, } from '@/lib/api/openapi/types' +import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' const FILE_EXAMPLE = { id: 'wf_V1StGXR8z5jdHi6BmyT91', @@ -213,6 +219,42 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2GetFileUploadContract, + filesOperation({ + operationId: 'getFileUpload', + summary: 'Get File Upload', + description: `Read an upload session's current state — whether it is still accepting bytes, has finalized into a file, or has failed. Use it to decide whether an interrupted transfer can be resumed or should be abandoned. Like every other upload control leg it requires the signed upload token, and is re-authorized against the workspace on each call.`, + errors: RESOURCE_ERRORS, + success: { description: 'Current upload-session state.' }, + }), + { + params: documentedSchema( + v2GetFileUploadContract.params, + 'GetFileUploadParams', + 'Get upload path parameters', + 'Upload session selected for reading.' + ), + query: documentedSchema( + v2GetFileUploadContract.query, + 'GetFileUploadQuery', + 'Get upload query', + 'Workspace scope for the upload session.' + ), + headers: documentedSchema( + v2GetFileUploadContract.headers, + 'GetFileUploadHeaders', + 'Get upload headers', + 'Signed upload control token.' + ), + response: documentedSchema( + v2GetFileUploadContract.response.schema, + 'FileUploadResponse', + 'File upload response', + 'Current upload-session state.' + ), + } + ), defineOpenApiRoute( v2AbortFileUploadContract, filesOperation({ @@ -329,6 +371,90 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ReadFileTextContract, + filesOperation({ + operationId: 'readFileText', + summary: 'Read File Text', + description: `Return a file's text content, parsed out of the stored bytes. This reads the file; it writes nothing — \`POST /api/v2/files/{fileId}/unzip\` is the endpoint that unzips an archive into the workspace. Answers \`400\` for a type no parser supports, naming the raw-bytes download as the escape hatch, and \`413\` for a file above the extraction ceiling. A generated document is extracted from its compiled artifact rather than its generation source, so one still compiling answers \`409\` and is worth retrying. **\`degraded: true\` means text extraction did not fully succeed and the returned text may be incomplete or synthesized from the file's raw bytes. Do not treat it as authoritative content.** The legacy \`.doc\` and \`.ppt\` parsers deliberately return best-effort content rather than failing, so this flag — not an error status — is how a partial extraction is reported. \`truncated\` separately reports that a parser limit stopped extraction early.`, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The extracted text and its extraction-quality flags.' }, + }), + { + params: documentedSchema( + v2ReadFileTextContract.params, + 'ReadFileTextParams', + 'Read file text path parameters', + 'File selected for text extraction.' + ), + query: documentedSchema( + v2ReadFileTextContract.query, + 'ReadFileTextQuery', + 'Read file text query', + 'Workspace scope and optional source-byte ceiling.' + ), + response: documentedSchema( + v2ReadFileTextContract.response.schema, + 'FileTextResponse', + 'File text response', + 'Text extracted from a workspace file.' + ), + } + ), + defineOpenApiRoute( + v2BulkDownloadFilesContract, + filesOperation({ + operationId: 'bulkDownloadFiles', + summary: 'Bulk Download Files', + description: `Stream a selection of workspace files as one zip. Select files by id and folders by path, each as one comma-separated parameter; a folder expands to all its descendants, and a path matching no folder is rejected rather than ignored. Each parameter accepts at most ${MAX_ZIP_DOWNLOAD_FILES} entries — the same ceiling the resolved selection is held to — and the resolved file count and total bytes are checked again, so an over-broad selection answers \`400\` rather than streaming indefinitely. Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'The selected files as a zip archive.', + headers: ['Content-Type', 'Content-Disposition'], + contentTypes: ['application/zip'], + }, + }), + { + query: documentedSchema( + v2BulkDownloadFilesContract.query, + 'BulkDownloadFilesQuery', + 'Bulk download files query', + 'Workspace scope and the file and folder selection to archive.' + ), + } + ), + defineOpenApiRoute( + v2UnzipFileContract, + filesOperation({ + operationId: 'unzipFile', + summary: 'Unzip File', + description: + "Unzip a `.zip` archive into a new folder beside it and answer counts plus the destination path. This writes new workspace files; it does not read anything out of the archive into the response — `GET /api/v2/files/{fileId}/text` is the endpoint that returns a file's text. The unpacked files are deliberately not returned — a large archive would materialize thousands of objects into one response — so page `GET /api/v2/files?folderPath=...` for the contents. Unzipping is slow: an archive near the size ceiling can run for minutes. Only one unzip of a given archive runs at a time; a concurrent attempt answers `409`. Archives past the size ceiling, and runs that outrun their time budget, answer `413`.", + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'Counts and destination folder for the unpacked archive.' }, + }), + { + params: documentedSchema( + v2UnzipFileContract.params, + 'UnzipFileParams', + 'Unzip file path parameters', + 'Archive selected for unzipping.' + ), + query: v2UnzipFileContract.query, + body: documentedSchema( + v2UnzipFileContract.body, + 'UnzipFileBody', + 'Unzip file body', + 'Workspace scope for the archive.' + ), + response: documentedSchema( + v2UnzipFileContract.response.schema, + 'FileUnzipResponse', + 'Unzip file response', + 'Counts and destination folder for the unpacked archive.' + ), + } + ), defineOpenApiRoute( v2DownloadFileContract, filesOperation({ @@ -701,8 +827,9 @@ const declaredRoutes = [ filesOperation({ operationId: 'bulkDeleteFiles', summary: 'Delete Files', - description: 'Delete up to 1,000 workspace files in one operation.', - errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + description: + 'Delete up to 1,000 workspace files in one operation. This is the same soft delete as \`DELETE /api/v2/files/{fileId}\`: files are archived, not erased, and \`POST /api/v2/files/{fileId}/restore\` reverses each one.', + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'Count of deleted files.' }, }), { @@ -733,7 +860,7 @@ const declaredRoutes = [ filesOperation({ operationId: 'listFilesFolders', summary: 'List Folders', - description: `List workspace file folders with optional parent-path filtering and sorting. ${FULL_SET_LIST}`, + description: `List workspace file folders with optional parent-path filtering and sorting. Pass \`scope=archived\` to list folders a recursive \`DELETE\` soft-deleted, which is how a caller finds a path to hand to \`POST /api/v2/files/folders/restore\`. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, success: { description: 'Workspace file folders.' }, }), @@ -752,6 +879,32 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2RestoreFileFolderContract, + filesOperation({ + operationId: 'restoreFilesFolder', + summary: 'Restore Folder', + description: + 'Restore a soft-deleted folder and everything archived with it. `DELETE /api/v2/files/folders` archives recursively, so this is what makes a recursive delete recoverable: without it the archived files stay visible through `GET /api/v2/files?scope=archived` but the folder structure cannot be rebuilt. Address the folder by the path reported by `GET /api/v2/files/folders?scope=archived`; a path that is not archived answers `404`.', + errors: [...RESOURCE_CONFLICT_ERRORS], + success: { description: 'The restored folder and what it brought back.' }, + }), + { + query: v2RestoreFileFolderContract.query, + body: documentedSchema( + v2RestoreFileFolderContract.body, + 'RestoreFileFolderRequest', + 'Restore file folder request', + 'Workspace scope and archived folder path.' + ), + response: documentedSchema( + v2RestoreFileFolderContract.response.schema, + 'FileFolderRestoreResponse', + 'Folder restore response', + 'The restored folder and the counts of items it brought back.' + ), + } + ), defineOpenApiRoute( v2CreateFileFolderContract, filesOperation({ @@ -873,34 +1026,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ ], security: V2_API_KEY_SECURITY, securitySchemes: V2_API_KEY_SECURITY_SCHEMES, - headers: { - 'Content-Type': { - schema: z.string().meta({ - id: 'ContentTypeHeader', - title: 'Content type', - description: - 'MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.', - }), - }, - 'Content-Disposition': { - schema: z.string().meta({ - id: 'ContentDispositionHeader', - title: 'Content disposition', - description: 'Attachment disposition containing sanitized and RFC 5987 encoded filenames.', - }), - }, - 'Content-Length': { - schema: z - .string() - .regex(/^(0|[1-9]\d*)$/) - .meta({ - id: 'ContentLengthHeader', - title: 'Content length', - description: 'File size in bytes.', - }), - }, - ...V2_COMMON_HEADERS, - }, + headers: { ...V2_BINARY_DOWNLOAD_HEADERS, ...V2_COMMON_HEADERS }, errorSchema: V2_ERROR_SCHEMA, errorResponses: withErrorExamples({ Conflict: { message: 'File already exists' }, diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts new file mode 100644 index 00000000000..7fec5395db8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts @@ -0,0 +1,234 @@ +import { + v2BulkUpdateKnowledgeChunksContract, + v2CreateKnowledgeChunkContract, + v2DeleteKnowledgeChunkContract, + v2GetKnowledgeChunkContract, + v2ListKnowledgeChunksContract, + v2UpdateKnowledgeChunkContract, +} from '@/lib/api/contracts/v2/knowledge-chunks' +import { + KNOWLEDGE_WORKSPACE_ID, + knowledgeOperation, +} from '@/lib/api/contracts/v2/openapi/knowledge-shared' +import { + documentedSchema, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' + +/** + * Chunk operations of the knowledge OpenAPI document. + * + * Every one of them publishes `409` rather than only the resource set: a chunk + * is only addressable once its document has finished processing, and a document + * still pending, processing, or failed refuses the request on state rather than + * on the request itself. + */ + +const CONNECTOR_MANAGED = + 'Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"` — change the content at the source and re-sync, or exclude the document from the connector.' + +const DOCUMENT_NOT_READY = + 'A document that has not finished processing answers `409`; the message names the status it is in.' + +export const knowledgeChunkOpenApiRoutes = [ + defineOpenApiRoute( + v2ListKnowledgeChunksContract, + knowledgeOperation({ + operationId: 'listKnowledgeChunks', + summary: 'List Chunks', + description: `List the passages a document was split into, with content search, enabled filtering, sorting, and opaque cursor pagination. Tag values are projected by slot; resolve slots to display names with \`GET /api/v2/knowledge/{knowledgeBaseId}/tags\`. ${DOCUMENT_NOT_READY} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'A page of document chunks.' }, + }), + { + params: documentedSchema( + v2ListKnowledgeChunksContract.params, + 'ListKnowledgeChunksParams', + 'List knowledge chunks path parameters', + 'Knowledge base and document whose chunks should be listed.' + ), + query: documentedSchema( + v2ListKnowledgeChunksContract.query, + 'ListKnowledgeChunksQuery', + 'List knowledge chunks query', + 'Workspace, search, enabled filtering, sorting, and pagination options.' + ), + response: documentedSchema( + v2ListKnowledgeChunksContract.response.schema, + 'V2KnowledgeChunkListResponse', + 'Knowledge chunk list response', + 'A cursor-paginated page of document chunks.' + ), + } + ), + defineOpenApiRoute( + v2CreateKnowledgeChunkContract, + knowledgeOperation({ + operationId: 'createKnowledgeChunk', + summary: 'Create Chunk', + description: `Append a chunk to a document. The text is embedded before the response returns, so the chunk is searchable immediately, and it inherits the document's tag values and the next \`chunkIndex\`. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The created chunk.' }, + }), + { + query: v2CreateKnowledgeChunkContract.query, + params: documentedSchema( + v2CreateKnowledgeChunkContract.params, + 'CreateKnowledgeChunkParams', + 'Create knowledge chunk path parameters', + 'Knowledge base and document the chunk is appended to.' + ), + body: documentedSchema( + v2CreateKnowledgeChunkContract.body, + 'CreateKnowledgeChunkRequest', + 'Create knowledge chunk request', + 'Workspace scope and the text to embed.', + [ + { + workspaceId: KNOWLEDGE_WORKSPACE_ID, + content: 'To reset your password, open Settings and choose Security.', + }, + ] + ), + response: documentedSchema( + v2CreateKnowledgeChunkContract.response.schema, + 'V2KnowledgeChunkResponse', + 'Knowledge chunk response', + 'A single document chunk.' + ), + } + ), + defineOpenApiRoute( + v2BulkUpdateKnowledgeChunksContract, + knowledgeOperation({ + operationId: 'bulkUpdateKnowledgeChunks', + summary: 'Bulk Update Chunks', + description: `Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is skipped rather than failing the request, so \`processed\` is the authoritative count. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'Outcome of the bulk chunk operation.' }, + }), + { + query: v2BulkUpdateKnowledgeChunksContract.query, + params: documentedSchema( + v2BulkUpdateKnowledgeChunksContract.params, + 'BulkUpdateKnowledgeChunksParams', + 'Bulk knowledge chunk path parameters', + 'Knowledge base and document whose chunks are updated.' + ), + body: documentedSchema( + v2BulkUpdateKnowledgeChunksContract.body, + 'BulkUpdateKnowledgeChunksRequest', + 'Bulk knowledge chunk request', + 'Workspace scope, the operation to apply, and the chunks to apply it to.', + [ + { + workspaceId: KNOWLEDGE_WORKSPACE_ID, + operation: 'disable', + chunkIds: ['4c1f9e77-2b3a-4f8d-9e10-6a2c8d4b1e05'], + }, + ] + ), + response: documentedSchema( + v2BulkUpdateKnowledgeChunksContract.response.schema, + 'V2BulkKnowledgeChunksResponse', + 'Bulk knowledge chunk response', + 'Counts and per-chunk failures from a bulk chunk operation.' + ), + } + ), + defineOpenApiRoute( + v2GetKnowledgeChunkContract, + knowledgeOperation({ + operationId: 'getKnowledgeChunk', + summary: 'Get Chunk', + description: `Retrieve one chunk of a document, including the exact text that was embedded. ${DOCUMENT_NOT_READY} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The requested chunk.' }, + }), + { + params: documentedSchema( + v2GetKnowledgeChunkContract.params, + 'GetKnowledgeChunkParams', + 'Get knowledge chunk path parameters', + 'Knowledge base, document, and chunk selected for retrieval.' + ), + query: documentedSchema( + v2GetKnowledgeChunkContract.query, + 'GetKnowledgeChunkQuery', + 'Get knowledge chunk query', + 'Workspace scope for the knowledge base.' + ), + response: documentedSchema( + v2GetKnowledgeChunkContract.response.schema, + 'V2KnowledgeChunkResponse', + 'Knowledge chunk response', + 'A single document chunk.' + ), + } + ), + defineOpenApiRoute( + v2UpdateKnowledgeChunkContract, + knowledgeOperation({ + operationId: 'updateKnowledgeChunk', + summary: 'Update Chunk', + description: `Correct a chunk's text or take it out of search. Changing \`content\` re-embeds the chunk and re-derives the document's token and character counts, so the correction reaches search immediately; disabling keeps the chunk indexed. ${CONNECTOR_MANAGED} ${DOCUMENT_NOT_READY} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The updated chunk.' }, + }), + { + query: v2UpdateKnowledgeChunkContract.query, + params: documentedSchema( + v2UpdateKnowledgeChunkContract.params, + 'UpdateKnowledgeChunkParams', + 'Update knowledge chunk path parameters', + 'Knowledge base, document, and chunk selected for update.' + ), + body: documentedSchema( + v2UpdateKnowledgeChunkContract.body, + 'UpdateKnowledgeChunkRequest', + 'Update knowledge chunk request', + 'Workspace scope and the fields to update. At least one is required.', + [{ workspaceId: KNOWLEDGE_WORKSPACE_ID, enabled: false }] + ), + response: documentedSchema( + v2UpdateKnowledgeChunkContract.response.schema, + 'V2KnowledgeChunkResponse', + 'Knowledge chunk response', + 'A single document chunk.' + ), + } + ), + defineOpenApiRoute( + v2DeleteKnowledgeChunkContract, + knowledgeOperation({ + operationId: 'deleteKnowledgeChunk', + summary: 'Delete Chunk', + description: `Permanently remove one chunk and subtract it from the document's counts. Deleting does not renumber the remaining chunks, so \`chunkIndex\` values stay stable but become non-contiguous. ${CONNECTOR_MANAGED} ${DOCUMENT_NOT_READY} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Chunk deletion acknowledgement.' }, + }), + { + params: documentedSchema( + v2DeleteKnowledgeChunkContract.params, + 'DeleteKnowledgeChunkParams', + 'Delete knowledge chunk path parameters', + 'Knowledge base, document, and chunk selected for deletion.' + ), + query: documentedSchema( + v2DeleteKnowledgeChunkContract.query, + 'DeleteKnowledgeChunkQuery', + 'Delete knowledge chunk query', + 'Workspace scope for the knowledge base.' + ), + response: documentedSchema( + v2DeleteKnowledgeChunkContract.response.schema, + 'V2KnowledgeDeleteResponse', + 'Knowledge deletion response', + 'Deletion acknowledgement containing the removed resource identifier.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge-shared.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge-shared.ts new file mode 100644 index 00000000000..bdbc809d3c7 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge-shared.ts @@ -0,0 +1,33 @@ +import { type ErrorResponseId, RATE_LIMIT_HEADERS } from '@/lib/api/contracts/v2/openapi/shared' +import type { OpenApiOperationMetadata, OpenApiSuccessMetadata } from '@/lib/api/openapi/types' + +/** + * Shared pieces of the knowledge OpenAPI document. + * + * The document is composed from three modules — knowledge bases and documents, + * chunks, and tag definitions — and every operation in all three has to publish + * the same tag and the same rate-limit headers. Holding the helper here rather + * than in one of the modules keeps the composition acyclic. + */ + +export const KNOWLEDGE_WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' +export const KNOWLEDGE_BASE_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7' +export const KNOWLEDGE_DOCUMENT_ID = 'b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12' +export const KNOWLEDGE_CHUNK_ID = '4c1f9e77-2b3a-4f8d-9e10-6a2c8d4b1e05' +export const KNOWLEDGE_TAG_ID = '3f0d2b18-9a41-4d6e-8c52-1b7e5a0f9c34' + +export function knowledgeOperation( + operation: Omit & { + errors: readonly ErrorResponseId[] + success: OpenApiSuccessMetadata + } +): OpenApiOperationMetadata { + return { + ...operation, + tags: ['Knowledge Bases'], + success: { + ...operation.success, + headers: [...(operation.success.headers ?? []), ...RATE_LIMIT_HEADERS], + }, + } +} diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge-tags.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge-tags.ts new file mode 100644 index 00000000000..5192d3a72ee --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge-tags.ts @@ -0,0 +1,257 @@ +import { + v2BulkSaveKnowledgeTagDefinitionsContract, + v2CreateKnowledgeTagContract, + v2DeleteKnowledgeTagContract, + v2DeleteKnowledgeTagDefinitionsContract, + v2GetNextKnowledgeTagSlotContract, + v2ListKnowledgeTagUsageContract, + v2UpdateKnowledgeTagContract, +} from '@/lib/api/contracts/v2/knowledge-tags' +import { + KNOWLEDGE_WORKSPACE_ID, + knowledgeOperation, +} from '@/lib/api/contracts/v2/openapi/knowledge-shared' +import { + documentedSchema, + FULL_SET_LIST, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' + +/** + * Tag-definition write operations of the knowledge OpenAPI document. + * + * The read half (`listKnowledgeTags`) lives beside the knowledge-base + * operations because it is the mapping every document read and tag filter + * depends on; these are the writes that let a caller create that mapping in the + * first place. + */ + +const TAG_LOOP = + 'Define a tag here, write its `tagSlot` on a document with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`, then filter by its `displayName` on the document list or on search.' + +export const knowledgeTagOpenApiRoutes = [ + defineOpenApiRoute( + v2CreateKnowledgeTagContract, + knowledgeOperation({ + operationId: 'createKnowledgeTag', + summary: 'Create Tag', + description: `Define one tag on a knowledge base; use \`PUT\` on this path to declare several at once. ${TAG_LOOP} Omit \`tagSlot\` to take the next free slot for the field type; a field type with no free slot left is a \`400\` naming it, since the remedy is a different type or a deleted definition rather than a retry. A \`tagSlot\` already taken, or a \`displayName\` already defined on this knowledge base, is a \`409\` naming which of the two to change. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The created tag definition.' }, + }), + { + query: v2CreateKnowledgeTagContract.query, + params: documentedSchema( + v2CreateKnowledgeTagContract.params, + 'CreateKnowledgeTagParams', + 'Create knowledge tag path parameters', + 'Knowledge base the tag is defined on.' + ), + body: documentedSchema( + v2CreateKnowledgeTagContract.body, + 'CreateKnowledgeTagRequest', + 'Create knowledge tag request', + 'Workspace scope, display name, field type, and optional slot.', + [{ workspaceId: KNOWLEDGE_WORKSPACE_ID, displayName: 'category', fieldType: 'text' }] + ), + response: documentedSchema( + v2CreateKnowledgeTagContract.response.schema, + 'V2KnowledgeTagResponse', + 'Knowledge tag response', + 'A single tag definition.' + ), + } + ), + defineOpenApiRoute( + v2UpdateKnowledgeTagContract, + knowledgeOperation({ + operationId: 'updateKnowledgeTag', + summary: 'Update Tag', + description: `Rename a tag, or change the value type stored in its slot. Renaming changes the name filters and document reads use; the slot, and every value in it, is untouched. A tag's slot is fixed for its lifetime and each slot holds one kind of value, so \`fieldType\` can only change to another type valid for the slot the tag already occupies — anything else is a \`400\`, and the way to get a tag of that type is to create one. A name another tag on this knowledge base already holds is a \`409\`. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The updated tag definition.' }, + }), + { + query: v2UpdateKnowledgeTagContract.query, + params: documentedSchema( + v2UpdateKnowledgeTagContract.params, + 'UpdateKnowledgeTagParams', + 'Update knowledge tag path parameters', + 'Knowledge base and tag definition selected for update.' + ), + body: documentedSchema( + v2UpdateKnowledgeTagContract.body, + 'UpdateKnowledgeTagRequest', + 'Update knowledge tag request', + 'Workspace scope and the fields to update. At least one is required.', + [{ workspaceId: KNOWLEDGE_WORKSPACE_ID, displayName: 'topic' }] + ), + response: documentedSchema( + v2UpdateKnowledgeTagContract.response.schema, + 'V2KnowledgeTagResponse', + 'Knowledge tag response', + 'A single tag definition.' + ), + } + ), + defineOpenApiRoute( + v2DeleteKnowledgeTagContract, + knowledgeOperation({ + operationId: 'deleteKnowledgeTag', + summary: 'Delete Tag', + description: `Remove a tag definition and clear its slot across every document and chunk in the knowledge base. Without a definition the slot has no meaning, so leaving the values would strand them under a raw slot name — this is not recoverable. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Tag deletion acknowledgement.' }, + }), + { + params: documentedSchema( + v2DeleteKnowledgeTagContract.params, + 'DeleteKnowledgeTagParams', + 'Delete knowledge tag path parameters', + 'Knowledge base and tag definition selected for deletion.' + ), + query: documentedSchema( + v2DeleteKnowledgeTagContract.query, + 'DeleteKnowledgeTagQuery', + 'Delete knowledge tag query', + 'Workspace scope for the knowledge base.' + ), + response: documentedSchema( + v2DeleteKnowledgeTagContract.response.schema, + 'V2DeleteKnowledgeTagResponse', + 'Delete knowledge tag response', + 'Acknowledgement naming the deleted definition and the slot it freed.' + ), + } + ), + defineOpenApiRoute( + v2GetNextKnowledgeTagSlotContract, + knowledgeOperation({ + operationId: 'getNextKnowledgeTagSlot', + summary: 'Get Next Tag Slot', + description: `Report which slot a create would take for a field type, and how many are left. Advisory rather than a claim: nothing is reserved, and \`POST /api/v2/knowledge/{knowledgeBaseId}/tags\` assigns the same slot when \`tagSlot\` is omitted. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'Slot availability for the requested field type.' }, + }), + { + params: documentedSchema( + v2GetNextKnowledgeTagSlotContract.params, + 'GetNextKnowledgeTagSlotParams', + 'Next knowledge tag slot path parameters', + 'Knowledge base whose slot availability is reported.' + ), + query: documentedSchema( + v2GetNextKnowledgeTagSlotContract.query, + 'GetNextKnowledgeTagSlotQuery', + 'Next knowledge tag slot query', + 'Workspace scope and the field type to count slots for.' + ), + response: documentedSchema( + v2GetNextKnowledgeTagSlotContract.response.schema, + 'V2NextKnowledgeTagSlotResponse', + 'Next knowledge tag slot response', + 'Slot availability for one tag field type.' + ), + } + ), + defineOpenApiRoute( + v2ListKnowledgeTagUsageContract, + knowledgeOperation({ + operationId: 'listKnowledgeTagUsage', + summary: 'List Tag Usage', + description: `Report how many documents and chunks carry a value for each defined tag, so a caller can tell a tag that is actually populated from one that was only declared. ${FULL_SET_LIST} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'Usage counts for every defined tag.' }, + }), + { + params: documentedSchema( + v2ListKnowledgeTagUsageContract.params, + 'ListKnowledgeTagUsageParams', + 'Knowledge tag usage path parameters', + 'Knowledge base whose tag usage is reported.' + ), + query: documentedSchema( + v2ListKnowledgeTagUsageContract.query, + 'ListKnowledgeTagUsageQuery', + 'Knowledge tag usage query', + 'Workspace scope for the knowledge base.' + ), + response: documentedSchema( + v2ListKnowledgeTagUsageContract.response.schema, + 'V2KnowledgeTagUsageListResponse', + 'Knowledge tag usage response', + 'Usage counts for every tag defined on one knowledge base.' + ), + } + ), + defineOpenApiRoute( + v2BulkSaveKnowledgeTagDefinitionsContract, + knowledgeOperation({ + operationId: 'bulkSaveKnowledgeTagDefinitions', + summary: 'Bulk Save Tag Definitions', + description: `Declare, in one request, several of the knowledge base's tag definitions. \`POST\` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in \`originalDisplayName\`; that is the only form that edits one in place. Without it the entry is a create, and a requested \`tagSlot\` another name already holds is not overwritten — the definition is created in the next free slot of its \`fieldType\` instead, so read the returned entry for the slot actually assigned. A create whose \`displayName\` already exists is refused in \`errors\`. Per-definition failures are reported in \`errors\` and still answer \`200\`. This writes the vocabulary, not one document's tag values — set those with \`PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'Definitions created and updated by the save.' }, + }), + { + query: v2BulkSaveKnowledgeTagDefinitionsContract.query, + params: documentedSchema( + v2BulkSaveKnowledgeTagDefinitionsContract.params, + 'BulkSaveKnowledgeTagDefinitionsParams', + 'Bulk save tag definitions path parameters', + 'Knowledge base whose tag vocabulary is written.' + ), + body: documentedSchema( + v2BulkSaveKnowledgeTagDefinitionsContract.body, + 'BulkSaveKnowledgeTagDefinitionsRequest', + 'Bulk save tag definitions request', + 'Workspace scope and the tag definitions to create or update.', + [ + { + workspaceId: KNOWLEDGE_WORKSPACE_ID, + definitions: [{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }], + }, + ] + ), + response: documentedSchema( + v2BulkSaveKnowledgeTagDefinitionsContract.response.schema, + 'V2BulkSaveKnowledgeTagDefinitionsResponse', + 'Bulk save tag definitions response', + 'Definitions created and updated, with any per-definition failures.' + ), + } + ), + defineOpenApiRoute( + v2DeleteKnowledgeTagDefinitionsContract, + knowledgeOperation({ + operationId: 'deleteKnowledgeTagDefinitions', + summary: 'Delete Tag Definitions', + description: `Remove tag definitions from the knowledge base. \`unused\` defaults to \`true\`, which removes only the definitions no document still carries a value for — the recoverable half, since a definition with nothing behind it can simply be redefined. Pass \`unused=false\` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. Delete one definition at a time with \`DELETE /api/v2/knowledge/{knowledgeBaseId}/tags/{tagId}\`. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'Number of tag definitions removed.' }, + }), + { + params: documentedSchema( + v2DeleteKnowledgeTagDefinitionsContract.params, + 'DeleteKnowledgeTagDefinitionsParams', + 'Delete tag definitions path parameters', + 'Knowledge base whose tag definitions are removed.' + ), + query: documentedSchema( + v2DeleteKnowledgeTagDefinitionsContract.query, + 'DeleteKnowledgeTagDefinitionsQuery', + 'Delete tag definitions query', + 'Workspace scope and how much of the vocabulary to remove.' + ), + response: documentedSchema( + v2DeleteKnowledgeTagDefinitionsContract.response.schema, + 'V2DeleteKnowledgeTagDefinitionsResponse', + 'Delete tag definitions response', + 'Number of tag definitions that were removed.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 3ac09dd84ef..a57abb5321d 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -1,5 +1,6 @@ import { v2AbortKnowledgeDocumentUploadContract, + v2AddWorkspaceFilesToKnowledgeBaseContract, v2BulkUpdateKnowledgeDocumentsContract, v2CompleteKnowledgeDocumentUploadContract, v2CreateKnowledgeBaseContract, @@ -21,6 +22,7 @@ import { v2ListKnowledgeFoldersContract, v2ListKnowledgeTagsContract, v2RelocateKnowledgeFolderContract, + v2RestoreKnowledgeBaseContract, v2SearchKnowledgeContract, v2SyncKnowledgeConnectorContract, v2UpdateKnowledgeBaseContract, @@ -30,6 +32,8 @@ import { v2UploadKnowledgeDocumentContract, v2UploadKnowledgeDocumentFormSchema, } from '@/lib/api/contracts/v2/knowledge' +import { knowledgeChunkOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/knowledge-chunks' +import { knowledgeTagOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/knowledge-tags' import { documentedSchema, type ErrorResponseId, @@ -109,7 +113,7 @@ const declaredRoutes = [ knowledgeOperation({ operationId: 'listKnowledgeBases', summary: 'List Knowledge Bases', - description: `List knowledge bases in a workspace with folder filtering, search, sorting, and opaque cursor pagination. ${FOLDER_TREE_TOO_LARGE}`, + description: `List knowledge bases in a workspace with lifecycle scope, folder filtering, search, sorting, and opaque cursor pagination. \`scope\` defaults to \`active\`; pass \`archived\` to list knowledge bases a \`DELETE\` archived, each carrying the \`deletedAt\` instant it was archived, and recover one with \`POST /api/v2/knowledge/{knowledgeBaseId}/restore\`. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of knowledge bases.' }, }), @@ -118,7 +122,7 @@ const declaredRoutes = [ v2ListKnowledgeBasesContract.query, 'ListKnowledgeBasesQuery', 'List knowledge bases query', - 'Workspace, folder, search, and sorting options for listing knowledge bases.' + 'Workspace, lifecycle scope, folder, search, and sorting options for listing knowledge bases.' ), response: documentedSchema( v2ListKnowledgeBasesContract.response.schema, @@ -603,7 +607,7 @@ const declaredRoutes = [ operationId: 'listKnowledgeDocuments', summary: 'List Documents', description: - 'List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.', + 'List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.', errors: RESOURCE_ERRORS, success: { description: 'A page of knowledge documents.' }, }), @@ -633,7 +637,7 @@ const declaredRoutes = [ knowledgeOperation({ operationId: 'bulkUpdateKnowledgeDocuments', summary: 'Bulk Enable or Disable Documents', - description: `Enable or disable many documents in one request, either by identifier or, with \`selectAll\`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with \`DELETE /api/v2/knowledge/{id}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`, + description: `Enable or disable many documents in one request, either by identifier or, with \`selectAll\`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with \`DELETE /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The number and identifiers of the documents that changed.' }, }), @@ -844,7 +848,7 @@ const declaredRoutes = [ summary: 'Complete Document Upload', description: 'Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.', - errors: [...WORKSPACE_ERRORS, 'UsageLimitExceeded', 'NotFound', 'Conflict'], + errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], success: { description: 'The completed upload and queued document.' }, }), { @@ -909,7 +913,7 @@ const declaredRoutes = [ knowledgeOperation({ operationId: 'updateKnowledgeDocument', summary: 'Update Document', - description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with \`GET /api/v2/knowledge/{id}/tags\`. The returned document omits the connector provenance the detail read carries. ${WORKSPACE_API_KEY_DENIED}`, + description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with \`GET /api/v2/knowledge/{knowledgeBaseId}/tags\`. The returned document omits the connector provenance the detail read carries. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated document, or the requeue acknowledgement.' }, }), @@ -1073,6 +1077,73 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2RestoreKnowledgeBaseContract, + knowledgeOperation({ + operationId: 'restoreKnowledgeBase', + summary: 'Restore Knowledge Base', + description: `Un-archive a soft-deleted knowledge base along with its documents and connectors. Idempotent: a knowledge base that is already active is returned unchanged with no audit entry recorded. Restoring into an archived workspace is a \`409\`, and a knowledge base whose folder is still archived is returned to the workspace root. ${FOLDER_TREE_TOO_LARGE}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The restored knowledge base.' }, + }), + { + query: v2RestoreKnowledgeBaseContract.query, + params: documentedSchema( + v2RestoreKnowledgeBaseContract.params, + 'RestoreKnowledgeBaseParams', + 'Restore knowledge base path parameters', + 'Knowledge base selected for restoration.' + ), + body: documentedSchema( + v2RestoreKnowledgeBaseContract.body, + 'RestoreKnowledgeBaseRequest', + 'Restore knowledge base request', + 'Workspace scope for the knowledge base.', + [{ workspaceId: WORKSPACE_ID }] + ), + response: documentedSchema( + v2RestoreKnowledgeBaseContract.response.schema, + 'V2KnowledgeBaseResponse', + 'Knowledge base response', + 'A single knowledge base.' + ), + } + ), + defineOpenApiRoute( + v2AddWorkspaceFilesToKnowledgeBaseContract, + knowledgeOperation({ + operationId: 'addWorkspaceFilesToKnowledgeBase', + summary: 'Index Workspace Files', + description: + 'Index files the workspace already stores, without re-uploading their bytes. Each reference is authorized against the file it names, so a reference the caller cannot read, one over the 100 MB document limit, or one whose type is not supported is reported in `failed` while the rest are queued — a partial outcome is a `200`, not a multi-status. A queued document starts in the `pending` processing state; the entries returned here carry only its identity, so read `GET /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}` for its current state. A workspace API key is rejected with `403`; use a personal API key.', + errors: [...RESOURCE_ERRORS, 'UsageLimitExceeded'], + success: { description: 'Files queued for indexing, with any that could not be.' }, + }), + { + query: v2AddWorkspaceFilesToKnowledgeBaseContract.query, + params: documentedSchema( + v2AddWorkspaceFilesToKnowledgeBaseContract.params, + 'AddWorkspaceFilesToKnowledgeBaseParams', + 'Index workspace files path parameters', + 'Knowledge base the files are indexed into.' + ), + body: documentedSchema( + v2AddWorkspaceFilesToKnowledgeBaseContract.body, + 'AddWorkspaceFilesToKnowledgeBaseRequest', + 'Index workspace files request', + 'Workspace scope and the workspace file references to index.', + [{ workspaceId: WORKSPACE_ID, fileReferences: ['handbook.pdf'] }] + ), + response: documentedSchema( + v2AddWorkspaceFilesToKnowledgeBaseContract.response.schema, + 'V2AddWorkspaceFilesToKnowledgeBaseResponse', + 'Index workspace files response', + 'Documents queued for indexing and references that could not be.' + ), + } + ), + ...knowledgeChunkOpenApiRoutes, + ...knowledgeTagOpenApiRoutes, ] as const const routes = declaredRoutes.map(withRequestBodyErrors) diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index ee9eeb4053b..f39c21cf054 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -1,8 +1,10 @@ import { v2GetLogContract, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { v2GetLogStatsContract } from '@/lib/api/contracts/v2/logs-stats' import { documentedSchema, ERROR_RESPONSES, type ErrorResponseId, + FOLDER_TREE_TOO_LARGE, RATE_LIMIT_HEADERS, RESOURCE_ERRORS, RUN_RETENTION, @@ -10,6 +12,7 @@ import { V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -23,6 +26,7 @@ const WORKFLOW_ID = '3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36' const LOG_LIST_EXAMPLE = { data: [ { + kind: 'workflow', runId: RUN_ID, workflowId: WORKFLOW_ID, deploymentVersionId: 'dep_2c4e6a8b0d1f', @@ -33,7 +37,15 @@ const LOG_LIST_EXAMPLE = { endedAt: '2026-01-15T10:30:01.250Z', totalDurationMs: 1250, cost: { total: 0.0032 }, - files: null, + files: [ + { + id: 'f1c3a7d0-4b52-4a8e-9f61-2d7c8b3e5a04', + name: 'summary.pdf', + size: 18422, + type: 'application/pdf', + downloadPath: `/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/files/f1c3a7d0-4b52-4a8e-9f61-2d7c8b3e5a04`, + }, + ], }, ], nextCursor: 'eyJzdGFydGVkQXQiOiIyMDI2LTAxLTE1VDEwOjMwOjAwMFoifQ==', @@ -65,11 +77,60 @@ const LOG_DETAIL_EXAMPLE = { workflowState: { blocks: {}, edges: [] }, traceSpans: [], finalOutput: { result: 'Hello, world!' }, - cost: { total: 0.0032 }, + cost: { + total: 0.0032, + items: [ + { category: 'fixed', description: 'Base execution charge', cost: 0.001 }, + { + category: 'model', + description: 'gpt-5', + cost: 0.0022, + inputTokens: 1840, + outputTokens: 260, + }, + ], + }, + workflowInput: { ticketId: 'T-4821' }, createdAt: '2026-01-15T10:30:00.000Z', }, } as const +const LOG_STATS_EXAMPLE = { + data: { + workflows: [ + { + workflowId: WORKFLOW_ID, + workflowName: 'Customer Support Agent', + segments: [ + { + timestamp: '2026-01-15T10:00:00.000Z', + totalExecutions: 40, + successfulExecutions: 38, + avgDurationMs: 1180, + }, + ], + totalExecutions: 40, + totalSuccessful: 38, + overallSuccessRate: 95, + }, + ], + workflowsTruncated: false, + aggregateSegments: [ + { + timestamp: '2026-01-15T10:00:00.000Z', + totalExecutions: 40, + successfulExecutions: 38, + avgDurationMs: 1180, + }, + ], + totalRuns: 40, + totalErrors: 2, + avgLatency: 1180, + timeBounds: { start: '2026-01-15T10:00:00.000Z', end: '2026-01-15T22:00:00.000Z' }, + segmentMs: 600000, + }, +} as const + function logsOperation( operation: Omit & { errors: readonly ErrorResponseId[] @@ -89,14 +150,14 @@ function logsOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListLogsContract, logsOperation({ operationId: 'listLogs', summary: 'List Logs', - description: `List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. ${RUN_RETENTION}`, - errors: RESOURCE_ERRORS, + description: `List workflow execution logs for a workspace with filters, selectable detail, sorting by start time, duration, cost, or status, and opaque cursor pagination. Chat and Sim-agent job runs join the sequence with \`includeJobRuns=true\`, which is accepted only under \`sortBy=startedAt\` — their cost is stored as a document and their status is not comparable, so they cannot participate in the other orderings. Each item's \`files\` lists only the files the run itself produced, addressed by \`downloadPath\`; input attachments a caller supplied are read through the files API instead. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of execution logs matching the filters.' }, }), { @@ -120,9 +181,8 @@ const routes = [ logsOperation({ operationId: 'getLog', summary: 'Get Log', - description: - 'Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.', - errors: RESOURCE_ERRORS, + description: `Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty \`traceSpans\` array does not mean the run recorded none. ${FOLDER_TREE_TOO_LARGE} ${RUN_RETENTION}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The requested diagnostic log representation.' }, }), { @@ -142,14 +202,42 @@ const routes = [ ), } ), + defineOpenApiRoute( + v2GetLogStatsContract, + logsOperation({ + operationId: 'getLogStats', + summary: 'Get Log Statistics', + description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans the oldest matching run through the later of the newest matching run and now, divided into exactly \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], + success: { description: 'Bucketed execution statistics for the workspace.' }, + }), + { + query: documentedSchema( + v2GetLogStatsContract.query, + 'GetLogStatsQuery', + 'Log statistics query', + 'Workspace, workflow, folder, trigger, level, date, and bucketing filters.' + ), + response: documentedSchema( + v2GetLogStatsContract.response.schema, + 'V2LogStatsResponse', + 'Log statistics response', + 'Bucketed success rate, error count, and latency for a workspace and its workflows.', + [LOG_STATS_EXAMPLE] + ), + } + ), ] as const +/** A no-op on these bodyless reads; kept so a future body-taking log operation inherits its 413. */ +const routes = declaredRoutes.map(withRequestBodyErrors) + export const logsOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-logs.json', info: { title: 'Sim API v2 — Logs', description: - 'Version 2 of the Sim REST API for listing workflow execution logs and retrieving complete diagnostic run snapshots.', + 'Version 2 of the Sim REST API for workflow execution logs: listing and sorting runs with filters, retrieving complete diagnostic run snapshots, and reading bucketed execution statistics.', version: '2.0.0', contact: { name: 'Sim Support', diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index dd68e8eb1ab..5f13e221f76 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,9 +1,17 @@ +import { + v2GetBlockContract, + v2GetToolContract, + v2ListBlocksContract, + v2ListConnectorTypesContract, + v2ListToolsContract, +} from '@/lib/api/contracts/v2/catalog' import { v2CreateCredentialConnectionContract, v2CreateServiceAccountCredentialContract, v2DeleteCredentialContract, v2ListCredentialProvidersContract, v2ListCredentialsContract, + v2UpdateCredentialContract, } from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, @@ -20,6 +28,7 @@ import { v2ListMcpServerToolsContract, v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' +import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' import { documentedSchema, type ErrorResponseId, @@ -51,6 +60,16 @@ import { v2RevokeSkillEditorContract, v2UpdateSkillContract, } from '@/lib/api/contracts/v2/skills' +import { + v2CreateWorkflowMcpServerContract, + v2DeleteWorkflowMcpServerContract, + v2DeployWorkflowMcpToolContract, + v2GetWorkflowMcpServerContract, + v2ListWorkflowMcpServersContract, + v2ListWorkflowMcpToolsContract, + v2UndeployWorkflowMcpToolContract, + v2UpdateWorkflowMcpServerContract, +} from '@/lib/api/contracts/v2/workflow-mcp-servers' import { v2GetWorkspaceContract, v2ListWorkspaceMembersContract, @@ -62,6 +81,177 @@ import { type OpenApiOperationMetadata, } from '@/lib/api/openapi/types' +const BLOCK_SUMMARY_EXAMPLE = { + id: 'slack', + name: 'Slack', + description: 'Send messages and read channels in Slack.', + category: 'tools', + integrationType: 'communication', + source: 'builtin', + authMode: 'oauth', + triggerAllowed: true, + triggerCapable: true, + triggerIds: ['slack_webhook'], + toolIds: ['slack_message', 'slack_canvas_read'], + operationIds: ['send', 'read'], + preview: false, + docsLink: 'https://docs.sim.ai/tools/slack', + tags: ['messaging'], +} as const + +const BLOCK_DETAIL_EXAMPLE = { + ...BLOCK_SUMMARY_EXAMPLE, + inputSchema: [ + { + id: 'operation', + type: 'dropdown', + title: 'Operation', + required: true, + options: [ + { id: 'send', label: 'Send message' }, + { id: 'read', label: 'Read messages' }, + ], + }, + ], + operationInputSchema: { + send: [{ id: 'text', type: 'long-input', title: 'Message', required: true }], + }, + inputDefinitions: { + channel: { type: 'string', description: 'Channel to post into.' }, + }, + operations: { + send: { + toolId: 'slack_message', + toolName: 'Slack Send Message', + description: 'Send a message to a Slack channel.', + inputs: { text: { type: 'string', required: true, description: 'Message body.' } }, + outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, + inputSchema: [{ id: 'text', type: 'long-input', title: 'Message', required: true }], + }, + }, + tools: [ + { + id: 'slack_message', + name: 'Slack Send Message', + description: 'Send a message to a Slack channel.', + version: '1.0.0', + hostedApiKey: 'none', + oauth: { required: true, provider: 'slack', requiredScopes: ['chat:write'] }, + params: { text: { type: 'string', required: true, description: 'Message body.' } }, + outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, + }, + ], + triggers: [ + { + id: 'slack_webhook', + outputs: { text: { type: 'string', description: 'Message text.' } }, + configFields: { + channels: { type: 'short-input', required: false, title: 'Channels' }, + }, + }, + ], + outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, +} as const + +const CONNECTOR_TYPE_EXAMPLE = { + connectorType: 'google_drive', + name: 'Google Drive', + description: 'Sync documents from a Google Drive folder.', + version: '1.0.0', + auth: { + mode: 'oauth', + provider: 'google-drive', + requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'], + }, + configFields: [ + { + id: 'folderSelector', + title: 'Folder', + type: 'selector', + selectorKey: 'google-drive-folder', + mimeType: 'application/vnd.google-apps.folder', + mode: 'basic', + canonicalParamId: 'folderId', + required: true, + }, + { + id: 'manualFolderId', + title: 'Folder ID', + type: 'short-input', + placeholder: 'Enter the folder ID', + mode: 'advanced', + canonicalParamId: 'folderId', + }, + ], + supportsIncrementalSync: true, + tagDefinitions: [{ id: 'owner', displayName: 'Owner', fieldType: 'text' }], +} as const + +/** + * `GET /api/v2/meta` resolves no workspace and no resource, so it cannot emit + * the `403` every workspace-scoped operation can, nor a `404`. A documented + * status an operation cannot emit is worse than none. + */ +const META_ERRORS = [ + 'BadRequest', + 'Unauthorized', + 'RateLimited', + 'InternalError', + 'ServiceUnavailable', +] as const satisfies readonly ErrorResponseId[] + +const TOOL_SUMMARY_EXAMPLE = { + id: 'slack_message', + name: 'Slack Send Message', + description: 'Send a message to a Slack channel.', + version: '1.0.0', + hostedApiKey: 'none', + oauth: { required: true, provider: 'slack', requiredScopes: ['chat:write'] }, +} as const + +const TOOL_DETAIL_EXAMPLE = { + ...TOOL_SUMMARY_EXAMPLE, + params: { + channel: { type: 'string', required: true, description: 'Channel ID to post into.' }, + text: { type: 'string', required: true, description: 'Message body.' }, + }, + outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, +} as const + +const WORKFLOW_MCP_SERVER_EXAMPLE = { + id: 'wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2', + name: 'Support agents', + description: 'Ticket triage and escalation workflows.', + isPublic: false, + mcpServerUrl: 'https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2', + createdAt: '2026-06-12T10:30:00.000Z', + updatedAt: '2026-06-12T10:30:00.000Z', +} as const + +const WORKFLOW_MCP_SERVER_LIST_EXAMPLE = { + ...WORKFLOW_MCP_SERVER_EXAMPLE, + toolCount: 1, + toolNames: ['triage_ticket'], +} as const + +const WORKFLOW_MCP_TOOL_EXAMPLE = { + id: 'wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3', + serverId: WORKFLOW_MCP_SERVER_EXAMPLE.id, + workflowId: '3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36', + toolName: 'triage_ticket', + toolDescription: 'Execute Ticket triage workflow', + mcpServerUrl: WORKFLOW_MCP_SERVER_EXAMPLE.mcpServerUrl, + apiEndpoint: 'https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute', + updated: false, + createdAt: '2026-06-12T10:30:00.000Z', + updatedAt: '2026-06-12T10:30:00.000Z', +} as const + +/** The publish example as a read returns it: `updated` is a publish outcome, not a field of the tool. */ +function omitUpdated({ updated: _updated, ...tool }: typeof WORKFLOW_MCP_TOOL_EXAMPLE) { + return tool +} + const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' const WORKSPACE_EXAMPLE = { @@ -250,12 +440,14 @@ const SECRET_EXAMPLE = { } as const type ResourceTag = + | 'Meta' | 'Workspaces' | 'MCP Servers' | 'Skills' | 'Custom Tools' | 'Credentials' | 'Secrets' + | 'Catalog' function resourceOperation( tag: ResourceTag, @@ -380,7 +572,7 @@ const declaredRoutes = [ operationId: 'listMcpServers', summary: 'List MCP Servers', description: - 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.', + 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{mcpServerId}/tools` runs a discovery.', errors: RESOURCE_ERRORS, success: { description: 'MCP servers registered in the workspace.' }, }), @@ -406,7 +598,7 @@ const declaredRoutes = [ operationId: 'createMcpServer', summary: 'Create MCP Server', description: - 'Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.', + 'Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{mcpServerId}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{mcpServerId}/tools` succeeds.', errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The MCP server was registered.' }, }), @@ -1139,7 +1331,7 @@ const declaredRoutes = [ resourceOperation('Secrets', { operationId: 'listSecrets', summary: 'List Secrets', - description: `List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. ${WORKSPACE_API_KEY_DENIED}`, + description: `List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, description, role, and timestamps are returned; secret values are never returned. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'Secret metadata visible to the caller.' }, }), @@ -1242,6 +1434,389 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2GetMetaContract, + resourceOperation('Meta', { + operationId: 'getApiMeta', + summary: 'Get API Capabilities', + description: + 'Report facts about the calling API key: whether it is in the v2 rollout cohort, whether it is personal or workspace-scoped, and when it expires. Every other v2 endpoint answers 404 both when the path does not exist and when your credential is not in the rollout cohort; call this endpoint to tell the two apart. It is the one v2 endpoint the rollout gate does not apply to, and it still requires a valid key.', + errors: META_ERRORS, + success: { description: 'Rollout and lifecycle facts about the calling key.' }, + }), + { + query: v2GetMetaContract.query, + response: documentedSchema( + v2GetMetaContract.response.schema, + 'GetApiMetaResponse', + 'API capabilities response', + 'Rollout cohort, key type, and expiry for the calling key.', + [{ data: { v2Enabled: true, keyType: 'personal', expiresAt: null } }] + ), + } + ), + defineOpenApiRoute( + v2ListWorkflowMcpServersContract, + resourceOperation('MCP Servers', { + operationId: 'listWorkflowMcpServers', + summary: 'List Workflow MCP Servers', + description: `List the MCP servers a workspace *publishes*. These serve deployed workflows as tools to outside MCP clients, which is the opposite direction from \`GET /api/v2/mcp-servers\` — that lists external servers Sim calls. Each entry carries the endpoint clients connect to and the tool names it exposes; those names are gathered under a 2,000-tool budget shared across the page, so on a page of unusually large servers the trailing entries can list fewer names than they publish. Read one server's full inventory with \`GET /api/v2/workflow-mcp-servers/{serverId}/tools\`. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'A page of published MCP servers.' }, + }), + { + query: v2ListWorkflowMcpServersContract.query, + response: documentedSchema( + v2ListWorkflowMcpServersContract.response.schema, + 'ListWorkflowMcpServersResponse', + 'List workflow MCP servers response', + 'A cursor-paginated page of published MCP servers.', + [{ data: [WORKFLOW_MCP_SERVER_LIST_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreateWorkflowMcpServerContract, + resourceOperation('MCP Servers', { + operationId: 'createWorkflowMcpServer', + summary: 'Create Workflow MCP Server', + description: `Publish a new MCP server for a workspace, optionally seeding it with workflows to expose as tools. Every workflow named in \`workflowIds\` must already be deployed. Setting \`isPublic\` lets any MCP client holding the server URL execute the workflows it publishes without a Sim API key. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The published MCP server.' }, + }), + { + query: v2CreateWorkflowMcpServerContract.query, + body: v2CreateWorkflowMcpServerContract.body, + response: documentedSchema( + v2CreateWorkflowMcpServerContract.response.schema, + 'CreateWorkflowMcpServerResponse', + 'Create workflow MCP server response', + 'The published MCP server.', + [{ data: WORKFLOW_MCP_SERVER_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2GetWorkflowMcpServerContract, + resourceOperation('MCP Servers', { + operationId: 'getWorkflowMcpServer', + summary: 'Get Workflow MCP Server', + description: `Read one published MCP server. The list is the only other place this state is published, so a caller holding a server id would otherwise have to page the collection and filter client-side. The tools it publishes are on its \`tools\` sub-resource. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The MCP server.' }, + }), + { + query: v2GetWorkflowMcpServerContract.query, + params: v2GetWorkflowMcpServerContract.params, + response: documentedSchema( + v2GetWorkflowMcpServerContract.response.schema, + 'GetWorkflowMcpServerResponse', + 'Get workflow MCP server response', + 'A single published MCP server.', + [{ data: WORKFLOW_MCP_SERVER_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ListWorkflowMcpToolsContract, + resourceOperation('MCP Servers', { + operationId: 'listWorkflowMcpTools', + summary: 'List Workflow MCP Tools', + description: `Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the \`workflowId\` that \`DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}\` addresses. Returned in one page rather than paged — so \`nextCursor\` is always null — and capped at 2,000 tools, which is far above any real server's inventory. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The tools this server publishes.' }, + }), + { + query: v2ListWorkflowMcpToolsContract.query, + params: v2ListWorkflowMcpToolsContract.params, + response: documentedSchema( + v2ListWorkflowMcpToolsContract.response.schema, + 'ListWorkflowMcpToolsResponse', + 'List workflow MCP tools response', + 'The tools a published MCP server exposes.', + [ + { + data: [omitUpdated(WORKFLOW_MCP_TOOL_EXAMPLE)], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2UpdateWorkflowMcpServerContract, + resourceOperation('MCP Servers', { + operationId: 'updateWorkflowMcpServer', + summary: 'Update Workflow MCP Server', + description: `Rename, re-describe, or change the public visibility of a published MCP server. Merge-patch shaped: an omitted key is unchanged and \`description: null\` clears the description. Publishing and unpublishing the workflows it serves are separate operations on its \`tools\` sub-resource. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The updated MCP server.' }, + }), + { + query: v2UpdateWorkflowMcpServerContract.query, + params: v2UpdateWorkflowMcpServerContract.params, + body: v2UpdateWorkflowMcpServerContract.body, + response: documentedSchema( + v2UpdateWorkflowMcpServerContract.response.schema, + 'UpdateWorkflowMcpServerResponse', + 'Update workflow MCP server response', + 'The updated MCP server.', + [{ data: { ...WORKFLOW_MCP_SERVER_EXAMPLE, isPublic: true } }] + ), + } + ), + defineOpenApiRoute( + v2DeleteWorkflowMcpServerContract, + resourceOperation('MCP Servers', { + operationId: 'deleteWorkflowMcpServer', + summary: 'Delete Workflow MCP Server', + description: `Unpublish an MCP server. Every tool it served stops answering and connected clients lose the endpoint. The workflows themselves are untouched — their own deployments stay live and executable through the workflow API. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The MCP server was unpublished.' }, + }), + { + query: v2DeleteWorkflowMcpServerContract.query, + params: v2DeleteWorkflowMcpServerContract.params, + response: documentedSchema( + v2DeleteWorkflowMcpServerContract.response.schema, + 'DeleteWorkflowMcpServerResponse', + 'Delete workflow MCP server response', + 'Acknowledgement that the MCP server was unpublished.', + [{ data: { id: WORKFLOW_MCP_SERVER_EXAMPLE.id, deleted: true } }] + ), + } + ), + defineOpenApiRoute( + v2DeployWorkflowMcpToolContract, + resourceOperation('MCP Servers', { + operationId: 'deployWorkflowMcpTool', + summary: 'Publish Workflow As MCP Tool', + description: `Publish a deployed workflow as a tool on an MCP server. The tool's input schema is generated from the deployed workflow's input format, so the workflow must already be deployed. Idempotent per workflow: a server carries at most one tool per workflow, so a repeat call replaces the existing tool and answers \`200\` with \`updated: true\` rather than conflicting. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The published tool.' }, + }), + { + query: v2DeployWorkflowMcpToolContract.query, + params: v2DeployWorkflowMcpToolContract.params, + body: v2DeployWorkflowMcpToolContract.body, + response: documentedSchema( + v2DeployWorkflowMcpToolContract.response.schema, + 'DeployWorkflowMcpToolResponse', + 'Publish workflow as MCP tool response', + 'The published tool.', + [{ data: WORKFLOW_MCP_TOOL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2UndeployWorkflowMcpToolContract, + resourceOperation('MCP Servers', { + operationId: 'undeployWorkflowMcpTool', + summary: 'Unpublish Workflow MCP Tool', + description: `Remove a workflow from an MCP server. Addressed by workflow rather than by tool identifier, because a server carries at most one live tool per workflow. The workflow's own deployment is untouched. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The tool was removed.' }, + }), + { + query: v2UndeployWorkflowMcpToolContract.query, + params: v2UndeployWorkflowMcpToolContract.params, + response: documentedSchema( + v2UndeployWorkflowMcpToolContract.response.schema, + 'UndeployWorkflowMcpToolResponse', + 'Unpublish workflow MCP tool response', + 'Acknowledgement that the tool was removed.', + [ + { + data: { + id: WORKFLOW_MCP_TOOL_EXAMPLE.id, + serverId: WORKFLOW_MCP_TOOL_EXAMPLE.serverId, + workflowId: WORKFLOW_MCP_TOOL_EXAMPLE.workflowId, + deleted: true, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2UpdateCredentialContract, + resourceOperation('Credentials', { + operationId: 'updateCredential', + summary: 'Update Credential', + description: `Rotate a service-account credential's secret material, or rename it. Send only the fields to change: an omitted field is left unchanged, and \`description: null\` clears the stored description. Secret fields are write-only and are never returned, and only a service-account credential has any: sending one for a credential of another type answers \`400\` rather than dropping it. The provider re-verifies replacement secret material before it replaces the stored secret, so a rejected secret leaves the stored one untouched and answers \`400\` with the provider's code in \`error.details.providerErrorCode\`; a provider that cannot be reached answers \`503\`. The credential ID is preserved, so every workflow, deployment, paused run, knowledge connector, and webhook that references it keeps working — which disconnecting and re-creating does not. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The updated credential without secret material.' }, + }), + { + params: documentedSchema( + v2UpdateCredentialContract.params, + 'UpdateCredentialParams', + 'Update credential path parameters', + 'Credential selected for update.' + ), + query: documentedSchema( + v2UpdateCredentialContract.query, + 'UpdateCredentialQuery', + 'Update credential query', + 'Workspace expected to own the credential.' + ), + body: documentedSchema( + v2UpdateCredentialContract.body, + 'UpdateCredentialRequest', + 'Update credential request', + 'Replacement display metadata and the write-only fields declared by provider discovery.', + [{ clientSecret: 'YOUR_ROTATED_CLIENT_SECRET' }] + ), + response: documentedSchema( + v2UpdateCredentialContract.response.schema, + 'UpdateCredentialResponse', + 'Update credential response', + 'Updated credential metadata without secret material.', + [{ data: CREDENTIAL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ListBlocksContract, + resourceOperation('Catalog', { + operationId: 'listBlocks', + summary: 'List Blocks', + description: + 'List the blocks available in a workspace, built-in and workspace-deployed alike, discriminated by `source`. Availability is caller-specific: the workspace’s integration allowlist, the organization’s revealed preview blocks, and the deployment’s allowlist all narrow the result. Use `capability=trigger` for the blocks that can start a workflow. Summaries name their tools and operations by id — resolve one with Get Block or Get Tool.', + errors: RESOURCE_ERRORS, + success: { description: 'A page of blocks available in the workspace.' }, + }), + { + query: documentedSchema( + v2ListBlocksContract.query, + 'ListBlocksQuery', + 'List blocks query', + 'Workspace scope, catalog filters, sort, and pagination.' + ), + response: documentedSchema( + v2ListBlocksContract.response.schema, + 'ListBlocksResponse', + 'List blocks response', + 'Blocks available in the workspace.', + [{ data: [BLOCK_SUMMARY_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2GetBlockContract, + resourceOperation('Catalog', { + operationId: 'getBlock', + summary: 'Get Block', + description: + 'Read one block’s full configuration shape: its fields and their conditions, its operations with the tool each runs, every tool’s parameters and outputs, and its triggers. An unversioned base type resolves to the newest version this caller can see — `confluence` answers with `confluence_v2` — and the returned `id` is always the resolved one, matching Get Tool. A block this caller cannot see answers 404, identically to one that does not exist.', + errors: RESOURCE_ERRORS, + success: { description: 'The block.' }, + }), + { + params: documentedSchema( + v2GetBlockContract.params, + 'GetBlockParams', + 'Get block path parameters', + 'Block selected for retrieval. An unversioned base type resolves to the newest version.' + ), + query: documentedSchema( + v2GetBlockContract.query, + 'GetBlockQuery', + 'Get block query', + 'Workspace whose availability rules are applied.' + ), + response: documentedSchema( + v2GetBlockContract.response.schema, + 'GetBlockResponse', + 'Get block response', + 'One block with its fields, operations, tools, and triggers.', + [{ data: BLOCK_DETAIL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ListToolsContract, + resourceOperation('Catalog', { + operationId: 'listTools', + summary: 'List Tools', + description: + 'List the built-in tools available in a workspace. Built-in tools only: a workspace’s MCP tools are discovered per server on List MCP Server Tools, and its code-backed custom tools are on List Custom Tools. A tool is available when a block the caller can see exposes it, so the same allowlist and visibility rules as List Blocks apply.', + errors: RESOURCE_ERRORS, + success: { description: 'A page of built-in tools available in the workspace.' }, + }), + { + query: documentedSchema( + v2ListToolsContract.query, + 'ListToolsQuery', + 'List tools query', + 'Workspace scope, tool filters, sort, and pagination.' + ), + response: documentedSchema( + v2ListToolsContract.response.schema, + 'ListToolsResponse', + 'List tools response', + 'Built-in tools available in the workspace.', + [{ data: [TOOL_SUMMARY_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2GetToolContract, + resourceOperation('Catalog', { + operationId: 'getTool', + summary: 'Get Tool', + description: + 'Read one built-in tool’s declared parameters and outputs. A name that is itself a registered id answers as that exact tool; a name that is not resolves to the newest version of its family. The returned `id` is always the one that answered, so a caller can see which version it got. A tool the workspace’s visible blocks do not expose answers `404`, identically to one that does not exist.', + errors: RESOURCE_ERRORS, + success: { description: 'The tool.' }, + }), + { + params: documentedSchema( + v2GetToolContract.params, + 'GetToolParams', + 'Get tool path parameters', + 'Tool selected for retrieval. An unversioned name resolves to the newest version.' + ), + query: documentedSchema( + v2GetToolContract.query, + 'GetToolQuery', + 'Get tool query', + 'Workspace whose availability rules are applied.' + ), + response: documentedSchema( + v2GetToolContract.response.schema, + 'GetToolResponse', + 'Get tool response', + 'One built-in tool with its parameters and outputs.', + [{ data: TOOL_DETAIL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ListConnectorTypesContract, + resourceOperation('Catalog', { + operationId: 'listConnectorTypes', + summary: 'List Connector Types', + description: `List every knowledge-base connector type and the source configuration each accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with \`multi: true\` stores a \`string[]\` rather than a \`string\`, and a \`canonicalParamId\` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by \`canonicalParamId\` rather than by the field's own \`id\`. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'The connector-type catalog.' }, + }), + { + query: documentedSchema( + v2ListConnectorTypesContract.query, + 'ListConnectorTypesQuery', + 'List connector types query', + 'Workspace scope and optional connector-name search.' + ), + response: documentedSchema( + v2ListConnectorTypesContract.response.schema, + 'ListConnectorTypesResponse', + 'List connector types response', + 'Knowledge-base connector types and their configuration fields.', + [{ data: [CONNECTOR_TYPE_EXAMPLE], nextCursor: null }] + ), + } + ), ] as const const routes = declaredRoutes.map(withRequestBodyErrors) @@ -1251,7 +1826,7 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ info: { title: 'Sim API v2 — Workspace Resources', description: - 'Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, and write-only secrets.', + 'Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, write-only secrets, and the block, tool, and connector-type catalogs.', version: '2.0.0', contact: { name: 'Sim Support', @@ -1265,6 +1840,10 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, servers: [{ url: 'https://www.sim.ai', description: 'Production' }], tags: [ + { + name: 'Meta', + description: 'Discover what the calling API key can reach.', + }, { name: 'Workspaces', description: 'Read workspace metadata and its effective member roster.', @@ -1290,6 +1869,10 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ name: 'Secrets', description: 'Set and manage write-only workspace and personal secret values.', }, + { + name: 'Catalog', + description: 'Discover the blocks, tools, and connector types this workspace can build with.', + }, ], security: V2_API_KEY_SECURITY, securitySchemes: V2_API_KEY_SECURITY_SCHEMES, diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 0a774a71c5b..db012b23e6e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -171,7 +171,7 @@ export const ERROR_RESPONSES = { * an SDK can branch on. Publishing it on every operation would add a branch to * every generated client that can never be taken. * - * `POST /workflows/{id}/execute` is the exception because there an abort + * `POST /workflows/{workflowId}/execute` is the exception because there an abort * leaves *residue*: the run may keep going and bill, so the response carries * `error.details.runId` for the caller to reconcile against once it reconnects. * That is caller-actionable information about state that outlives the @@ -287,16 +287,29 @@ export const RESOURCE_MUTATION_ERRORS = [ * `413` on any route whose contract declares one — and a status a caller can * receive but the spec omits is an unhandled branch in every generated client. * + * `415` is derived the same way and for the same reason: the JSON builder + * answers `UNSUPPORTED_MEDIA_TYPE` for any body sent under a content type it + * cannot read (`v2-json-route.ts`), so every route declaring a body can return + * it, and none of them had said so. + * * Derived from the contract rather than chosen per operation, so a new body - * route cannot forget it. One-directional: it never removes a `413` from a + * route cannot forget either. One-directional: it never removes a `413` from a * bodyless read, several of which publish one for the folder-tree ceiling. */ +const BODY_DERIVED_ERRORS = [ + 'PayloadTooLarge', + 'UnsupportedMediaType', +] as const satisfies readonly ErrorResponseId[] + +/** @see BODY_DERIVED_ERRORS */ export function withRequestBodyErrors(route: OpenApiRouteDefinition): OpenApiRouteDefinition { - if (!route.contract.body || route.operation.errors.includes('PayloadTooLarge')) return route - return { - ...route, - operation: { ...route.operation, errors: [...route.operation.errors, 'PayloadTooLarge'] }, + if (!route.contract.body) return route + const derived = route.operation.errors.slice() + for (const code of BODY_DERIVED_ERRORS) { + if (!derived.includes(code)) derived.push(code) } + if (derived.length === route.operation.errors.length) return route + return { ...route, operation: { ...route.operation, errors: derived } } } export const V2_API_KEY_SECURITY = [{ apiKey: [] }] as const @@ -401,6 +414,39 @@ export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = export const RUN_RETENTION = "Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override." +/** + * Response headers a binary download declares on top of the common set. Shared + * so every document that publishes a byte-serving route describes the same + * three headers identically. + */ +export const V2_BINARY_DOWNLOAD_HEADERS = { + 'Content-Type': { + schema: z.string().meta({ + id: 'ContentTypeHeader', + title: 'Content type', + description: + 'MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.', + }), + }, + 'Content-Disposition': { + schema: z.string().meta({ + id: 'ContentDispositionHeader', + title: 'Content disposition', + description: 'Attachment disposition containing sanitized and RFC 5987 encoded filenames.', + }), + }, + 'Content-Length': { + schema: z + .string() + .regex(/^(0|[1-9]\d*)$/) + .meta({ + id: 'ContentLengthHeader', + title: 'Content length', + description: 'File size in bytes.', + }), + }, +} as const + export const V2_COMMON_HEADERS = { 'X-RateLimit-Limit': { schema: z.number().int().nonnegative().meta({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index b6685b594a2..e7aa5412f21 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -18,11 +18,15 @@ import { import { v2AddTableColumnContract, v2AddWorkflowGroupContract, + v2BulkDeleteTablesContract, + v2BulkUpdateTableRowsContract, + v2CancelTableDispatchContract, v2CancelTableExportContract, v2CancelTableImportContract, v2CancelTableRunsContract, v2CompleteTableImportContract, v2CreateTableContract, + v2CreateTableDispatchContract, v2CreateTableExportContract, v2CreateTableFolderContract, v2CreateTableImportContract, @@ -36,22 +40,27 @@ import { v2DeleteTableRowsContract, v2DeleteTableViewContract, v2DeleteWorkflowGroupContract, - v2FindTableRowsContract, + v2GetRowEnrichmentContract, v2GetTableContract, + v2GetTableDispatchContract, v2GetTableExportContract, v2GetTableImportContract, v2GetTableRowContract, v2GetTableViewContract, + v2ListTableDispatchesContract, v2ListTableFoldersContract, v2ListTableRowsContract, v2ListTablesContract, v2ListTableViewsContract, v2ListWorkflowGroupsContract, + v2MoveTablesContract, v2QueryRowsContract, v2QueryRowsCountContract, v2RelocateTableFolderContract, + v2RestoreTableContract, + v2RestoreTableFolderContract, v2RunRowEnrichmentContract, - v2RunTableColumnContract, + v2SearchTableRowsContract, v2TableExportDownloadContract, v2UpdateRowsByFilterContract, v2UpdateTableColumnContract, @@ -67,6 +76,7 @@ import { type OpenApiOperationMetadata, type OpenApiSuccessMetadata, } from '@/lib/api/openapi/types' +import { TABLE_LIMITS } from '@/lib/table/constants' const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' const TABLE_ID = 'tbl_7c9e6679742540de944be07fc1f90ae7' @@ -75,6 +85,7 @@ const VIEW_ID = 'view_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07' const GROUP_ID = 'grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204' const IMPORT_ID = 'imp_4f6a8c0e2b1d43759a7c9e1f3b5d7082' const EXPORT_ID = 'exp_3e5f7a9c1b2d4068a0c2e4f6b8d0f193' +const DISPATCH_ID = 'dsp_9a1c3e5f7b2d40689c4e6a8b0d2f4173' /** * Only for operations that genuinely reach a `lib/table/mutation-locks` assert @@ -127,7 +138,7 @@ const declaredRoutes = [ tableOperation({ operationId: 'listTables', summary: 'List Tables', - description: `List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. ${FOLDER_TREE_TOO_LARGE}`, + description: `List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. \`scope=archived\` lists tables a \`DELETE\` archived, which \`POST /api/v2/tables/{tableId}/restore\` can bring back. ${FOLDER_TREE_TOO_LARGE}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'PayloadTooLarge'], success: { description: 'A page of tables in the workspace.' }, }), @@ -219,7 +230,8 @@ const declaredRoutes = [ tableOperation({ operationId: 'deleteTable', summary: 'Delete Table', - description: 'Delete a table and return an explicit deletion acknowledgement.', + description: + 'Archive a table and return an explicit deletion acknowledgement. The table is soft-deleted, not erased: its rows are retained and `POST /api/v2/tables/{tableId}/restore` brings it back.', errors: TABLE_MUTATION_ERRORS, success: { description: 'Table deletion acknowledgement.' }, }), @@ -249,7 +261,7 @@ const declaredRoutes = [ tableOperation({ operationId: 'updateTable', summary: 'Update Table', - description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. The error body carries \`details.applied\` naming the fields that landed — retry with only the ones missing from it.\n\n${FOLDER_TREE_TOO_LARGE}`, + description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. When at least one field landed before the failure the error body carries \`details.applied\` naming those fields — retry with only the ones missing from it. Its absence means nothing was applied.\n\n${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated table.' }, }), @@ -378,7 +390,7 @@ const declaredRoutes = [ operationId: 'listTableRows', summary: 'List Rows', description: - 'List a plain cursor page in default row order. Pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. Use the query endpoint for predicate filtering and sorting.', + "List a plain cursor page in default row order. Pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. Use the query endpoint for predicate filtering and sorting. Set `includeRunState=true` to attach each row's per-workflow-group run outcomes; the row limit is capped when it is set.", errors: RESOURCE_ERRORS, success: { description: 'A page of table rows.' }, }), @@ -512,7 +524,8 @@ const declaredRoutes = [ tableOperation({ operationId: 'getTableRow', summary: 'Get Row', - description: 'Retrieve one row by identifier.', + description: + "Retrieve one row by identifier. Set `includeRunState=true` to attach the row's per-workflow-group run outcomes.", errors: RESOURCE_ERRORS, success: { description: 'The requested table row.' }, }), @@ -644,7 +657,7 @@ const declaredRoutes = [ operationId: 'queryTableRows', summary: 'Query Rows', description: - 'Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.', + "Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`. Set `includeRunState: true` to attach each row's per-workflow-group run outcomes; the row limit is capped when it is set. Row totals live on the companion `POST /api/v2/tables/{tableId}/query/count`, which is a separate snapshot — a caller needing a consistent pair should take the count first and treat it as a floor.", errors: TABLE_QUERY_ERRORS, success: { description: 'A page of matching table rows.' }, }), @@ -1033,34 +1046,34 @@ const declaredRoutes = [ } ), defineOpenApiRoute( - v2RunTableColumnContract, + v2CreateTableDispatchContract, tableOperation({ - operationId: 'runTableColumns', - summary: 'Run Column Groups', + operationId: 'createTableDispatch', + summary: 'Create Run Dispatch', description: - 'Asynchronously run workflow or enrichment groups across all rows or a selected row subset.', + 'Asynchronously run workflow or enrichment groups across all rows or a selected row subset. Poll the returned `dispatchId` with `GET /api/v2/tables/{tableId}/dispatches/{dispatchId}` until its status is `complete` or `canceled`, and cancel it with `DELETE` on the same path. A `null` `dispatchId` means the run settled inline and there is nothing to poll.', errors: RESOURCE_ERRORS, - success: { description: 'The accepted table-column dispatch.' }, + success: { description: 'The accepted run dispatch.' }, }), { - query: v2RunTableColumnContract.query, + query: v2CreateTableDispatchContract.query, params: documentedSchema( - v2RunTableColumnContract.params, - 'RunTableColumnsParams', - 'Run table columns path parameters', + v2CreateTableDispatchContract.params, + 'CreateTableDispatchParams', + 'Create table dispatch path parameters', 'Table whose producer groups should run.' ), body: documentedSchema( - v2RunTableColumnContract.body, - 'RunTableColumnsRequest', - 'Run table columns request', + v2CreateTableDispatchContract.body, + 'CreateTableDispatchRequest', + 'Create table dispatch request', 'Workspace scope, producer groups, execution mode, and optional row scope.', [{ workspaceId: WORKSPACE_ID, groupIds: [GROUP_ID] }] ), response: documentedSchema( - v2RunTableColumnContract.response.schema, - 'V2RunTableColumnsResponse', - 'Run table columns response', + v2CreateTableDispatchContract.response.schema, + 'V2CreateTableDispatchResponse', + 'Create table dispatch response', 'Accepted background dispatch identifier.' ), } @@ -1070,7 +1083,8 @@ const declaredRoutes = [ tableOperation({ operationId: 'runRowEnrichment', summary: 'Run Enrichment For One Row', - description: 'Asynchronously run one workflow or enrichment group for one table row.', + description: + 'Asynchronously run one workflow or enrichment group for one table row. Poll the returned `dispatchId` with `GET /api/v2/tables/{tableId}/dispatches/{dispatchId}`; a `null` `dispatchId` means the cell already settled inline.', errors: RESOURCE_ERRORS, success: { description: 'The accepted row enrichment dispatch.' }, }), @@ -1098,27 +1112,26 @@ const declaredRoutes = [ } ), defineOpenApiRoute( - v2FindTableRowsContract, + v2SearchTableRowsContract, tableOperation({ - operationId: 'findTableRows', - summary: 'Find Rows', - description: - 'Search every cell case-insensitively, optionally within a predicate-filtered and sorted view.', + operationId: 'searchTableRows', + summary: 'Search Rows', + description: `Text-search every cell case-insensitively for the substring \`q\`, optionally within a predicate-filtered and sorted view. This is TEXT search, not the structured predicate read: \`POST /api/v2/tables/{tableId}/query\` is that one, and on this surface \`query\` always means a structured predicate while \`search\` always means text.\n\nIt returns cell COORDINATES — \`{ ordinal, rowId, column }\` — and never row data. \`ordinal\` is the row's zero-based index in the same filtered, sorted view \`POST /query\` pages, so read the rows themselves through that. The result is uncursored and capped: at most ${TABLE_LIMITS.MAX_FIND_MATCHES} matches come back and \`truncated\` is \`true\` when more matched than were returned. There is no cursor to page with — narrow \`q\` or the predicate instead.`, errors: RESOURCE_ERRORS, success: { description: 'The matching table cells.' }, }), { - query: v2FindTableRowsContract.query, + query: v2SearchTableRowsContract.query, params: documentedSchema( - v2FindTableRowsContract.params, - 'FindTableRowsParams', - 'Find table rows path parameters', + v2SearchTableRowsContract.params, + 'SearchTableRowsParams', + 'Search table rows path parameters', 'Table whose cells should be searched.' ), body: documentedSchema( - v2FindTableRowsContract.body, - 'FindTableRowsRequest', - 'Find table rows request', + v2SearchTableRowsContract.body, + 'SearchTableRowsRequest', + 'Search table rows request', 'Workspace scope, substring query, and optional predicate and sort.', [ { @@ -1129,9 +1142,9 @@ const declaredRoutes = [ ] ), response: documentedSchema( - v2FindTableRowsContract.response.schema, - 'V2FindTableRowsResponse', - 'Find table rows response', + v2SearchTableRowsContract.response.schema, + 'V2SearchTableRowsResponse', + 'Search table rows response', 'Matching table cells and truncation state.' ), } @@ -1373,8 +1386,8 @@ const declaredRoutes = [ v2GetTableExportContract.params, 'GetTableExportParams', 'Get table export path parameters', - 'Export selected for retrieval.', - [{ exportId: EXPORT_ID }] + 'Table that owns the export, and the export selected for retrieval.', + [{ tableId: TABLE_ID, exportId: EXPORT_ID }] ), query: documentedSchema( v2GetTableExportContract.query, @@ -1404,7 +1417,7 @@ const declaredRoutes = [ v2CancelTableExportContract.params, 'CancelTableExportParams', 'Cancel table export path parameters', - 'Export selected for cancellation.' + 'Table that owns the export, and the export selected for cancellation.' ), query: documentedSchema( v2CancelTableExportContract.query, @@ -1435,7 +1448,7 @@ const declaredRoutes = [ v2TableExportDownloadContract.params, 'DownloadTableExportParams', 'Download table export path parameters', - 'Export selected for download.' + 'Table that owns the export, and the export selected for download.' ), query: documentedSchema( v2TableExportDownloadContract.query, @@ -1591,6 +1604,298 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2RestoreTableFolderContract, + tableOperation({ + operationId: 'restoreTablesFolder', + summary: 'Restore Folder', + description: + "Un-archive a table folder a recursive `DELETE` archived, along with every subfolder and table archived with it. Address it by the path it held when it was deleted. The restore may legally land it elsewhere: a folder whose parent is still archived is re-rooted to `/`, and a name an active sibling has taken meanwhile is deduplicated — so read the returned folder's `path` rather than assuming the requested one. A path that is not archived answers `404`. `DELETE /api/v2/tables/folders` returns the path it archived, which is the value to keep and send here; unlike the files surface, `GET /api/v2/tables/folders` does not yet list archived folders, so a caller that discards that path cannot recover it over the API.", + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The restored table folder and what it brought back.' }, + }), + { + query: v2RestoreTableFolderContract.query, + body: documentedSchema( + v2RestoreTableFolderContract.body, + 'RestoreTableFolderRequest', + 'Restore table folder request', + 'Workspace scope and the canonical path the archived folder held.', + [{ workspaceId: WORKSPACE_ID, path: '/Sales/Enterprise' }] + ), + response: documentedSchema( + v2RestoreTableFolderContract.response.schema, + 'V2RestoreTableFolderResponse', + 'Restore table folder response', + 'The restored table folder and the counts of items it brought back.' + ), + } + ), + defineOpenApiRoute( + v2RestoreTableContract, + tableOperation({ + operationId: 'restoreTable', + summary: 'Restore Table', + description: + 'Un-archive a table a `DELETE` archived, along with the rows, views, and workflow groups archived with it. Find archived tables with `scope=archived` on the table list. Idempotent: a table that is already active is returned unchanged with no audit entry recorded, so a retry after a dropped response cannot look like a failure. A name collision is resolved by renaming, so the restored table may come back under a different `name`.', + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The restored table.' }, + }), + { + query: v2RestoreTableContract.query, + params: documentedSchema( + v2RestoreTableContract.params, + 'RestoreTableParams', + 'Restore table path parameters', + 'Archived table selected for restoration.' + ), + body: documentedSchema( + v2RestoreTableContract.body, + 'RestoreTableRequest', + 'Restore table request', + 'Workspace scope for the archived table.', + [{ workspaceId: WORKSPACE_ID }] + ), + response: documentedSchema( + v2RestoreTableContract.response.schema, + 'V2RestoreTableResponse', + 'Restore table response', + 'The restored table.' + ), + } + ), + defineOpenApiRoute( + v2BulkUpdateTableRowsContract, + tableOperation({ + operationId: 'bulkUpdateTableRows', + summary: 'Bulk Update Rows', + description: + 'Apply a distinct partial data patch to each of up to 1000 rows in one request. Each patch merges into its row, so a column absent from `data` is left alone. Membership is atomic: a `rowId` naming no row in this table fails the whole request with a `400` listing the missing identifiers. Use `PATCH /api/v2/tables/{tableId}/rows` when one patch applies to every matching row.', + errors: [...TABLE_MUTATION_ERRORS, 'PayloadTooLarge'], + success: { description: 'The bulk update result.' }, + }), + { + query: v2BulkUpdateTableRowsContract.query, + params: documentedSchema( + v2BulkUpdateTableRowsContract.params, + 'BulkUpdateTableRowsParams', + 'Bulk update table rows path parameters', + 'Table whose rows should be updated.' + ), + body: documentedSchema( + v2BulkUpdateTableRowsContract.body, + 'BulkUpdateTableRowsRequest', + 'Bulk update table rows request', + 'Workspace scope and one merge patch per row.', + [ + { + workspaceId: WORKSPACE_ID, + updates: [ + { rowId: ROW_ID, data: { status: 'active' } }, + { rowId: 'row_2b4d6f8a0c1e3759b8d0f2a4c6e80193', data: { status: 'churned' } }, + ], + }, + ] + ), + response: documentedSchema( + v2BulkUpdateTableRowsContract.response.schema, + 'V2BulkUpdateTableRowsResponse', + 'Bulk update table rows response', + 'Updated row count and identifiers.' + ), + } + ), + defineOpenApiRoute( + v2GetRowEnrichmentContract, + tableOperation({ + operationId: 'getRowEnrichment', + summary: 'Get Enrichment Run Detail', + description: + "Retrieve the provider cascade behind one enrichment cell: every configured provider in cascade order, each one's status, hosted-key cost, and duration, plus which provider produced the match. `null` means the cell has never run, or ran before cascade detail was recorded — distinct from a `404`, which means the table, row, or group does not exist.", + errors: RESOURCE_ERRORS, + success: { description: 'The enrichment run detail, or null when none was recorded.' }, + }), + { + params: documentedSchema( + v2GetRowEnrichmentContract.params, + 'GetRowEnrichmentParams', + 'Get row enrichment path parameters', + 'Table, row, and producer group whose run detail is requested.' + ), + query: documentedSchema( + v2GetRowEnrichmentContract.query, + 'GetRowEnrichmentQuery', + 'Get row enrichment query', + 'Workspace scope for the row.' + ), + response: documentedSchema( + v2GetRowEnrichmentContract.response.schema, + 'V2RowEnrichmentResponse', + 'Row enrichment response', + 'Provider cascade, cost, and timing for one enrichment cell.' + ), + } + ), + defineOpenApiRoute( + v2GetTableDispatchContract, + tableOperation({ + operationId: 'getTableDispatch', + summary: 'Get Run Dispatch', + description: + 'Poll one workflow-column run dispatch by the `dispatchId` the run endpoints returned. Answers in every lifecycle state — `pending`, `dispatching`, `complete`, and `canceled` — so a poller can wait for a run to settle. Per-cell outcomes are read with `includeRunState` on the row endpoints.', + errors: RESOURCE_ERRORS, + success: { description: 'The requested run dispatch.' }, + }), + { + params: documentedSchema( + v2GetTableDispatchContract.params, + 'GetTableDispatchParams', + 'Get table dispatch path parameters', + 'Table that owns the dispatch, and the dispatch selected for retrieval.' + ), + query: documentedSchema( + v2GetTableDispatchContract.query, + 'GetTableDispatchQuery', + 'Get table dispatch query', + 'Workspace scope for the dispatch.' + ), + response: documentedSchema( + v2GetTableDispatchContract.response.schema, + 'V2TableRunDispatchResponse', + 'Table run dispatch response', + 'A single table run dispatch.' + ), + } + ), + defineOpenApiRoute( + v2CancelTableDispatchContract, + tableOperation({ + operationId: 'cancelTableDispatch', + summary: 'Cancel Run Dispatch', + description: + 'Cancel one run dispatch by the `dispatchId` the run endpoint returned. This stops the scheduler: the dispatcher observes the cancellation at its next iteration and enqueues no further cells. Cells already handed to the queue are NOT canceled here — nothing links a queued cell back to the dispatch that enqueued it — so use `POST /api/v2/tables/{tableId}/cancel-runs` to stop work already in flight. Idempotent: a dispatch already `complete` or `canceled` is returned unchanged.', + errors: RESOURCE_ERRORS, + success: { description: 'The dispatch in its post-cancellation state.' }, + }), + { + params: documentedSchema( + v2CancelTableDispatchContract.params, + 'CancelTableDispatchParams', + 'Cancel table dispatch path parameters', + 'Table that owns the dispatch, and the dispatch selected for cancellation.' + ), + query: documentedSchema( + v2CancelTableDispatchContract.query, + 'CancelTableDispatchQuery', + 'Cancel table dispatch query', + 'Workspace scope for the dispatch.' + ), + response: documentedSchema( + v2CancelTableDispatchContract.response.schema, + 'V2CancelTableDispatchResponse', + 'Cancel table dispatch response', + 'The dispatch in its post-cancellation state.' + ), + } + ), + defineOpenApiRoute( + v2ListTableDispatchesContract, + tableOperation({ + operationId: 'listTableDispatches', + summary: 'List Active Run Dispatches', + description: + 'List the run dispatches still in flight on one table. Bounded by the dispatcher rather than by a page size, so this list is unpaginated and `nextCursor` is always null. A settled dispatch is read by identifier.', + errors: RESOURCE_ERRORS, + success: { description: "The table's active run dispatches." }, + }), + { + params: documentedSchema( + v2ListTableDispatchesContract.params, + 'ListTableDispatchesParams', + 'List table dispatches path parameters', + 'Table whose active run dispatches should be listed.' + ), + query: documentedSchema( + v2ListTableDispatchesContract.query, + 'ListTableDispatchesQuery', + 'List table dispatches query', + 'Workspace scope for the table.' + ), + response: documentedSchema( + v2ListTableDispatchesContract.response.schema, + 'V2TableRunDispatchListResponse', + 'Table run dispatch list response', + "The table's active run dispatches." + ), + } + ), + defineOpenApiRoute( + v2MoveTablesContract, + tableOperation({ + operationId: 'moveTables', + summary: 'Move Tables and Folders', + description: + 'Move up to 100 tables and table folders into one destination folder in a single authorized request. Folders are named by canonical path, and `null` or `/` moves to the workspace root. Best-effort per item: a table filed inside a selected folder is reported in `skipped` because the folder already carries it, an entry that resolves to nothing lands in `notFound`, and an item refused by a lock or a folder cycle lands in `failed` with a reason. An invalid destination fails the whole request before anything moves.', + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], + success: { description: 'Per-item outcome of the bulk move.' }, + }), + { + query: v2MoveTablesContract.query, + body: documentedSchema( + v2MoveTablesContract.body, + 'BulkMoveTablesRequest', + 'Bulk move tables request', + 'Workspace scope, the tables and folder paths to move, and the destination folder path.', + [ + { + workspaceId: WORKSPACE_ID, + tableIds: [TABLE_ID], + folderPaths: ['/Sales/Enterprise'], + targetFolderPath: '/Revenue', + }, + ] + ), + response: documentedSchema( + v2MoveTablesContract.response.schema, + 'V2MoveTablesResponse', + 'Bulk move tables response', + 'Per-item outcome of a bulk table and folder move.' + ), + } + ), + defineOpenApiRoute( + v2BulkDeleteTablesContract, + tableOperation({ + operationId: 'bulkDeleteTables', + summary: 'Bulk Delete Tables and Folders', + description: + 'Archive up to 100 tables and delete table folders in a single authorized request. Folders are named by canonical path and each cascades to everything inside it; `deletedItems` reports the totals across every cascade. Archived tables stay recoverable through `POST /api/v2/tables/{tableId}/restore`. Best-effort per item, with the same `skipped` / `notFound` / `failed` dispositions as the bulk move.', + errors: [...RESOURCE_ERRORS, 'Locked', 'PayloadTooLarge'], + success: { description: 'Per-item outcome of the bulk delete.' }, + }), + { + query: v2BulkDeleteTablesContract.query, + body: documentedSchema( + v2BulkDeleteTablesContract.body, + 'BulkDeleteTablesRequest', + 'Bulk delete tables request', + 'Workspace scope, and the tables and folder paths to delete.', + [ + { + workspaceId: WORKSPACE_ID, + tableIds: [TABLE_ID], + folderPaths: ['/Sales/Archive'], + }, + ] + ), + response: documentedSchema( + v2BulkDeleteTablesContract.response.schema, + 'V2BulkDeleteTablesResponse', + 'Bulk delete tables response', + 'Per-item outcome of a bulk table and folder delete.' + ), + } + ), ] as const const routes = declaredRoutes.map(withRequestBodyErrors) diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index ef62bb6fcdf..4f7d8aebd81 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -1,3 +1,10 @@ +import { omit } from '@sim/utils/object' +import { + v2DeleteWorkflowChatDeploymentContract, + v2GetWorkflowChatDeploymentContract, + v2ListChatDeploymentsContract, + v2ReplaceWorkflowChatDeploymentContract, +} from '@/lib/api/contracts/v2/chat-deployments' import { documentedSchema, ERROR_RESPONSES, @@ -5,6 +12,7 @@ import { FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, HEAD_MIRRORS_GET, + HEAD_OMITS_PAYLOAD_HEADERS, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -12,6 +20,7 @@ import { RUN_RETENTION, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, + V2_BINARY_DOWNLOAD_HEADERS, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, @@ -20,12 +29,17 @@ import { } from '@/lib/api/contracts/v2/openapi/shared' import { EXECUTE_OPTION_CONSTRAINTS, + v2ActivateWorkflowVersionContract, + v2ApplyWorkflowOperationsContract, + v2ApplyWorkflowVariablesContract, v2CancelWorkflowRunContract, v2CreateWorkflowContract, v2CreateWorkflowFolderContract, v2DeleteWorkflowContract, v2DeleteWorkflowFolderContract, v2DeployWorkflowContract, + v2DownloadRunFileContract, + v2DuplicateWorkflowContract, v2ExecuteWorkflowContract, v2ExecuteWorkflowQueuedResponseSchema, v2ExecuteWorkflowSyncResponseSchema, @@ -33,19 +47,26 @@ import { v2GetWorkflowContract, v2GetWorkflowDeploymentContract, v2GetWorkflowRunContract, + v2GetWorkflowStateContract, v2GetWorkflowVersionContract, v2ImportWorkflowContract, v2ListWorkflowFoldersContract, v2ListWorkflowRunsContract, v2ListWorkflowsContract, v2ListWorkflowVersionsContract, + v2MoveWorkflowsContract, v2RelocateWorkflowFolderContract, + v2ReplaceWorkflowStateContract, + v2RestoreWorkflowContract, v2ResumeWorkflowContract, v2ResumeWorkflowQueuedResponseSchema, v2ResumeWorkflowSyncResponseSchema, + v2RevertWorkflowVersionContract, v2RollbackWorkflowContract, v2UndeployWorkflowContract, v2UpdateWorkflowContract, + v2UpdateWorkflowPublicApiContract, + v2UpdateWorkflowVersionContract, } from '@/lib/api/contracts/v2/workflows' import { defineOpenApiDocument, @@ -80,6 +101,19 @@ const WORKFLOW_FOLDER_EXAMPLE = { locked: false, } as const +/** An empty lint report, for examples where the findings are not the subject. */ +const EMPTY_LINT_EXAMPLE = { + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + fieldIssues: [], + unresolvedReferences: [], + notes: [], +} as const + const WORKFLOW_VERSION_EXAMPLE = { id: 'version_3', version: 3, @@ -91,6 +125,53 @@ const WORKFLOW_VERSION_EXAMPLE = { latestOperationStatus: 'active', } as const +/** + * The one confusable pair on this surface: `/workflows/{workflowId}/deployment` + * (singular) is the workflow's own API deployment, `/workflows/{workflowId}/deployments/chat` + * is one surface it is served on. Stated on both rather than on whichever the + * caller happens to open first. + */ +const WORKFLOW_DEPLOYMENT_VS_CHAT = + 'Not to be confused with `/workflows/{workflowId}/deployments/chat`, which is the hosted chat the workflow is published as. This path governs whether the workflow is executable at all; that one governs one surface it is served on. A workflow can be deployed with no chat, and removing its chat leaves it deployed and executable.' + +const CHAT_VS_WORKFLOW_DEPLOYMENT = + "Not to be confused with `/workflows/{workflowId}/deployment` (singular), which is the workflow's own API deployment — its live version and whether the draft has drifted. That path governs whether the workflow is executable at all; this one governs the hosted chat it is served on. The chat is a singleton of its workflow, so it has no id of its own in any path and no separate create verb: `PUT` is create-or-replace and is the only write." + +const CHAT_DEPLOYMENT_EXAMPLE = { + id: 'chat_01J8ZK3QW4M6X2R9T7B5C0V2', + workflowId: WORKFLOW_ID, + workspaceId: '9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94', + identifier: 'support', + url: 'https://sim.ai/chat/support', + title: 'Support chat', + description: 'Ask about billing, onboarding, or outages.', + isActive: true, + authType: 'public', + hasPassword: false, + allowedEmails: [], + customizations: { primaryColor: '#6F3DFA', welcomeMessage: 'Hi there! How can I help?' }, + outputConfigs: [{ blockId: 'block_01J8ZK3QW4M6X2R9T7B5C0V4', path: 'content' }], + includeThinking: false, + includeToolCalls: false, + createdAt: '2026-06-12T10:30:00.000Z', + updatedAt: '2026-06-12T10:30:00.000Z', +} as const + +/** The list projection: {@link CHAT_DEPLOYMENT_EXAMPLE} without the fields the detail read gates. */ +const CHAT_DEPLOYMENT_LIST_ITEM_EXAMPLE = omit(CHAT_DEPLOYMENT_EXAMPLE, [ + 'allowedEmails', + 'hasPassword', + 'customizations', +]) + +const WORKFLOW_GRAPH_EXAMPLE = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, +} as const + const RUN_RESULT_EXAMPLE = { data: { runId: RUN_ID, @@ -165,7 +246,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'listWorkflows', summary: 'List Workflows', - description: `List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. ${FOLDER_TREE_TOO_LARGE}`, + description: `List workflows in a workspace with lifecycle scope, folder and deployment filters, search, sorting, and opaque cursor pagination. \`scope\` defaults to \`active\`; pass \`archived\` to list workflows a \`DELETE\` archived. ${FOLDER_TREE_TOO_LARGE}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'PayloadTooLarge'], success: jsonSuccess('A page of workflows.'), }), @@ -185,7 +266,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'createWorkflowV2', summary: 'Create Workflow', - description: `Create a workflow in a workspace root or canonical workflow folder. ${FOLDER_TREE_TOO_LARGE}`, + description: `Create a workflow in a workspace root or canonical workflow folder. The response carries the blocks the platform seeded the workflow with, so the start block's id is available without a second request — attach edges to it directly. ${FOLDER_TREE_TOO_LARGE}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict', 'Locked', 'PayloadTooLarge'], success: jsonSuccess('The created workflow.'), }), @@ -196,11 +277,245 @@ const declaredRoutes = [ v2CreateWorkflowContract.response.schema, 'CreateWorkflowResponse', 'Create workflow response', - 'The created workflow summary.', + 'The created workflow and the blocks it was seeded with.', + [ + { + data: { + ...WORKFLOW_EXAMPLE, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + blocks: [{ id: 'start-1', type: 'starter', name: 'Start' }], + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2GetWorkflowStateContract, + workflowOperation({ + operationId: 'getWorkflowState', + summary: 'Get Workflow State', + description: + 'Get the editable draft graph of a workflow: blocks, edges, the loop and parallel containers derived from them, and variables. This is the pollable read — it records no audit event, and `HEAD` mirrors `GET`. The payload is **unsanitized**: it carries workspace-scoped `credentialId`, `knowledgeBaseId`, and `tableId` values verbatim, so it is not portable to another workspace. Use `GET /workflows/{workflowId}/export` for a portable, sanitized copy — and note that export is not a read-modify-write source, because sanitizing it drops every credential binding. Unknown members are stripped, so what this returns is exactly the set of keys `PUT /workflows/{workflowId}/state` accepts.', + /** + * No `413`: unlike the workflow reads beside it this one resolves no + * folder path, so it never materializes the workspace's folder tree, and + * a documented status the operation cannot emit is worse than none. The + * graph itself is bounded on the write side. + */ + errors: RESOURCE_ERRORS, + success: jsonSuccess('The workflow draft graph.'), + }), + { + params: v2GetWorkflowStateContract.params, + query: v2GetWorkflowStateContract.query, + response: documentedSchema( + v2GetWorkflowStateContract.response.schema, + 'WorkflowStateResponse', + 'Workflow state response', + 'The editable draft graph of a workflow.', + [{ data: WORKFLOW_GRAPH_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ReplaceWorkflowStateContract, + workflowOperation({ + operationId: 'replaceWorkflowState', + summary: 'Replace Workflow State', + description: `Replace a workflow\u2019s editable draft graph wholesale. \`loops\` and \`parallels\` are accepted but ignored — both are recomputed from \`blocks\`. Omitting \`variables\` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that \`needsRedeployment\` becomes true; \`POST /workflows/{workflowId}/deploy\` publishes the draft.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — but \`needsRedeployment\` describes the state before the write, and warnings raised by persistence itself are necessarily absent.`, + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The draft graph was replaced.'), + }), + { + params: v2ReplaceWorkflowStateContract.params, + query: documentedSchema( + v2ReplaceWorkflowStateContract.query, + 'ReplaceWorkflowStateQuery', + 'Replace workflow state query', + 'Whether to validate without persisting.' + ), + body: v2ReplaceWorkflowStateContract.body, + response: documentedSchema( + v2ReplaceWorkflowStateContract.response.schema, + 'ReplaceWorkflowStateResponse', + 'Replace workflow state response', + 'Outcome of replacing a workflow draft graph.', + [ + { + data: { + id: WORKFLOW_ID, + warnings: [], + needsRedeployment: true, + dryRun: false, + lint: EMPTY_LINT_EXAMPLE, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ApplyWorkflowOperationsContract, + workflowOperation({ + operationId: 'applyWorkflowOperations', + summary: 'Apply Workflow Operations', + description: `Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in \`skipped\`, each with a machine-readable \`type\`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. \`deferred\` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet \`atomic\` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers \`409\` with \`error.details.code: "OPERATIONS_NOT_APPLIED"\`, the same \`skipped\` array, and a \`droppedInputs\` array, having persisted nothing.\n\nA \`block_id\` you supply on an \`add\` or \`insert_into_subflow\` is only a label unless it is already a UUID: the engine mints one and returns the pairing in \`mintedBlockIds\`. References between operations in the same batch are remapped for you, so \`triage\` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation \`params\` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: \`inputs\` keyed by sub-block id, with \`retry\`, \`triggerMode\` and \`advancedMode\` beside it rather than inside it, and \`connections\` keyed by source handle. \`GET /blocks/{blockId}\` publishes the inputs a given block type accepts.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only \`inputValidationErrors\` lists inputs that were actually dropped.\n\nAs with \`PUT /workflows/{workflowId}/state\`, this changes only the draft; deploy to publish it. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — but \`needsRedeployment\` describes the state before the write, and warnings raised by persistence itself are necessarily absent.`, + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The batch was applied.'), + }), + { + params: v2ApplyWorkflowOperationsContract.params, + query: documentedSchema( + v2ApplyWorkflowOperationsContract.query, + 'ApplyWorkflowOperationsQuery', + 'Apply workflow operations query', + 'Whether to evaluate without persisting.' + ), + body: v2ApplyWorkflowOperationsContract.body, + response: documentedSchema( + v2ApplyWorkflowOperationsContract.response.schema, + 'ApplyWorkflowOperationsResponse', + 'Apply workflow operations response', + 'Outcome of a batch of semantic edits.', + [ + { + data: { + id: WORKFLOW_ID, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + mintedBlockIds: { triage: 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77' }, + lint: { + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + fieldIssues: [ + { + blockId: 'agent-1', + blockName: 'Triage', + blockType: 'agent', + missingRequiredFields: ['systemPrompt'], + inactiveModeValues: [], + }, + ], + unresolvedReferences: [], + notes: [], + }, + warnings: [], + needsRedeployment: true, + dryRun: false, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ApplyWorkflowVariablesContract, + workflowOperation({ + operationId: 'applyWorkflowVariables', + summary: 'Update Workflow Variables', + description: + 'Add, edit, and delete a workflow\u2019s variables. Operations are matched by variable `name` and applied in order; a batch that changes nothing answers `200` with `changed: false`. Values are coerced to the declared `type`, and a value that cannot be coerced is stored as supplied. Read the current set from `variables` on `GET /workflows/{workflowId}`.', + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The variable set after the batch.'), + }), + { + params: v2ApplyWorkflowVariablesContract.params, + query: v2ApplyWorkflowVariablesContract.query, + body: v2ApplyWorkflowVariablesContract.body, + response: documentedSchema( + v2ApplyWorkflowVariablesContract.response.schema, + 'ApplyWorkflowVariablesResponse', + 'Apply workflow variables response', + 'Outcome of a workflow variable update.', + [{ data: { id: WORKFLOW_ID, variableCount: 3, changed: true } }] + ), + } + ), + defineOpenApiRoute( + v2DuplicateWorkflowContract, + workflowOperation({ + operationId: 'duplicateWorkflow', + summary: 'Duplicate Workflow', + description: `Copy a workflow, including its blocks, edges, subflows, and variables, into the same workspace. Omitting \`name\` reuses the source name; a collision inside the destination folder is deduplicated rather than refused. ${FOLDER_TREE_TOO_LARGE}`, + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The created copy.'), + }), + { + params: v2DuplicateWorkflowContract.params, + query: v2DuplicateWorkflowContract.query, + body: v2DuplicateWorkflowContract.body, + response: documentedSchema( + v2DuplicateWorkflowContract.response.schema, + 'DuplicateWorkflowResponse', + 'Duplicate workflow response', + 'The created copy.', + [ + { + data: { + ...WORKFLOW_EXAMPLE, + name: 'Customer support triage (copy)', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2RestoreWorkflowContract, + workflowOperation({ + operationId: 'restoreWorkflow', + summary: 'Restore Workflow', + description: `Bring an archived workflow back, along with the schedules, webhooks, MCP tools, and chats that were archived with it. A workflow that is not archived answers \`409\`. A workflow whose folder was archived is restored to the workspace root. ${FOLDER_TREE_TOO_LARGE}`, + errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], + success: jsonSuccess('The restored workflow.'), + }), + { + params: v2RestoreWorkflowContract.params, + query: v2RestoreWorkflowContract.query, + response: documentedSchema( + v2RestoreWorkflowContract.response.schema, + 'RestoreWorkflowResponse', + 'Restore workflow response', + 'The restored workflow.', [{ data: WORKFLOW_EXAMPLE }] ), } ), + defineOpenApiRoute( + v2MoveWorkflowsContract, + workflowOperation({ + operationId: 'moveWorkflows', + summary: 'Move Workflows', + description: `Relocate up to 100 workflows into one folder. Explicitly best-effort: each workflow moves in its own transaction, and one that is absent from the workspace, archived, or locked lands in \`failed\` while the rest still move. Duplicate ids are collapsed. ${FOLDER_TREE_TOO_LARGE}`, + errors: [...WORKSPACE_ERRORS, 'NotFound'], + success: jsonSuccess('Which workflows moved and which did not.'), + }), + { + query: v2MoveWorkflowsContract.query, + body: v2MoveWorkflowsContract.body, + response: documentedSchema( + v2MoveWorkflowsContract.response.schema, + 'MoveWorkflowsResponse', + 'Move workflows response', + 'Which workflows moved and which did not.', + [{ data: { moved: [WORKFLOW_ID], failed: [], folderPath: '/Operations' } }] + ), + } + ), defineOpenApiRoute( v2GetWorkflowContract, workflowOperation({ @@ -249,9 +564,10 @@ const declaredRoutes = [ workflowOperation({ operationId: 'deleteWorkflowV2', summary: 'Delete Workflow', - description: 'Permanently delete a workflow and its associated mutable state.', + description: + 'Archive a workflow. Despite the verb, this is not an erasure: the workflow, and the schedules, webhooks, MCP tools, and chats attached to it, are stamped archived and stop running, and `POST /workflows/{workflowId}/restore` brings all of them back. An archived workflow disappears from the default list and is reachable with `scope=archived`. The `deleted` field is retained for shipped clients; `archived` states what actually happened.', errors: [...RESOURCE_ERRORS, 'Locked'], - success: jsonSuccess('The workflow was deleted.'), + success: jsonSuccess('The workflow was archived.'), }), { query: v2DeleteWorkflowContract.query, @@ -260,8 +576,8 @@ const declaredRoutes = [ v2DeleteWorkflowContract.response.schema, 'DeleteWorkflowResponse', 'Delete workflow response', - 'Confirmation that the workflow was deleted.', - [{ data: { id: WORKFLOW_ID, deleted: true } }] + 'Confirmation that the workflow was archived.', + [{ data: { id: WORKFLOW_ID, deleted: true, archived: true } }] ), } ), @@ -319,13 +635,110 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2UpdateWorkflowVersionContract, + workflowOperation({ + operationId: 'updateWorkflowVersionV2', + summary: 'Update Workflow Version', + description: + 'Relabel a deployment version. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the release note. Metadata only — the pinned graph is immutable, and this never changes which version is live. Promote a version with `POST /workflows/{workflowId}/versions/{version}/activate`.', + errors: RESOURCE_ERRORS, + success: jsonSuccess('The updated version metadata.'), + }), + { + query: v2UpdateWorkflowVersionContract.query, + params: v2UpdateWorkflowVersionContract.params, + body: v2UpdateWorkflowVersionContract.body, + response: documentedSchema( + v2UpdateWorkflowVersionContract.response.schema, + 'UpdateWorkflowVersionResponse', + 'Update workflow version response', + 'The deployment version metadata after the update.', + [ + { + data: { + version: WORKFLOW_VERSION_EXAMPLE.version, + name: WORKFLOW_VERSION_EXAMPLE.name, + description: WORKFLOW_VERSION_EXAMPLE.description, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ActivateWorkflowVersionContract, + workflowOperation({ + operationId: 'activateWorkflowVersion', + summary: 'Activate Workflow Version', + description: `Promote an existing deployment version to live. Activation is asynchronous; inspect \`isDeployed\` and \`latestDeploymentAttempt\` for current state. Unlike \`rollback\`, the target is named by the path and the workflow need not already be deployed. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], + success: jsonSuccess('The accepted activation attempt.'), + }), + { + query: v2ActivateWorkflowVersionContract.query, + params: v2ActivateWorkflowVersionContract.params, + body: v2ActivateWorkflowVersionContract.body, + response: documentedSchema( + v2ActivateWorkflowVersionContract.response.schema, + 'ActivateWorkflowVersionResponse', + 'Activate workflow version response', + 'Current deployment state after accepting the activation attempt.', + [ + { + data: { + id: WORKFLOW_ID, + isDeployed: false, + deployedAt: null, + warnings: [], + activeDeployment: null, + latestDeploymentAttempt: { + id: 'depop_01J8ZK4RX5N7Y3S0U8D6E1W2', + deploymentVersionId: 'depver_01J8ZK4RX5N7Y3S0U8D6E1W3', + version: 3, + action: 'activate', + status: 'activating', + isCurrent: true, + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'not_applicable' }, + requestedAt: '2026-06-12T10:30:00.000Z', + activatedAt: null, + error: null, + }, + version: 3, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2RevertWorkflowVersionContract, + workflowOperation({ + operationId: 'revertWorkflowVersion', + summary: 'Revert Workflow To Version', + description: `Overwrite the editable draft with the graph pinned by a deployment version, discarding every unsaved edit. This is the most destructive operation in the deployment family and it does **not** change what is live — to move production, use \`activate\` or \`rollback\`, both of which leave the draft alone. Pass \`active\` as the version to discard draft edits and return to the live graph. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], + success: jsonSuccess('The draft after it was overwritten.'), + }), + { + query: v2RevertWorkflowVersionContract.query, + params: v2RevertWorkflowVersionContract.params, + body: v2RevertWorkflowVersionContract.body, + response: documentedSchema( + v2RevertWorkflowVersionContract.response.schema, + 'RevertWorkflowVersionResponse', + 'Revert workflow version response', + 'The draft after it was overwritten by the deployment version.', + [{ data: { id: WORKFLOW_ID, version: 3, lastSaved: 1765535400000 } }] + ), + } + ), defineOpenApiRoute( v2GetWorkflowDeploymentContract, workflowOperation({ operationId: 'getWorkflowDeployment', summary: 'Get Workflow Deployment', - description: - 'Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.', + description: `Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes \`needsRedeployment\` and \`isPublicApi\`.\n\n\`isPublicApi\` is the security-relevant one: while it is \`true\` the deployed workflow executes without an API key, so anyone holding the execution URL can run it — and consume the workspace’s billed usage — anonymously. It is set through \`PATCH /workflows/{workflowId}/deployment\`, and this read is the only way to audit whether it is on.\n\n${WORKFLOW_DEPLOYMENT_VS_CHAT}`, errors: RESOURCE_ERRORS, success: jsonSuccess('The current deployment state.'), }), @@ -336,13 +749,14 @@ const declaredRoutes = [ v2GetWorkflowDeploymentContract.response.schema, 'WorkflowDeploymentResponse', 'Workflow deployment response', - 'Current deployment state, including draft-versus-live drift.', + 'Current deployment state, including draft-versus-live drift and whether the deployment is publicly executable.', [ { data: { id: WORKFLOW_ID, isDeployed: true, needsRedeployment: true, + isPublicApi: false, deployedAt: '2026-06-12T10:30:00.000Z', warnings: [], activeDeployment: { @@ -368,6 +782,28 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2UpdateWorkflowPublicApiContract, + workflowOperation({ + operationId: 'updateWorkflowPublicApi', + summary: 'Update Workflow Public API Access', + description: `Enable or disable unauthenticated public execution of the deployed workflow. While enabled, anyone holding the execution URL can run the workflow without an API key. An organization that forbids public sharing refuses this with \`403\` and \`PUBLIC_SHARING_NOT_ALLOWED\`. ${WORKFLOW_DEPLOYMENT_VS_CHAT} ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge', 'Locked'], + success: jsonSuccess('The updated public API setting.'), + }), + { + query: v2UpdateWorkflowPublicApiContract.query, + params: v2UpdateWorkflowPublicApiContract.params, + body: v2UpdateWorkflowPublicApiContract.body, + response: documentedSchema( + v2UpdateWorkflowPublicApiContract.response.schema, + 'UpdateWorkflowPublicApiResponse', + 'Update workflow public API response', + 'Public API access after the update.', + [{ data: { id: WORKFLOW_ID, isPublicApi: true } }] + ), + } + ), defineOpenApiRoute( v2DeployWorkflowContract, workflowOperation({ @@ -450,7 +886,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'rollbackWorkflow', summary: 'Rollback Workflow', - description: `Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. ${WORKSPACE_API_KEY_DENIED}`, + description: `Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. Use this to step back from the currently live version; to make a specific version live by naming it in the path — including when the workflow is not currently deployed — use \`POST /workflows/{workflowId}/versions/{version}/activate\`. Neither touches the draft. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The accepted rollback attempt.'), }), @@ -559,6 +995,91 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListChatDeploymentsContract, + workflowOperation({ + operationId: 'listChatDeployments', + summary: 'List Chat Deployments', + description: + 'List the workflows a workspace has published as hosted chats. Each entry carries the public `url` a visitor uses — there is no chat subdomain, the identifier is a path segment.\n\nThis is the only chat path not addressed under a workflow, and deliberately so: every chat is a singleton of the workflow it publishes, but "what does this workspace serve" is a question no per-workflow path can answer. Filter by `workflowId` to resolve one workflow\'s chat without holding its id.\n\nEntries are deliberately narrower than the singleton read: `allowedEmails`, `hasPassword`, and `customizations` are available only from `GET /api/v2/workflows/{workflowId}/deployments/chat`, which requires workspace `admin`. That is what keeps this list callable at workspace `read` and by a workspace API key. A stored password is never returned by either.', + errors: RESOURCE_ERRORS, + success: jsonSuccess('A page of chat deployments.'), + }), + { + query: v2ListChatDeploymentsContract.query, + response: documentedSchema( + v2ListChatDeploymentsContract.response.schema, + 'ChatDeploymentListResponse', + 'Chat deployment list response', + 'A cursor-paginated page of chat deployments.', + [{ data: [CHAT_DEPLOYMENT_LIST_ITEM_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2GetWorkflowChatDeploymentContract, + workflowOperation({ + operationId: 'getWorkflowChatDeployment', + summary: 'Get Workflow Chat Deployment', + description: `Read the hosted chat a workflow is published as. Answers \`404\` when the workflow publishes no chat. ${CHAT_VS_WORKFLOW_DEPLOYMENT} The stored password is never returned — \`hasPassword\` reports only whether one is set. This carries the visitor gate — \`authType\`, \`hasPassword\`, and the \`allowedEmails\` allow-list — so it requires workspace \`admin\`, unlike the workspace-wide list. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: jsonSuccess("The workflow's chat deployment."), + }), + { + query: v2GetWorkflowChatDeploymentContract.query, + params: v2GetWorkflowChatDeploymentContract.params, + response: documentedSchema( + v2GetWorkflowChatDeploymentContract.response.schema, + 'GetWorkflowChatDeploymentResponse', + 'Get workflow chat deployment response', + "The workflow's chat deployment.", + [{ data: CHAT_DEPLOYMENT_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ReplaceWorkflowChatDeploymentContract, + workflowOperation({ + operationId: 'replaceWorkflowChatDeployment', + summary: 'Create or Replace Workflow Chat Deployment', + description: `Publish a workflow as a hosted chat, or replace the chat it already publishes. ${CHAT_VS_WORKFLOW_DEPLOYMENT}\n\n**Replace, not merge.** The chat ends up as exactly what the body describes: an omitted optional field takes its platform default rather than whatever the previous chat carried, so sending the same body twice leaves the same result. \`password\` is therefore required whenever \`authType\` is \`"password"\` and rejected otherwise — it is write-only and never readable back, so carrying one over implicitly is the one place a replace would quietly stop meaning replace. \`allowedEmails\` follows the same rule: required and non-empty for \`"email"\` and \`"sso"\`, rejected for the modes that admit no allow-list. \`customizations\` is the one documented exception: it merges per field, so an omitted \`imageUrl\` keeps the stored one rather than clearing it, and customization keys this surface does not declare do not survive the write. That behaviour is shared with the in-app editor and the Copilot deploy tool, which both send partial objects.\n\nThis also deploys the workflow, because a chat serves the live version: a draft that has drifted is republished as part of the call. Two conditions answer \`409\` — an \`identifier\` another live chat already holds, and a workflow deployment attempt still preparing, which the caller can retry once it becomes active. \`authType: "public"\` leaves the chat open to anyone holding the URL. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], + success: jsonSuccess('The published chat deployment.'), + }), + { + query: v2ReplaceWorkflowChatDeploymentContract.query, + params: v2ReplaceWorkflowChatDeploymentContract.params, + body: v2ReplaceWorkflowChatDeploymentContract.body, + response: documentedSchema( + v2ReplaceWorkflowChatDeploymentContract.response.schema, + 'ReplaceWorkflowChatDeploymentResponse', + 'Replace workflow chat deployment response', + 'The chat deployment as stored after the replace.', + [{ data: CHAT_DEPLOYMENT_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2DeleteWorkflowChatDeploymentContract, + workflowOperation({ + operationId: 'deleteWorkflowChatDeployment', + summary: 'Delete Workflow Chat Deployment', + description: `Stop serving a workflow's hosted chat. Its URL stops answering and the identifier becomes free again. The workflow's own deployment is untouched and stays executable through the workflow API — to undeploy that, use \`DELETE /workflows/{workflowId}/deploy\`. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: jsonSuccess('The chat deployment was removed.'), + }), + { + query: v2DeleteWorkflowChatDeploymentContract.query, + params: v2DeleteWorkflowChatDeploymentContract.params, + response: documentedSchema( + v2DeleteWorkflowChatDeploymentContract.response.schema, + 'DeleteWorkflowChatDeploymentResponse', + 'Delete workflow chat deployment response', + 'Acknowledgement that the chat deployment was removed.', + [{ data: { id: CHAT_DEPLOYMENT_EXAMPLE.id, deleted: true } }] + ), + } + ), defineOpenApiRoute( v2ExecuteWorkflowContract, workflowOperation({ @@ -644,8 +1165,8 @@ const declaredRoutes = [ workflowRunOperation({ operationId: 'getWorkflowRunV2', summary: 'Get Workflow Run', - description: 'Get current workflow run state, optionally including final and block outputs.', - errors: RESOURCE_CONFLICT_ERRORS, + description: `Get current workflow run state, optionally including final and block outputs. With \`includeOutput\`, \`files\` lists the files the run produced, each with a \`downloadPath\`; add \`includeFileBase64\` to inline their bytes, which answers \`413\` naming the download path when a single file, or the run's inlined total, exceeds the 16 MiB ceiling. Because inlining reads object storage, this \`GET\` is not a safe read. ${HEAD_MIRRORS_GET}`, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The workflow run status.'), }), { @@ -671,12 +1192,40 @@ const declaredRoutes = [ error: null, output: { result: 'Ticket routed to Support' }, blockOutputs: null, + files: [ + { + id: 'file_1a2b3c', + name: 'summary.pdf', + size: 20_480, + type: 'application/pdf', + downloadPath: `/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/files/file_1a2b3c`, + base64: null, + }, + ], }, }, ] ), } ), + defineOpenApiRoute( + v2DownloadRunFileContract, + workflowRunOperation({ + operationId: 'downloadWorkflowRunFileV2', + summary: 'Download Workflow Run File', + description: `Download one file a run produced. The run resource reports the files a run emitted; address one of them by its \`id\` here. Run output carries \`/api/files/serve/...\` URLs that reject API keys, so this is the byte path out of a run for an API-key caller. Execution objects are not retained indefinitely, so a \`404\` for a file an older run produced is expected rather than a fault. ${RUN_RETENTION} Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, + errors: [...RESOURCE_CONFLICT_ERRORS], + success: { + description: 'The run file bytes.', + headers: [...RATE_LIMIT_HEADERS, 'Content-Type', 'Content-Disposition', 'Content-Length'], + contentTypes: ['application/octet-stream'], + }, + }), + { + params: v2DownloadRunFileContract.params, + query: v2DownloadRunFileContract.query, + } + ), defineOpenApiRoute( v2ResumeWorkflowContract, workflowRunOperation({ @@ -894,7 +1443,7 @@ export const workflowsOpenApiDocument = defineOpenApiDocument({ ], security: V2_API_KEY_SECURITY, securitySchemes: V2_API_KEY_SECURITY_SCHEMES, - headers: V2_COMMON_HEADERS, + headers: { ...V2_BINARY_DOWNLOAD_HEADERS, ...V2_COMMON_HEADERS }, errorSchema: V2_ERROR_SCHEMA, errorResponses: ERROR_RESPONSES, routes, diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index df65bdfb1c3..677de6ac6af 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -57,7 +57,7 @@ import { * A list that needs a real expression tree (Tables) keeps its own `POST /query`. * * Two lists predate the convention and are the documented exceptions: - * `GET /api/v2/logs` and `GET /api/v2/workflows/{id}/runs` have no `sortBy` + * `GET /api/v2/logs` and `GET /api/v2/workflows/{workflowId}/runs` have no `sortBy` * (the sort column is fixed to execution start time) and spell the direction * `order`, not `sortOrder`. They are not a pattern to copy, and renaming the * param would break shipped callers. @@ -66,7 +66,7 @@ import { * against the resource's *single* natural name field, and nothing else: * `name` for files/folders/workflows/tables/knowledge bases/MCP servers/ * skills/credential providers, `title` for custom tools, `filename` for - * knowledge documents (`GET /knowledge/{id}/documents`), and `displayName` + * knowledge documents (`GET /knowledge/{knowledgeBaseId}/documents`), and `displayName` * for both credentials and secrets (`GET /secrets`, where the secret's name * *is* the credential `displayName`). It never matches ids, descriptions, or * content. `%` and `_` in the term are matched literally, not as wildcards. @@ -111,8 +111,8 @@ import { * including `GET /mcp-servers`, since nothing caps how many servers a workspace * registers. What remains full-set is bounded by construction rather than by a * caller's `limit`: the four folder lists, whose trees are capped where they - * load; `GET /knowledge/{id}/tags`, capped by the fixed tag-slot table; - * `GET /mcp-servers/{id}/tools`, capped by tool discovery itself; and + * load; `GET /knowledge/{knowledgeBaseId}/tags`, capped by the fixed tag-slot table; + * `GET /mcp-servers/{mcpServerId}/tools`, capped by tool discovery itself; and * `GET /tables/{tableId}/views` and `GET /tables/{tableId}/groups`, capped per * table; and the credential-provider catalog, bounded by code-defined OAuth * and service-account registries. @@ -127,12 +127,12 @@ import { * (`encodeSortedCursor`) wherever the page comes from one ordered SQL read, and * an offset (`encodeOffsetCursor`) only where it cannot — `GET /skills`, which * merges the static builtin registry into the DB rows and re-sorts in JS, and - * `GET /knowledge/{id}/documents`, whose underlying query is limit/offset. + * `GET /knowledge/{knowledgeBaseId}/documents`, whose underlying query is limit/offset. * Prefer the keyset; an offset needs that kind of reason. * * The third is per-domain: a list whose read predates the shared codecs, or * whose page boundary is not expressible as one, mints its own — a bare - * `encodeCursor({ version })` on `GET /workflows/{id}/versions` and + * `encodeCursor({ version })` on `GET /workflows/{workflowId}/versions` and * `encodeCursor({ email })` on the workspace member list, the audit-log and run-log * codecs in `lib/audit-logs/query.ts` and `lib/logs/list-logs.ts`, the table-row * codec in `lib/table/rows/cursor.ts`, and a usage-event id passed straight @@ -152,9 +152,9 @@ import { * * The authoritative per-list binding is pinned in * `v2/__tests__/list-pagination.test.ts`, which fails when a list gains a param - * that is neither bound nor explicitly exempted. The three lists whose token is - * minted by a domain codec (`GET /logs`, `GET /audit-logs`, `GET /billing/logs`) - * get the same binding by wrapping that token in a query-stamped envelope. + * that is neither bound nor explicitly exempted. The two lists whose token is + * minted by a domain codec (`GET /audit-logs`, `GET /billing/logs`) get the same + * binding by wrapping that token in a query-stamped envelope. */ /** @@ -246,7 +246,7 @@ export const v2CursorListResponse = ( * Default and maximum page size for a v2 paged list. * * These are the values the majority of already-paged v2 lists shipped with - * (`/workflows`, `/workflows/{id}/versions`, `/workflows/{id}/runs`, + * (`/workflows`, `/workflows/{workflowId}/versions`, `/workflows/{workflowId}/runs`, * `/workspaces/{id}/members`, `/billing/logs`), so they are what a list adopting * pagination now inherits. */ @@ -376,7 +376,7 @@ export function v2PaginationFields(options: V2LimitOptions = {}) { * * The form is `z.datetime()`, which is UTC-only: a date with no time * (`2026-08-06`) and an offset-bearing timestamp (`2026-08-06T00:00:00+02:00`) - * are both rejected. `GET /logs` and `GET /workflows/{id}/runs` are sibling + * are both rejected. `GET /logs` and `GET /workflows/{workflowId}/runs` are sibling * reads over the same runs, so the same timestamp must work on both — sharing * the schema is what makes that true rather than merely intended, and it is why * the descriptions say "UTC ISO 8601" instead of overpromising "ISO 8601". @@ -405,18 +405,19 @@ export function v2RunWindowBoundSchema(field: 'startDate' | 'endDate') { } /** - * The single `order` param the two run-window reads take in place of - * `sortBy` + `sortOrder`, for the same reason they share - * {@link v2RunWindowBoundSchema}: `GET /logs` and `GET /workflows/{id}/runs` are - * sibling reads over the same runs, so a value that works on one must work on - * the other. - * - * Sharing it also keeps the *published* member order identical. Two hand-written - * `z.enum([...])` literals spelled the same set in opposite orders, which the - * generated specs faithfully reproduced — harmless to a parser, but it reads as - * two APIs rather than one, and a caller comparing the two pages has no way to - * tell an ordering accident from a meaningful difference. The order is - * {@link LIST_SORT_ORDERS}, the same one `sortOrder` publishes everywhere else. + * The single `order` param `GET /workflows/{workflowId}/runs` takes in place of + * `sortBy` + `sortOrder`, because start time is the only column it can order by. + * + * `GET /logs` used to be its twin here. It is not any more: it reads the same + * rows but can also order them by duration, cost, and status, so it publishes + * the ordinary `sortBy` + `sortOrder` pair. The two remain sibling reads and + * still share {@link v2RunWindowBoundSchema}, so a timestamp that works on one + * works on the other — only the ordering vocabulary differs, and it differs + * because the sortable sets genuinely differ. + * + * The member order is {@link LIST_SORT_ORDERS}, the same one `sortOrder` + * publishes everywhere else, so the two spellings cannot drift apart in the + * generated specs and read as two APIs. */ export function v2RunOrderSchema(subject: 'execution' | 'run') { return z diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index e4fd1fc7943..b5150f48e96 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -27,11 +27,11 @@ import { * * 1. **Single-resource writes.** The internal `POST` takes an array, conflates * create and update, and answers with the whole workspace skill list. v2 - * splits it into `POST /v2/skills` (201) and `PATCH /v2/skills/[id]`, each + * splits it into `POST /v2/skills` (201) and `PATCH /v2/skills/[skillId]`, each * answering with the one skill that changed. * 2. **`content` is detail-only.** A skill body is up to 50 000 characters, so * the list returns summaries and the full body is fetched per skill from - * `GET /v2/skills/[id]`. + * `GET /v2/skills/[skillId]`. * * Field validation lives in `lib/skills/orchestration`, so these schemas and the * lib enforce the same limits — the schemas reuse the shared field primitives @@ -119,7 +119,7 @@ export const v2SkillEditorDeleteDataSchema = z export type V2SkillEditorDeleteData = z.output export const v2SkillParamsSchema = z.object({ - id: nonEmptyIdSchema.describe( + skillId: nonEmptyIdSchema.describe( 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.' ), }) @@ -258,7 +258,7 @@ export const v2CreateSkillContract = defineRouteContract({ export const v2GetSkillContract = defineRouteContract({ method: 'GET', - path: '/api/v2/skills/[id]', + path: '/api/v2/skills/[skillId]', params: v2SkillParamsSchema, query: v2SkillWorkspaceQuerySchema, response: { @@ -269,7 +269,7 @@ export const v2GetSkillContract = defineRouteContract({ export const v2UpdateSkillContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/skills/[id]', + path: '/api/v2/skills/[skillId]', query: noInputSchema, params: v2SkillParamsSchema, body: v2UpdateSkillBodySchema, @@ -281,7 +281,7 @@ export const v2UpdateSkillContract = defineRouteContract({ export const v2DeleteSkillContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/skills/[id]', + path: '/api/v2/skills/[skillId]', params: v2SkillParamsSchema, query: v2SkillWorkspaceQuerySchema, response: { @@ -292,7 +292,7 @@ export const v2DeleteSkillContract = defineRouteContract({ export const v2ListSkillEditorsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/skills/[id]/editors', + path: '/api/v2/skills/[skillId]/editors', params: v2SkillParamsSchema, query: v2ListSkillEditorsQuerySchema, response: { @@ -303,7 +303,7 @@ export const v2ListSkillEditorsContract = defineRouteContract({ export const v2GrantSkillEditorContract = defineRouteContract({ method: 'POST', - path: '/api/v2/skills/[id]/editors', + path: '/api/v2/skills/[skillId]/editors', params: v2SkillParamsSchema, query: noInputSchema, body: v2GrantSkillEditorBodySchema, @@ -316,7 +316,7 @@ export const v2GrantSkillEditorContract = defineRouteContract({ export const v2RevokeSkillEditorContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/skills/[id]/editors', + path: '/api/v2/skills/[skillId]/editors', params: v2SkillParamsSchema, query: v2RevokeSkillEditorQuerySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index bfb77e18be6..6dae538c028 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + noInputSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { addWorkflowGroupBodySchema, cancelTableRunsBodyBaseSchema, @@ -54,6 +58,7 @@ import { v2FolderPathSchema, v2FolderSchema, v2ListFoldersQuerySchema, + v2NonRootFolderPathInputSchema, v2PaginationFields, v2RelocateFolderBodySchema, v2SearchSchema, @@ -68,7 +73,7 @@ import { v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' -import { TABLE_LIMITS } from '@/lib/table/constants' +import { MAX_TABLE_BATCH_ITEMS, TABLE_LIMITS } from '@/lib/table/constants' import { CSV_DURABLE_MAX_FILE_SIZE_BYTES, CSV_DURABLE_MAX_FILE_SIZE_MESSAGE, @@ -101,6 +106,18 @@ import type { RowData } from '@/lib/table/types' export const V2_DEFAULT_ROW_LIMIT = 100 /** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` (query) or an export resource. */ export const V2_MAX_ROW_LIMIT = 1000 +/** + * Hard cap on a page `limit` that also asks for the run-state sidecar. + * + * The sidecar is a second read whose `blockErrors` are unbounded jsonb, so its + * cost is not a function of the row data the page already bounds. The drain + * enforces its own byte budget, but a byte budget only decides how far a read + * gets before it is refused — this bounds how much a caller may ask for in the + * first place, so an ordinary large page keeps working while the pathological + * one is a `400` naming the flag that caused it rather than a `413` after the + * work was started. + */ +export const V2_MAX_RUN_STATE_ROW_LIMIT = 200 /** Keeps upload-token metadata comfortably below common 8 KiB request-header limits after signing. */ export const V2_TABLE_IMPORT_OPTIONS_MAX_BYTES = 2 * 1024 @@ -182,10 +199,12 @@ export const v2ApiTableSchema = z export type V2ApiTable = z.output /** - * Public row shape emitted by `toApiRow`: `{ id, data, createdAt, updatedAt }`, - * no storage internals (`position`/`orderKey`/`executions`). `data` is keyed by - * column NAME and select cells carry their option NAME; cell values are - * user-defined, so the map is `Record`. Timestamps ISO. + * Public row shape emitted by `toApiRow`: `{ id, data, createdAt, updatedAt }` + * plus an opt-in `runState`. Storage internals stay off the wire — + * `position` and `orderKey` are a fractional index a caller cannot mint and + * that is nullable mid-backfill. `data` is keyed by column NAME and select + * cells carry their option NAME; cell values are user-defined, so the map is + * `Record`. Timestamps ISO. */ export const v2RowDataSchema = z .record( @@ -200,10 +219,75 @@ export const v2RowDataSchema = z examples: [{ email: 'jane@example.com', name: 'Jane Doe', age: 30 }], }) as z.ZodType +/** + * Outcome of the most recent workflow-group run on one cell. + * + * Mirrors the stored `table_row_executions` sidecar minus two fields: `jobId` + * is the async scheduler's own identity and addresses nothing public, and + * `enrichmentDetails` is the deep provider cascade, which has its own + * sub-resource (`GET /tables/{tableId}/rows/{rowId}/enrichment/{groupId}`) + * precisely so it stays off the paged row read. + * + * The status enum is the column's full domain, not the subset any one caller + * happens to observe: a run reaches a terminal state, and a response schema + * that only knew the in-flight half would turn reading a finished cell into a + * 500. + */ +/** + * `status` and `blockErrors` come off a `text` column and a schemaless JSONB + * column, both read through bare `as` casts. The writers guard both shapes, so + * drift is latent rather than observed — but a response schema is `.parse`d on + * the way out, so a closed enum and a strict `Record` would each + * turn one drifted row into a `500` on a well-formed read. `status` is therefore + * a documented string rather than an enum, and the loader projects `blockErrors` + * through `normalizeBlockErrors` before it reaches here. + */ +export const v2RowRunStateSchema = z + .object({ + status: z + .string() + .describe( + 'Lifecycle state of the most recent run for this cell: `pending`, `queued`, `running`, `completed`, `error`, or `canceled`.' + ), + executionId: z + .string() + .nullable() + .describe('Workflow execution identifier, or null before a worker claimed the cell.'), + workflowId: z.string().describe('Workflow the group runs for this cell.'), + error: z.string().nullable().describe('Failure reason, or null when the run did not fail.'), + runningBlockIds: z.array(z.string()).describe('Block identifiers currently mid-execution.'), + blockErrors: z + .record(z.string(), z.string()) + .describe('Per-block failure messages keyed by block identifier.'), + canceledAt: v2TimestampSchema + .nullable() + .describe('ISO 8601 timestamp when the cell was canceled, or null.'), + }) + .meta({ + id: 'V2TableRowRunState', + title: 'Table row run state', + description: 'Run outcome for one workflow group on one row.', + }) +export type V2RowRunState = z.output + export const v2ApiRowSchema = z .object({ id: z.string().describe('Unique row identifier.'), data: v2RowDataSchema.describe('Row cells keyed by column name.'), + /** + * Per-group run state, opt-in. + * + * Optional rather than nullable on purpose: absent means "not requested", + * which is a different fact from "requested and this row has never run" + * (an empty object). Only the three read surfaces that accept + * `includeRunState` ever populate it. + */ + runState: z + .record(z.string(), v2RowRunStateSchema) + .optional() + .describe( + 'Per-workflow-group run state keyed by group identifier. Present only when the read requested `includeRunState`.' + ), createdAt: v2TimestampSchema.describe('ISO 8601 timestamp when the row was created.'), updatedAt: v2TimestampSchema.describe('ISO 8601 timestamp when the row was last modified.'), }) @@ -335,9 +419,23 @@ export type V2TableSortBy = (typeof v2TableSortFields)[number] * `v1ListTablesQuerySchema` — the single-table read/delete routes reuse that * schema and have no list params. */ +/** + * Listing scopes. Two-valued, mirroring `v2WorkflowScopeSchema` and + * `v2FileScopeSchema` rather than the three-valued internal `tableScopeSchema`: + * `all` drops the `archived_at` predicate entirely and degrades to a full + * workspace scan, and a caller that wants both sets can walk two pages. + */ +export const v2TableScopeSchema = z.enum(['active', 'archived']) +export type V2TableScope = z.output + export const v2ListTablesQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace whose tables should be listed.'), + scope: v2TableScopeSchema + .default('active') + .describe( + 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' + ), folderPath: v2FolderPathInputSchema .optional() .describe(`Restrict results to tables in this folder. ${V2_FOLDER_FILTER_MISS}`), @@ -560,6 +658,61 @@ export const v2DeleteTableFolderContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2DeleteTableFolderDataSchema) }, }) +export const v2RestoreTableFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the archived folder.'), + path: v2NonRootFolderPathInputSchema.describe( + 'Path the folder held when `DELETE /api/v2/tables/folders` archived it.' + ), + }) + .strict() +export type V2RestoreTableFolderBody = z.input + +export const v2RestoreTableFolderDataSchema = z + .object({ + folder: v2FolderSchema.describe( + 'The restored folder, at the path it actually landed on — which is not always the path requested.' + ), + restoredItems: z + .object({ + folders: z + .number() + .int() + .nonnegative() + .describe('Folders restored, including the one addressed.'), + tables: z.number().int().nonnegative().describe('Tables restored inside the folder tree.'), + }) + .strict() + .describe('What the restore brought back.'), + }) + .strict() + .meta({ + id: 'V2TableFolderRestore', + title: 'Table folder restore result', + description: 'The restored folder and the counts of items it brought back.', + }) +export type V2TableFolderRestore = z.output + +/** + * Restores a soft-deleted table folder tree. + * + * `DELETE /api/v2/tables/folders` archives recursively, so without this the archived tables + * were visible through `GET /api/v2/tables?scope=archived` while the folder structure itself + * was unrecoverable over the API. + * + * Path-addressed, matching the rest of the v2 table folder family, and the path is the one + * the folder held at delete time. The restore can legally land it elsewhere — a folder whose + * parent is still archived is re-rooted, and a name an active sibling took meanwhile is + * deduplicated — so read the returned folder's `path` rather than assuming the request's. + */ +export const v2RestoreTableFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/folders/restore', + query: noInputSchema, + body: v2RestoreTableFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2RestoreTableFolderDataSchema) }, +}) + export const v2DeleteTableContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -704,8 +857,29 @@ export const v2TableRowsQuerySchema = tableRowsQueryBaseSchema .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.' ), + includeRunState: booleanQueryFlagSchema + .optional() + .default(false) + .describe( + `Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its \`blockErrors\` are unbounded, so a full page carries it only when asked. Caps \`limit\` at ${V2_MAX_RUN_STATE_ROW_LIMIT}.` + ), }) .strict() + /** + * The same ceiling `POST /query` applies to the same flag. This read cannot + * express the unbounded form, so there is no `limit: 0` pair to refuse here — + * but two different row caps for one flag across two reads of one resource is + * an inconsistency a caller can only discover from a 400. + */ + .superRefine((query, ctx) => { + if (query.includeRunState && query.limit > V2_MAX_RUN_STATE_ROW_LIMIT) { + ctx.addIssue({ + code: 'custom', + path: ['limit'], + message: `limit cannot exceed ${V2_MAX_RUN_STATE_ROW_LIMIT} when includeRunState is set`, + }) + } + }) export type V2TableRowsQuery = z.output /** Cursor-paginated row list. */ @@ -755,8 +929,40 @@ export const v2QueryRowsBodySchema = z .min(1, 'cursor must be a non-empty token') .optional() .describe('Opaque cursor returned by the previous query page.'), + includeRunState: z + .boolean() + .optional() + .default(false) + .describe( + `Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its \`blockErrors\` are unbounded, so a full page carries it only when asked. Incompatible with \`limit: 0\`, and caps \`limit\` at ${V2_MAX_RUN_STATE_ROW_LIMIT}.` + ), }) .strict() + .superRefine((body, ctx) => { + if (!body.includeRunState) return + /** + * `limit: 0` is the unbounded form, and the sidecar has no page to be + * bounded by: the row drain would read the whole table and the sidecar read + * would follow it, both before anything could refuse the result. Refusing + * the pair at the contract is the only place that costs nothing. + */ + if (body.limit === 0) { + ctx.addIssue({ + code: 'custom', + path: ['limit'], + message: + 'limit: 0 cannot be combined with includeRunState; request a bounded page or drop includeRunState', + }) + return + } + if (body.limit !== undefined && body.limit > V2_MAX_RUN_STATE_ROW_LIMIT) { + ctx.addIssue({ + code: 'custom', + path: ['limit'], + message: `limit cannot exceed ${V2_MAX_RUN_STATE_ROW_LIMIT} when includeRunState is set`, + }) + } + }) export type V2QueryRowsBody = z.input /** @@ -982,11 +1188,110 @@ export const v2UpsertTableRowBodySchema = upsertTableRowBodySchema }) .strict() +/** + * Single-row read query. Declared separately from + * {@link v2TableWorkspaceQuerySchema} because that schema is shared with the + * table read/delete and the row delete, none of which return a row body for + * `includeRunState` to shape. + */ +export const v2GetTableRowQuerySchema = v2TableWorkspaceQuerySchema + .extend({ + includeRunState: booleanQueryFlagSchema + .optional() + .default(false) + .describe('Include per-workflow-group run state on the returned row. Off by default.'), + }) + .strict() +export type V2GetTableRowQuery = z.output + +/** + * Heterogeneous bulk row update: one distinct patch per row, in one authorized + * request. + * + * `PATCH /api/v2/tables/{tableId}/rows` is the predicate form — one patch + * applied to every row a filter matches — so it cannot express 500 different + * writes. This is a `POST` on its own path rather than a second body shape on + * that `PATCH`: two request shapes sharing one verb and path have undefined + * precedence when a body satisfies both. + * + * Each patch MERGES into its row, like the single-row `PATCH`: a column absent + * from `data` is left alone, not cleared. + */ +export const v2BulkUpdateRowsBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the table.'), + updates: z + .array( + z + .object({ + rowId: z + .string() + .min(1, 'rowId must be a non-empty row identifier') + .describe('Identifier of the row this patch applies to.'), + data: v2RowDataSchema.describe('Cells to merge into this row, keyed by column name.'), + }) + .strict() + ) + .min(1, 'updates must contain at least one row') + .max( + TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, + `Cannot update more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per batch` + ) + .superRefine((updates, ctx) => { + const seen = new Set() + for (const [index, update] of updates.entries()) { + if (seen.has(update.rowId)) { + ctx.addIssue({ + code: 'custom', + path: [index, 'rowId'], + message: `Duplicate rowId "${update.rowId}"; each row may appear at most once per batch`, + }) + } + seen.add(update.rowId) + } + }) + .describe('One merge patch per row. Each row identifier may appear at most once.'), + }) + .strict() +export type V2BulkUpdateRowsBody = z.input + +/** + * The bulk update is atomic on membership: a `rowId` naming no row in this table + * fails the whole request with a `400` naming the missing ids, rather than + * reporting a per-item miss. A caller sending explicit row identifiers already + * believes they exist, and a partially-applied batch it has to reconcile is + * strictly worse than a refusal it can retry. + */ +export const v2BulkUpdateRowsDataSchema = z + .object({ + updatedCount: z.number().int().nonnegative().describe('Number of rows the batch updated.'), + updatedRowIds: z.array(z.string()).describe('Identifiers of the rows the batch updated.'), + }) + .strict() + .meta({ + id: 'V2BulkUpdateRowsData', + title: 'Bulk update rows data', + description: 'Rows affected by a heterogeneous bulk update.', + }) +export type V2BulkUpdateRowsData = z.output + +export const v2BulkUpdateTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/bulk-update', + query: noInputSchema, + params: tableIdParamsSchema, + body: v2BulkUpdateRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2BulkUpdateRowsDataSchema), + }, +}) + export const v2GetTableRowContract = defineRouteContract({ method: 'GET', path: '/api/v2/tables/[tableId]/rows/[rowId]', params: tableRowParamsSchema, - query: v2TableWorkspaceQuerySchema, + query: v2GetTableRowQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2ApiRowSchema), @@ -1036,6 +1341,31 @@ export const v2UpsertTableRowContract = defineRouteContract({ export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }).strict() export type V2WorkspaceScopedBody = z.input +/** + * Un-archives a table that `DELETE /api/v2/tables/{tableId}` archived, together + * with the rows, views, and groups archived alongside it. + * + * Idempotent: restoring a table that is already active answers `200` with its + * current representation rather than `409`, so a retry after a dropped response + * cannot look like a failure. No audit entry is recorded for that no-op. This + * matches `POST /api/v2/knowledge/{knowledgeBaseId}/restore`, which takes the same position + * for the same reason. + * + * Restore renames on collision rather than failing, so the returned table's + * `name` may differ from the one it was archived under. + */ +export const v2RestoreTableContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/restore', + params: tableIdParamsSchema, + query: noInputSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ApiTableSchema), + }, +}) + const v2TableViewPredicateOutputSchema = z .unknown() .superRefine((value, ctx) => { @@ -1328,7 +1658,7 @@ function refineGroupSource( * - `autoRun` defaults to **false**. On the first-party surface it defaults to * true so a UI add fills cells immediately, but here it would make one POST * fan out a metered run across every existing row. Callers opt in, or fire - * explicitly via `POST /columns/run`. + * explicitly via `POST /tables/{tableId}/dispatches`. */ export const v2AddWorkflowGroupBodySchema = z .object({ @@ -1488,13 +1818,17 @@ export const v2RunColumnDataSchema = z export type V2RunColumnData = z.output /** - * Runs one or more workflow/enrichment groups across the table or a row subset. - * Asynchronous: the response acknowledges the dispatch, and cell values land as - * the runs complete. Poll the rows endpoints for results. + * Creates a run dispatch: runs one or more workflow/enrichment groups across the table or a + * row subset. Asynchronous — the response acknowledges the dispatch, and cell values land as + * the runs complete. Poll the rows endpoints for results, or the dispatch itself at + * `GET /api/v2/tables/{tableId}/dispatches/{dispatchId}`. + * + * It shares its path with the dispatch list, get, and cancel so create/list/get/delete are + * one coherent resource rather than a verb hanging off `/columns`. */ -export const v2RunTableColumnContract = defineRouteContract({ +export const v2CreateTableDispatchContract = defineRouteContract({ method: 'POST', - path: '/api/v2/tables/[tableId]/columns/run', + path: '/api/v2/tables/[tableId]/dispatches', query: noInputSchema, params: tableIdParamsSchema, body: v2RunColumnBodySchema, @@ -1510,7 +1844,7 @@ export const v2RowEnrichmentParamsSchema = tableRowParamsSchema.extend({ export type V2RowEnrichmentParams = z.output /** - * The single-cell case of {@link v2RunTableColumnContract}: runs one group for + * The single-cell case of {@link v2CreateTableDispatchContract}: runs one group for * one row. The scope lives entirely in the path, so the body carries only the * workspace. */ @@ -1526,12 +1860,105 @@ export const v2RunRowEnrichmentContract = defineRouteContract({ }, }) +/** One provider's outcome inside an enrichment cascade. */ +export const v2EnrichmentProviderOutcomeSchema = z + .object({ + id: z.string().describe('Provider identifier, e.g. `hunter`.'), + label: z.string().describe('Human-readable provider name.'), + toolId: z.string().describe('Sim tool identifier the provider ran.'), + status: z + .string() + .describe( + "How this provider ended: `matched`, `no_match`, `skipped`, `error`, or `not_run`. Declared as a string rather than a closed enum because the value is read back out of a schemaless JSONB blob — a member added by a newer runner must widen a client's switch, not fail its read." + ), + cost: z + .number() + .describe('Hosted-key cost in USD this provider incurred; zero when Sim did not bill it.'), + durationMs: z.number().describe('Wall-clock milliseconds this provider took; zero if skipped.'), + error: z.string().nullable().describe('Failure reason when `status` is `error`, else null.'), + }) + .meta({ + id: 'V2EnrichmentProviderOutcome', + title: 'Enrichment provider outcome', + description: "One provider's result within an enrichment cascade.", + }) +export type V2EnrichmentProviderOutcome = z.output + /** - * Lookup body: a case-insensitive substring search across every cell, narrowed + * The provider cascade behind one enrichment cell: which providers ran, in what + * order, what each cost and took, and which one produced the match. + * + * Declared field-by-field rather than reusing the internal contract's opaque + * `domainObjectSchema`: this payload is not opaque, and `z.unknown()` in a + * response slot would need an `untyped-response` annotation it does not + * deserve. + * + * But it IS read back out of a schemaless JSONB column through a bare `as` + * cast, so the declared shape is what a writer intended rather than what the + * column holds. Every field a blob could be missing is therefore nullable, and + * the route projects the stored value onto these keys (`toApiEnrichmentDetail`) + * before presenting it — the same shape `normalizeStoredViewConfig` uses on the + * other stored blob this surface publishes. Without both halves a row written + * by an older runner is a caller-reachable `500` on a well-formed read. + */ +export const v2EnrichmentRunDetailSchema = z + .object({ + startedAt: v2TimestampSchema + .nullable() + .describe('ISO 8601 timestamp when the cascade started, or null when not recorded.'), + completedAt: v2TimestampSchema + .nullable() + .describe('ISO 8601 timestamp when the cascade finished, or null when not recorded.'), + durationMs: z + .number() + .describe('Wall-clock milliseconds across the whole cascade; zero when not recorded.'), + totalCost: z + .number() + .describe('Sum of per-provider hosted-key cost in USD; zero when not recorded.'), + matchedProvider: z + .string() + .nullable() + .describe('Provider that produced the match, or null when none did.'), + aborted: z.boolean().describe('True when the run was canceled before it settled.'), + providers: z + .array(v2EnrichmentProviderOutcomeSchema) + .describe('Every configured provider, in cascade order, including those that never ran.'), + }) + .meta({ + id: 'V2EnrichmentRunDetail', + title: 'Enrichment run detail', + description: 'Provider cascade, cost, and timing for one enrichment cell.', + }) +export type V2EnrichmentRunDetail = z.output + +/** + * The deep read deliberately kept off the paged row surface: `includeRunState` + * on the row reads reports the cell's status, this reports how it got there. + * + * `null` is a real answer — the cell has never run, or it ran before the + * cascade breakdown was recorded — and is distinct from a 404, which means the + * table, row, or group does not exist. + */ +export const v2GetRowEnrichmentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + params: v2RowEnrichmentParamsSchema, + query: v2TableWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2EnrichmentRunDetailSchema.nullable()), + }, +}) + +/** + * Text-search body: a case-insensitive substring search across every cell, narrowed * by the same predicate/sort grammar as `POST /query`. POST because the * predicate tree is a structured body, not a querystring dialect. + * + * `query` on this surface means a structured predicate and `search` means text, which is + * why this is `/rows/search` and the predicate read is `/query`. */ -export const v2FindRowsBodySchema = z +export const v2SearchRowsBodySchema = z .object({ workspaceId: workspaceIdSchema, q: z @@ -1543,7 +1970,7 @@ export const v2FindRowsBodySchema = z sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), }) .strict() -export type V2FindRowsBody = z.input +export type V2SearchRowsBody = z.input /** * One matching cell. `ordinal` is the row's 0-based index in the @@ -1569,7 +1996,7 @@ export type V2RowMatch = z.output * {@link TABLE_LIMITS.MAX_FIND_MATCHES} and more cells match than were returned * — narrow the predicate rather than paging, since matches have no cursor. */ -export const v2FindRowsDataSchema = z +export const v2SearchRowsDataSchema = z .object({ matches: z .array(v2RowMatchSchema) @@ -1582,21 +2009,21 @@ export const v2FindRowsDataSchema = z ), }) .meta({ - id: 'V2FindRowsData', - title: 'Find rows data', + id: 'V2SearchRowsData', + title: 'Search rows data', description: 'Matching table cells and truncation state.', }) -export type V2FindRowsData = z.output +export type V2SearchRowsData = z.output -export const v2FindTableRowsContract = defineRouteContract({ +export const v2SearchTableRowsContract = defineRouteContract({ method: 'POST', - path: '/api/v2/tables/[tableId]/rows/find', + path: '/api/v2/tables/[tableId]/rows/search', query: noInputSchema, params: tableIdParamsSchema, - body: v2FindRowsBodySchema, + body: v2SearchRowsBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2FindRowsDataSchema), + schema: v2DataResponse(v2SearchRowsDataSchema), }, }) @@ -1606,6 +2033,16 @@ export const v2TableImportParamsSchema = z.object({ export const v2TableExportParamsSchema = z.object({ exportId: z.string().min(1).describe('Unique table-export identifier.'), }) + +/** + * The nested export address. v2 reads an export under the table that owns it, so the parent + * is in the path and is authorized before the child is looked at; an `exportId` belonging to + * a different table answers `404`, exactly as an unknown id does. + */ +export const v2NestedTableExportParamsSchema = tableIdParamsSchema.extend( + v2TableExportParamsSchema.shape +) +export type V2NestedTableExportParams = z.output export const v2TableTransferWorkspaceQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace that owns the transfer resource.'), @@ -1951,16 +2388,16 @@ export const v2CreateTableExportContract = defineRouteContract({ export const v2GetTableExportContract = defineRouteContract({ method: 'GET', - path: '/api/v2/tables/exports/[exportId]', - params: v2TableExportParamsSchema, + path: '/api/v2/tables/[tableId]/exports/[exportId]', + params: v2NestedTableExportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, }) export const v2CancelTableExportContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/tables/exports/[exportId]', - params: v2TableExportParamsSchema, + path: '/api/v2/tables/[tableId]/exports/[exportId]', + params: v2NestedTableExportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, }) @@ -1979,8 +2416,8 @@ export const v2TableExportDownloadDataSchema = z export const v2TableExportDownloadContract = defineRouteContract({ method: 'GET', - path: '/api/v2/tables/exports/[exportId]/download', - params: v2TableExportParamsSchema, + path: '/api/v2/tables/[tableId]/exports/[exportId]/download', + params: v2NestedTableExportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2TableExportDownloadDataSchema) }, }) @@ -2011,7 +2448,7 @@ export type V2CancelTableRunsData = z.output /** * Stops in-flight and pending workflow/enrichment cell runs — the counterpart - * to `POST /columns/run`. Import and export work is canceled by deleting its + * to `POST /tables/{tableId}/dispatches`. Import and export work is canceled by deleting its * resource instead. */ export const v2CancelTableRunsContract = defineRouteContract({ @@ -2025,3 +2462,301 @@ export const v2CancelTableRunsContract = defineRouteContract({ schema: v2DataResponse(v2CancelTableRunsDataSchema), }, }) + +/** + * Dispatch lifecycle, published in full. + * + * The first-party `activeDispatchSchema` publishes only the two in-flight + * states because it backs an *active* list and nothing else can appear in one. + * Reusing it for a resource read would make polling a finished dispatch a 500, + * because v2 response schemas are parsed on the way out — so the resource read + * declares the column's whole domain instead. + */ +export const v2TableDispatchStatusSchema = z.enum([ + 'pending', + 'dispatching', + 'complete', + 'canceled', +]) +export type V2TableDispatchStatus = z.output + +/** + * One run dispatch: the unit `POST /tables/{tableId}/dispatches` creates and + * returns a `dispatchId` for. + * + * The stored `cursor` — the highest row position already enqueued — is + * deliberately not published. It is a scheduler internal, and a field named + * `cursor` on a v2 resource would be read as a pagination token. + */ +export const v2TableRunDispatchSchema = z + .object({ + id: z.string().describe('Unique dispatch identifier.'), + tableId: z.string().describe('Table the dispatch runs against.'), + workspaceId: z.string().describe('Workspace that owns the dispatch.'), + status: v2TableDispatchStatusSchema.describe('Current dispatch lifecycle state.'), + mode: z + .enum(['all', 'incomplete', 'new']) + .describe( + 'Which cells the dispatch targets: `all` re-runs settled cells, `incomplete` skips them, `new` covers only cells that have never run.' + ), + scope: z + .object({ + groupIds: z.array(z.string()).describe('Workflow groups the dispatch runs.'), + rowIds: z + .array(z.string()) + .optional() + .describe('Explicit rows the dispatch targets; absent means every eligible row.'), + }) + .strict() + .describe('What the dispatch was asked to run.'), + limit: z + .object({ + type: z.literal('rows').describe('Unit the cap counts.'), + max: z.number().int().positive().describe('Hard ceiling in units of `type`.'), + }) + .strict() + .nullable() + .describe('Cap on how much work the dispatch does, or null when unbounded.'), + processedCount: z + .number() + .int() + .nonnegative() + .describe('Units of `limit.type` consumed so far.'), + isManualRun: z + .boolean() + .describe('True when a caller started the run, false for an automatic re-fire.'), + requestedAt: v2TimestampSchema.describe('ISO 8601 timestamp when the dispatch was created.'), + completedAt: v2TimestampSchema + .nullable() + .describe('ISO 8601 timestamp when the dispatch completed, or null.'), + canceledAt: v2TimestampSchema + .nullable() + .describe('ISO 8601 timestamp when the dispatch was canceled, or null.'), + }) + .meta({ + id: 'V2TableRunDispatch', + title: 'Table run dispatch', + description: 'Lifecycle state of one table workflow-column run dispatch.', + }) +export type V2TableRunDispatch = z.output + +export const v2TableDispatchParamsSchema = tableIdParamsSchema.extend({ + dispatchId: z.string().min(1).describe('Unique table run-dispatch identifier.'), +}) +export type V2TableDispatchParams = z.output + +/** + * Polls one dispatch to completion — the resource `POST /tables/{tableId}/dispatches`'s + * `dispatchId` names. A `null` `dispatchId` there means the run settled inline + * and there is nothing to poll. + */ +export const v2GetTableDispatchContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/dispatches/[dispatchId]', + params: v2TableDispatchParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableRunDispatchSchema) }, +}) + +/** + * Cancels one dispatch by id — the id-addressed counterpart to + * `POST /tables/{tableId}/cancel-runs`, which cancels by predicate scope and cannot name a + * single dispatch. Keep using `cancel-runs` to stop cell runs already in the queue. + */ +export const v2CancelTableDispatchContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/dispatches/[dispatchId]', + params: v2TableDispatchParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableRunDispatchSchema) }, +}) + +/** + * What is currently running on one table. Returns only the in-flight + * dispatches (`pending`, `dispatching`); a settled one is reachable by id. + * + * Unpaged: the dispatcher keeps at most a handful of active dispatches per + * table, so the set is bounded by construction the same way a table's saved + * views and workflow groups are. + */ +export const v2ListTableDispatchesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/dispatches', + params: tableIdParamsSchema, + query: v2TableWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2TableRunDispatchSchema, { paged: false }), + }, +}) + +/** + * Bulk table/folder selection, shared by the move and delete bodies. + * + * v2 addresses folders by canonical PATH everywhere else, so these bodies do + * too. Resolving a path to a folder id is an authorization-sensitive lookup and + * happens inside the application use case, never in the route. + */ +const v2BulkTableIdListSchema = z + .array(z.string().min(1)) + .max(MAX_TABLE_BATCH_ITEMS, `Cannot address more than ${MAX_TABLE_BATCH_ITEMS} ids`) + .default([]) + +const v2BulkTableFolderPathListSchema = z + .array(v2FolderPathInputSchema) + .max(MAX_TABLE_BATCH_ITEMS, `Cannot address more than ${MAX_TABLE_BATCH_ITEMS} folder paths`) + .default([]) + +/** + * Bounds the combined selection. Each list is bounded on its own first so an + * oversized array is rejected before the combined arithmetic; folders cost the + * same budget as tables because they cascade. + */ +function refineV2BoundedTableSelection( + selection: { tableIds: string[]; folderPaths: string[] }, + ctx: z.RefinementCtx +): void { + const total = selection.tableIds.length + selection.folderPaths.length + if (total === 0) { + ctx.addIssue({ + code: 'custom', + path: ['tableIds'], + message: 'At least one table or folder path must be selected', + }) + return + } + if (total > MAX_TABLE_BATCH_ITEMS) { + ctx.addIssue({ + code: 'custom', + path: ['tableIds'], + message: `tableIds and folderPaths cannot contain more than ${MAX_TABLE_BATCH_ITEMS} entries combined`, + }) + } +} + +export const v2MoveTablesBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns every selected item.'), + tableIds: v2BulkTableIdListSchema.describe('Tables to move, by identifier.'), + folderPaths: v2BulkTableFolderPathListSchema.describe( + 'Table folders to re-parent, by canonical path.' + ), + /** Omission moves the selection to the workspace root, as on `POST /files/move`. */ + targetFolderPath: v2FolderPathInputSchema + .optional() + .describe('Destination folder path. Omit to move the selection to the workspace root.'), + }) + .strict() + .superRefine(refineV2BoundedTableSelection) +export type V2MoveTablesBody = z.input + +export const v2BulkDeleteTablesBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns every selected item.'), + tableIds: v2BulkTableIdListSchema.describe('Tables to archive, by identifier.'), + folderPaths: v2BulkTableFolderPathListSchema.describe( + 'Table folders to delete, by canonical path. Each cascades to everything inside it.' + ), + }) + .strict() + .superRefine(refineV2BoundedTableSelection) +export type V2BulkDeleteTablesBody = z.input + +/** + * One item the batch acted on. A folder is named by its canonical path, not its + * id — the request addressed it that way and an id it never sees is an id it + * can never use. + */ +const v2BulkTableItemSchema = z + .object({ + kind: z.enum(['table', 'folder']).describe('Which kind of item this entry names.'), + id: z.string().describe('Table identifier, or the folder path for a folder.'), + name: z.string().describe('Table name, or the folder path for a folder.'), + }) + .strict() + +/** An entry nothing active resolved to. No name, because nothing was found to name. */ +const v2BulkTableMissingSchema = z + .object({ + kind: z.enum(['table', 'folder']).describe('Which kind of item this entry names.'), + id: z.string().describe('Table identifier, or the folder path for a folder.'), + }) + .strict() + +/** + * An item the batch reached but could not act on for a reason the caller can + * act on in turn — a delete lock, a folder cycle. Distinct from `notFound`, + * which also absorbs items the caller may not write to. + */ +const v2BulkTableFailureSchema = v2BulkTableItemSchema + .extend({ reason: z.string().describe('Why this item could not be acted on.') }) + .strict() + +/** Items a selected folder already carries, so the batch left them to it. */ +const v2BulkTableSkippedSchema = z + .array(v2BulkTableItemSchema) + .describe('Items dropped because a selected folder already carries them.') + +export const v2MoveTablesDataSchema = z + .object({ + moved: z.array(v2BulkTableItemSchema).describe('Items the batch moved.'), + skipped: v2BulkTableSkippedSchema, + notFound: z.array(v2BulkTableMissingSchema).describe('Entries nothing active resolved to.'), + failed: z.array(v2BulkTableFailureSchema).describe('Items the batch could not move.'), + }) + .strict() + .meta({ + id: 'V2MoveTablesData', + title: 'Bulk move tables data', + description: 'Per-item outcome of a bulk table and folder move.', + }) +export type V2MoveTablesData = z.output + +export const v2BulkDeleteTablesDataSchema = z + .object({ + deleted: z.array(v2BulkTableItemSchema).describe('Items the batch archived or deleted.'), + skipped: v2BulkTableSkippedSchema, + notFound: z.array(v2BulkTableMissingSchema).describe('Entries nothing active resolved to.'), + failed: z.array(v2BulkTableFailureSchema).describe('Items the batch could not delete.'), + deletedItems: z + .object({ + tables: z.number().int().describe('Tables archived, including folder cascades.'), + folders: z.number().int().describe('Folders deleted, including nested folders.'), + }) + .strict() + .describe('Totals across the explicit archives and every folder cascade they triggered.'), + }) + .strict() + .meta({ + id: 'V2BulkDeleteTablesData', + title: 'Bulk delete tables data', + description: 'Per-item outcome of a bulk table and folder delete.', + }) +export type V2BulkDeleteTablesData = z.output + +/** + * Moves a mixed selection of tables and table folders in one authorized + * request, best-effort per item: an item the batch could not act on is reported + * in `failed` or `notFound` rather than stranding the rest of the selection. + */ +export const v2MoveTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/move', + query: noInputSchema, + body: v2MoveTablesBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2MoveTablesDataSchema) }, +}) + +/** + * Archives a mixed selection of tables and deletes table folders in one + * authorized request. Archived tables are recoverable through + * `POST /tables/{tableId}/restore`; a deleted folder cascades to everything + * inside it. + */ +export const v2BulkDeleteTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/bulk-delete', + query: noInputSchema, + body: v2BulkDeleteTablesBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2BulkDeleteTablesDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts index feb2f6543bf..4cc7ad15961 100644 --- a/apps/sim/lib/api/contracts/v2/uploads.ts +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -38,7 +38,7 @@ export const v2OptionalUploadTokenHeadersSchema = z.object({ * contract. */ const TRANSFER_STEP_CONTRACT = - 'Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ "error": { "code", "message" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand.' + 'Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim\'s own data plane: success is `204` with an empty body, and a failure is the same `{ "error": { "code", "message" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider\'s own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider\'s error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.' export const v2PutUploadTransferSchema = z .object({ @@ -100,7 +100,12 @@ export type V2PartUrlsBody = z.input export const v2UploadPartUrlSchema = z .object({ partNumber: z.number().int().min(1).describe('Multipart part number.'), - url: z.string().url().describe(`Signed URL for this upload part. ${TRANSFER_STEP_CONTRACT}`), + url: z + .string() + .url() + .describe( + `Signed URL for this upload part. ${TRANSFER_STEP_CONTRACT}\n\nYou do not need to retain the \`ETag\` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so \`POST .../complete\` only has to happen after every part has been sent.` + ), headers: z .record(z.string(), z.string()) .describe('Headers that must be included with the part upload.'), diff --git a/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts b/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts new file mode 100644 index 00000000000..31c511ba2da --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts @@ -0,0 +1,459 @@ +import { z } from 'zod' +import { + noInputSchema, + nonEmptyIdSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' + +/** + * v2 workflow-MCP server contracts. + * + * A *workflow* MCP server is one Sim publishes: a workspace groups deployed + * workflows under it, and an outside MCP client calls them as tools. That is the + * opposite direction from `/api/v2/mcp-servers`, which registers external + * servers Sim consumes. The two resources share nothing but a protocol name and + * live on separate paths for exactly that reason — overloading one path would + * make `DELETE /mcp-servers/{mcpServerId}` mean "stop calling out" or "stop serving" + * depending on which table the id happened to be in. + * + * Every operation here denies workspace API keys: publishing a workflow for + * execution by an outside agent is an authority grant that needs an accountable + * human. + */ + +export const V2_WORKFLOW_MCP_SERVER_NAME_MAX = 255 +export const V2_WORKFLOW_MCP_SERVER_DESCRIPTION_MAX = 2000 +export const V2_WORKFLOW_MCP_TOOL_NAME_MAX = 128 +export const V2_WORKFLOW_MCP_TOOL_DESCRIPTION_MAX = 2000 + +/** + * Mirrors `MAX_MCP_PARAMETER_DESCRIPTION_OVERRIDES` in + * `lib/mcp/application/workflow-deployments.ts`, which rejects a longer array as + * a domain validation error. Bounding it at the contract too turns that into a + * `400` naming the field rather than a domain refusal the caller has to read. + */ +export const V2_WORKFLOW_MCP_PARAMETER_DESCRIPTIONS_MAX = 100 + +export const v2WorkflowMcpServerParamsSchema = z + .object({ + serverId: nonEmptyIdSchema.describe('Unique workflow-MCP server identifier.'), + }) + .meta({ + id: 'WorkflowMcpServerParams', + title: 'Workflow MCP server path parameters', + description: 'Workflow-MCP server selected by the request path.', + }) +export type V2WorkflowMcpServerParams = z.output + +export const v2WorkflowMcpToolParamsSchema = v2WorkflowMcpServerParamsSchema + .extend({ + workflowId: workflowIdSchema.describe('Workflow published as a tool on this server.'), + }) + .meta({ + id: 'WorkflowMcpToolParams', + title: 'Workflow MCP tool path parameters', + description: + 'Server and workflow selected by the request path. A workflow appears at most once per server, so the pair identifies the tool.', + }) +export type V2WorkflowMcpToolParams = z.output + +export const v2WorkflowMcpServerSchema = z + .object({ + id: z.string().describe('Unique workflow-MCP server identifier.'), + name: z.string().describe('Server display name, shown to connecting MCP clients.'), + description: z.string().nullable().describe('Optional server description, or null when unset.'), + isPublic: z.boolean().describe('Whether the server answers MCP clients without a Sim API key.'), + mcpServerUrl: z + .string() + .describe('Endpoint an MCP client connects to. Published here so callers never build it.') + .meta({ examples: ['https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2'] }), + createdAt: z + .string() + .describe('ISO 8601 timestamp when the server was created.') + .meta({ format: 'date-time' }), + updatedAt: z + .string() + .describe('ISO 8601 timestamp when the server was last modified.') + .meta({ format: 'date-time' }), + }) + .meta({ + id: 'WorkflowMcpServer', + title: 'Workflow MCP server', + description: 'A workspace-published MCP server exposing deployed workflows as tools.', + }) +export type V2WorkflowMcpServer = z.output + +/** + * A server as the list publishes it: the resource plus the tool inventory it + * exposes. The single-resource writes do not carry the inventory, because a + * create or a rename does not read it and reporting a count the write did not + * observe would be a lie the caller cannot detect. + */ +export const v2WorkflowMcpServerListItemSchema = v2WorkflowMcpServerSchema + .extend({ + toolCount: z.number().int().nonnegative().describe('Number of workflows published as tools.'), + toolNames: z + .array(z.string()) + .describe('Tool names this server publishes, alphabetically ordered.'), + }) + .meta({ + id: 'WorkflowMcpServerListItem', + title: 'Workflow MCP server list item', + description: 'A published MCP server together with the tool names it exposes.', + }) +export type V2WorkflowMcpServerListItem = z.output + +export const v2WorkflowMcpServerSortFields = ['name', 'createdAt', 'updatedAt'] as const +export type V2WorkflowMcpServerSortBy = (typeof v2WorkflowMcpServerSortFields)[number] + +export const v2ListWorkflowMcpServersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace whose published MCP servers to list.'), + ...v2SortFields(v2WorkflowMcpServerSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), + ...v2PaginationFields({ description: 'Maximum workflow-MCP servers to return per page.' }), + }) + .strict() + .meta({ + id: 'ListWorkflowMcpServersQuery', + title: 'List workflow MCP servers query', + description: 'Workspace scope, ordering, and pagination for published MCP servers.', + }) +export type V2ListWorkflowMcpServersQuery = z.output + +export const v2CreateWorkflowMcpServerBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace in which to publish the server.'), + name: z + .string({ error: 'name is required' }) + .trim() + .min(1, 'name cannot be empty') + .max( + V2_WORKFLOW_MCP_SERVER_NAME_MAX, + `name must be at most ${V2_WORKFLOW_MCP_SERVER_NAME_MAX} characters` + ) + .describe('Server display name, shown to connecting MCP clients.'), + description: z + .string() + .trim() + .max( + V2_WORKFLOW_MCP_SERVER_DESCRIPTION_MAX, + `description must be at most ${V2_WORKFLOW_MCP_SERVER_DESCRIPTION_MAX} characters` + ) + .optional() + .describe('Optional server description.'), + isPublic: z + .boolean() + .optional() + .describe( + 'Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL.' + ) + .meta({ default: false }), + workflowIds: z + .array(workflowIdSchema) + .max( + V2_WORKFLOW_MCP_PARAMETER_DESCRIPTIONS_MAX, + `workflowIds must contain at most ${V2_WORKFLOW_MCP_PARAMETER_DESCRIPTIONS_MAX} entries` + ) + .optional() + .describe('Deployed workflows to publish as tools on the new server.'), + }) + .strict() + .meta({ + id: 'CreateWorkflowMcpServerRequest', + title: 'Create workflow MCP server request', + description: 'A new workspace-published MCP server and the workflows it exposes.', + examples: [ + { + workspaceId: '9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94', + name: 'Support agents', + workflowIds: ['3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36'], + }, + ], + }) +export type V2CreateWorkflowMcpServerBody = z.input + +/** + * Merge-patch shaped: an omitted key is unchanged, and `description: null` + * clears the description. At least one key is required, so a body that would + * change nothing is a `400` rather than a `200` that did nothing. + */ +export const v2UpdateWorkflowMcpServerBodySchema = z + .object({ + name: v2CreateWorkflowMcpServerBodySchema.shape.name.optional(), + description: z + .string() + .trim() + .max( + V2_WORKFLOW_MCP_SERVER_DESCRIPTION_MAX, + `description must be at most ${V2_WORKFLOW_MCP_SERVER_DESCRIPTION_MAX} characters` + ) + .nullable() + .optional() + .describe('New server description, or null to clear it.'), + isPublic: z + .boolean() + .optional() + .describe('Whether the server answers MCP clients without a Sim API key.'), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined && body.isPublic === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or isPublic must be provided', + }) + } + }) + .meta({ + id: 'UpdateWorkflowMcpServerRequest', + title: 'Update workflow MCP server request', + description: 'Merge-patch body for a published MCP server.', + examples: [{ isPublic: true }], + }) +export type V2UpdateWorkflowMcpServerBody = z.input + +export const v2DeleteWorkflowMcpServerDataSchema = z + .object({ + id: z.string().describe('Identifier of the unpublished server.'), + deleted: z.literal(true).describe('Whether the server was unpublished.'), + }) + .meta({ + id: 'DeleteWorkflowMcpServerResult', + title: 'Delete workflow MCP server result', + description: 'Unpublish acknowledgement.', + }) +export type V2DeleteWorkflowMcpServerData = z.output + +export const v2WorkflowMcpToolSchema = z + .object({ + id: z.string().describe('Unique tool identifier.'), + serverId: z.string().describe('Server that publishes this tool.'), + workflowId: z.string().describe('Workflow this tool executes.'), + toolName: z + .string() + .describe( + 'Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar.' + ), + toolDescription: z.string().nullable().describe('Description shown to MCP clients.'), + mcpServerUrl: z.string().describe('Endpoint an MCP client connects to.'), + apiEndpoint: z.string().describe('Sim execution endpoint this tool calls through.'), + updated: z + .boolean() + .describe( + 'False when the workflow was newly published on this server, true when an existing tool was replaced. Publishing is idempotent per workflow, so a repeat call answers 200 with true rather than conflicting.' + ), + createdAt: z + .string() + .describe('ISO 8601 timestamp when the tool was created.') + .meta({ format: 'date-time' }), + updatedAt: z + .string() + .describe('ISO 8601 timestamp when the tool was last modified.') + .meta({ format: 'date-time' }), + }) + .meta({ + id: 'WorkflowMcpTool', + title: 'Workflow MCP tool', + description: 'A deployed workflow published as a tool on a workflow-MCP server.', + }) +export type V2WorkflowMcpTool = z.output + +export const v2DeployWorkflowMcpToolBodySchema = z + .object({ + workflowId: workflowIdSchema.describe( + 'Deployed workflow to publish. The workflow must already be deployed.' + ), + toolName: z + .string() + .trim() + .min(1, 'toolName cannot be empty') + .max( + V2_WORKFLOW_MCP_TOOL_NAME_MAX, + `toolName must be at most ${V2_WORKFLOW_MCP_TOOL_NAME_MAX} characters` + ) + .optional() + .describe( + 'Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted.' + ), + toolDescription: z + .string() + .trim() + .max( + V2_WORKFLOW_MCP_TOOL_DESCRIPTION_MAX, + `toolDescription must be at most ${V2_WORKFLOW_MCP_TOOL_DESCRIPTION_MAX} characters` + ) + .optional() + .describe('Description shown to MCP clients. Derived from the workflow name when omitted.'), + parameterDescriptions: z + .array( + z + .object({ + name: z + .string() + .trim() + .min(1, 'parameterDescriptions[].name cannot be empty') + .describe('Input field of the deployed workflow to describe.'), + description: z + .string() + .trim() + .min(1, 'parameterDescriptions[].description cannot be empty') + .max( + V2_WORKFLOW_MCP_TOOL_DESCRIPTION_MAX, + `parameterDescriptions[].description must be at most ${V2_WORKFLOW_MCP_TOOL_DESCRIPTION_MAX} characters` + ) + .describe('Text MCP clients see for that field.'), + }) + .strict() + ) + .max( + V2_WORKFLOW_MCP_PARAMETER_DESCRIPTIONS_MAX, + `parameterDescriptions must contain at most ${V2_WORKFLOW_MCP_PARAMETER_DESCRIPTIONS_MAX} entries` + ) + .optional() + .describe( + 'Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored.' + ), + }) + .strict() + .meta({ + id: 'DeployWorkflowMcpToolRequest', + title: 'Publish workflow as MCP tool request', + description: 'The workflow to publish and the tool metadata MCP clients see.', + examples: [{ workflowId: '3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36', toolName: 'triage_ticket' }], + }) +export type V2DeployWorkflowMcpToolBody = z.input + +export const v2UndeployWorkflowMcpToolDataSchema = z + .object({ + id: z.string().describe('Identifier of the removed tool.'), + serverId: z.string().describe('Server the tool was removed from.'), + workflowId: z.string().describe('Workflow that is no longer published.'), + deleted: z.literal(true).describe('Whether the tool was removed.'), + }) + .meta({ + id: 'UndeployWorkflowMcpToolResult', + title: 'Unpublish workflow MCP tool result', + description: 'Tool removal acknowledgement.', + }) +export type V2UndeployWorkflowMcpToolData = z.output + +export const v2ListWorkflowMcpServersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflow-mcp-servers', + query: v2ListWorkflowMcpServersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowMcpServerListItemSchema), + }, +}) + +export const v2CreateWorkflowMcpServerContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflow-mcp-servers', + query: noInputSchema, + body: v2CreateWorkflowMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowMcpServerSchema), + /** + * A created resource, like every other v2 collection `POST` that mints one. + * Its sibling `POST /api/v2/mcp-servers` already answers `201`; publishing a + * workflow as a tool below deliberately stays `200` because re-posting an + * already-published workflow updates it rather than creating a second one. + */ + status: 201, + }, +}) + +/** + * A published tool as a read returns it. + * + * `updated` is omitted deliberately: it reports whether a *publish* replaced an + * existing tool, which is a fact about that request, not about the tool. + * Publishing it here would force every read to answer a question it cannot. + */ +export const v2WorkflowMcpToolListItemSchema = v2WorkflowMcpToolSchema + .omit({ updated: true }) + .meta({ + id: 'WorkflowMcpToolListItem', + title: 'Workflow MCP tool list item', + description: 'A tool a server publishes, as returned by a read.', + }) +export type V2WorkflowMcpToolListItem = z.output + +export const v2GetWorkflowMcpServerContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflow-mcp-servers/[serverId]', + query: noInputSchema, + params: v2WorkflowMcpServerParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowMcpServerSchema), + }, +}) + +export const v2ListWorkflowMcpToolsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflow-mcp-servers/[serverId]/tools', + query: noInputSchema, + params: v2WorkflowMcpServerParamsSchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowMcpToolListItemSchema, { paged: false }), + }, +}) + +export const v2UpdateWorkflowMcpServerContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflow-mcp-servers/[serverId]', + query: noInputSchema, + params: v2WorkflowMcpServerParamsSchema, + body: v2UpdateWorkflowMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowMcpServerSchema), + }, +}) + +export const v2DeleteWorkflowMcpServerContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflow-mcp-servers/[serverId]', + query: noInputSchema, + params: v2WorkflowMcpServerParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteWorkflowMcpServerDataSchema), + }, +}) + +export const v2DeployWorkflowMcpToolContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflow-mcp-servers/[serverId]/tools', + query: noInputSchema, + params: v2WorkflowMcpServerParamsSchema, + body: v2DeployWorkflowMcpToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowMcpToolSchema), + }, +}) + +export const v2UndeployWorkflowMcpToolContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId]', + query: noInputSchema, + params: v2WorkflowMcpToolParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UndeployWorkflowMcpToolDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 4432437b508..efe5825f53c 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1,11 +1,19 @@ +import { + BLOCK_RETRY_MAX_TRIES, + BLOCK_RETRY_MAX_WAIT_MS, + BLOCK_RETRY_MIN_TRIES, + BLOCK_RETRY_MIN_WAIT_MS, +} from '@sim/workflow-types/workflow' import { z } from 'zod' import { activeDeploymentSummarySchema, deployedWorkflowStateSchema, deploymentOperationSummarySchema, deploymentVersionNumberSchema, + deploymentVersionOrActiveParamsSchema, deploymentVersionParamsSchema, deploymentVersionSchema, + updatePublicApiBodySchema, } from '@/lib/api/contracts/deployments' import { booleanQueryFlagSchema, @@ -47,7 +55,9 @@ import { workflowIdParamsSchema, } from '@/lib/api/contracts/workflows' import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types' +import { WORKFLOW_SKIPPED_ITEM_TYPES } from '@/lib/workflows/editing/types' export const V2_WORKFLOW_RUN_ID_HEADER = 'X-Run-Id' @@ -84,7 +94,7 @@ export type V2ExecuteWorkflowHeaders = z.input /** - * v2 workflows contracts. Request shapes are reused from v1 (the `[id]` param - * is unchanged, and the list query extends v1's with the v2 search/sort + * v2 workflows contracts. Request shapes are reused from v1 (the workflow-id + * param is unchanged, spelled `[workflowId]` here and `[id]` in v1, and the list query extends v1's with the v2 search/sort * convention); only the response envelope is upgraded to the canonical v2 * shapes with concrete item/detail schemas. Deploy, rollback, and undeploy * have named v2 lifecycle result schemas and use `v2DataResponse` (the v1 @@ -129,9 +139,24 @@ export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] * sort convention. The keyset behind the cursor follows `sortBy`, so the cursor * carries the sort it was minted under and is rejected once that changes. */ +/** + * Listing scopes. Two-valued on purpose, diverging from the three-valued + * internal `workflowScopeSchema`: `all` drops the `archived_at` predicate + * entirely, so it can use neither of the workflow table's two partial indexes + * and degrades to a full workspace scan. Mirrors `v2FileScopeSchema`. + */ +export const v2WorkflowScopeSchema = z.enum(['active', 'archived']) + +export type V2WorkflowScope = z.output + export const v2ListWorkflowsQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace whose workflows should be listed.'), + scope: v2WorkflowScopeSchema + .default('active') + .describe( + 'Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' + ), folderPath: v2FolderPathInputSchema .optional() .describe(`Restrict results to workflows in this folder path. ${V2_FOLDER_FILTER_MISS}`), @@ -179,7 +204,7 @@ export const v2WorkflowListItemSchema = z * `executeWorkflowCore`'s post-execution hook, under * `result.success && result.status !== 'paused'` — and nothing ever * decrements it, so the two ways it disagrees with - * `GET /workflows/{id}/runs` point in opposite directions and both are + * `GET /workflows/{workflowId}/runs` point in opposite directions and both are * reachable at once. The description is what makes that legible; the * counter itself is left alone because its stored values already carry the * narrow meaning and no backfill can recover runs whose logs retention has @@ -190,7 +215,7 @@ export const v2WorkflowListItemSchema = z .int() .nonnegative() .describe( - 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction.' + 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction.' ), lastRunAt: z .string() @@ -254,8 +279,9 @@ export const v2WorkflowDetailSchema = v2WorkflowListItemSchema export type V2WorkflowDetail = z.output export const v2WorkflowIdParamsSchema = workflowIdParamsSchema + .omit({ id: true }) .extend({ - id: workflowIdParamsSchema.shape.id + workflowId: workflowIdParamsSchema.shape.id .describe('Unique workflow identifier.') .meta({ examples: ['3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36'] }), }) @@ -266,8 +292,9 @@ export const v2WorkflowIdParamsSchema = workflowIdParamsSchema }) export const v2DeploymentVersionParamsSchema = deploymentVersionParamsSchema + .omit({ id: true }) .extend({ - id: deploymentVersionParamsSchema.shape.id.describe('Unique workflow identifier.'), + workflowId: deploymentVersionParamsSchema.shape.id.describe('Unique workflow identifier.'), version: deploymentVersionParamsSchema.shape.version .describe('Numeric deployment version.') .meta({ examples: [3] }), @@ -278,6 +305,29 @@ export const v2DeploymentVersionParamsSchema = deploymentVersionParamsSchema description: 'Workflow and deployment version selected by the request path.', }) +/** + * Version path parameters that also accept the literal `active`. Only the + * revert operation takes this form: loading "whatever is live" into the draft + * is a meaningful request, whereas activating or relabelling the already-active + * version is not, so the other two keep the numeric-only schema. + */ +export const v2DeploymentVersionOrActiveParamsSchema = deploymentVersionOrActiveParamsSchema + .omit({ id: true }) + .extend({ + workflowId: deploymentVersionOrActiveParamsSchema.shape.id.describe( + 'Unique workflow identifier.' + ), + version: deploymentVersionOrActiveParamsSchema.shape.version + .describe('Numeric deployment version, or `active` for the currently live version.') + .meta({ examples: [3, 'active'] }), + }) + .meta({ + id: 'WorkflowVersionOrActiveParams', + title: 'Workflow version path parameters', + description: + 'Workflow and deployment version selected by the request path, where the version may be the literal `active`.', + }) + export const v2DeploymentStateSchema = z .object({ id: z @@ -312,7 +362,8 @@ export const v2DeploymentStateSchema = z * Read-only deployment state. Extends the shared state with `needsRedeployment`, * which the mutation responses cannot carry: it compares the live graph against * the draft, and immediately after a deploy or rollback the two are equal by - * construction. + * construction — and with `isPublicApi`, which was write-only across the whole + * surface until this read published it. */ export const v2WorkflowDeploymentSchema = v2DeploymentStateSchema .extend({ @@ -321,6 +372,11 @@ export const v2WorkflowDeploymentSchema = v2DeploymentStateSchema .describe( 'Whether the editable draft has diverged from the live deployment version. False while a deployment attempt is still preparing or activating, and false when nothing is deployed.' ), + isPublicApi: z + .boolean() + .describe( + 'Whether the deployed workflow accepts unauthenticated public API execution. While true, anyone holding the execution URL can run the workflow — and be billed for it — without an API key, so this is the field an audit of what a deployment exposes reads. Changed with `PATCH /workflows/{workflowId}/deployment`.' + ), }) .meta({ id: 'WorkflowDeployment', @@ -344,7 +400,7 @@ export const v2DeployWorkflowDataSchema = v2DeploymentStateSchema id: 'DeployResult', title: 'Deploy result', description: - 'Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned only here. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{id}/versions`.', + 'Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned by every deployment mutation as well as this read. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{workflowId}/versions`.', }) export type V2DeployWorkflowData = z.output @@ -368,6 +424,23 @@ export const v2RollbackWorkflowDataSchema = v2DeploymentStateSchema }) export type V2RollbackWorkflowData = z.output +/** + * The same shape under an activation-shaped name. + * + * `POST /versions/{version}/activate` and `POST /rollback` return identical + * data, but publishing the activate response as `RollbackResult` named and + * described a generated client's activate type as a rollback. The component id + * `RollbackResult` stays on rollback, where shipped clients already depend on + * it; activate gets its own rather than renaming theirs. + */ +export const v2ActivateWorkflowVersionDataSchema = v2RollbackWorkflowDataSchema.meta({ + id: 'VersionActivationResult', + title: 'Version activation result', + description: + 'Activation attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state.', +}) +export type V2ActivateWorkflowVersionData = z.output + export const v2ListWorkflowsContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows', @@ -380,7 +453,7 @@ export const v2ListWorkflowsContract = defineRouteContract({ export const v2GetWorkflowContract = defineRouteContract({ method: 'GET', - path: '/api/v2/workflows/[id]', + path: '/api/v2/workflows/[workflowId]', query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { @@ -472,16 +545,60 @@ export type V2UpdateWorkflowBody = z.input export const v2DeleteWorkflowDataSchema = z .object({ - id: z.string().describe('Identifier of the deleted workflow.'), - deleted: z.literal(true).describe('Confirms that the workflow was deleted.'), + id: z.string().describe('Identifier of the archived workflow.'), + /** + * Retained for shipped clients. `DELETE` has always archived rather than + * erased; renaming it would break them, so `archived` states the semantics + * alongside it. + */ + deleted: z.literal(true).describe('Confirms that the workflow is no longer live.'), + archived: z + .literal(true) + .describe( + 'The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{workflowId}/restore` brings all of them back.' + ), }) .meta({ id: 'DeleteWorkflowResult', title: 'Delete workflow result', - description: 'Confirmation that a workflow was deleted.', + description: 'Confirmation that a workflow was archived.', }) export type V2DeleteWorkflowData = z.output +const v2SeededBlockSchema = z + .object({ + id: z.string().describe('Block identifier.'), + type: z.string().describe('Registered block type.'), + name: z.string().describe('Block display name.'), + }) + .strict() + .meta({ + id: 'SeededWorkflowBlock', + title: 'Seeded workflow block', + description: 'A block the platform placed in a newly created workflow.', + }) + +/** + * Create result. Carries the seeded blocks — deliberately a summary rather than + * the whole graph, which would reintroduce the unbounded response + * `GET /workflows/{workflowId}/state` exists to keep off the common path. + */ +export const v2CreateWorkflowDataSchema = v2WorkflowListItemSchema + .extend({ + blocks: z + .array(v2SeededBlockSchema) + .describe( + 'Blocks seeded into the new workflow. Contains the start block; attach edges to its `id`.' + ), + }) + .meta({ + id: 'CreateWorkflowResult', + title: 'Create workflow result', + description: 'The created workflow and the blocks it was seeded with.', + }) + +export type V2CreateWorkflowData = z.output + export const v2CreateWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows', @@ -489,14 +606,14 @@ export const v2CreateWorkflowContract = defineRouteContract({ body: v2CreateWorkflowBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2WorkflowListItemSchema), + schema: v2DataResponse(v2CreateWorkflowDataSchema), status: 201, }, }) export const v2UpdateWorkflowContract = defineRouteContract({ method: 'PATCH', - path: '/api/v2/workflows/[id]', + path: '/api/v2/workflows/[workflowId]', query: noInputSchema, params: v2WorkflowIdParamsSchema, body: v2UpdateWorkflowBodySchema, @@ -508,7 +625,7 @@ export const v2UpdateWorkflowContract = defineRouteContract({ export const v2DeleteWorkflowContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/workflows/[id]', + path: '/api/v2/workflows/[workflowId]', query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { @@ -676,7 +793,7 @@ export type V2WorkflowVersionDetail = z.output + +/** + * Merge-patch shaped: an omitted key is unchanged, and `description: null` + * clears the release note. `name` has no null form because a version label is + * either set or absent and the column already stores absence as null — clearing + * it is expressible, but only by the internal editor, which owns the empty + * state. At least one key is required, because a body carrying neither is a + * caller mistake that would otherwise answer `200` having changed nothing. + */ +export const v2UpdateWorkflowVersionBodySchema = z + .object({ + name: z + .string() + .trim() + .min(1, 'name cannot be empty') + .max(100, 'name must be 100 characters or less') + .optional() + .describe('New label for the deployment version.'), + description: z + .string() + .trim() + .max(50_000, 'description must be 50000 characters or less') + .nullable() + .optional() + .describe('New release note for the deployment version, or null to clear it.'), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name or description must be provided', + }) + } + }) + .meta({ + id: 'UpdateWorkflowVersionRequest', + title: 'Update workflow version request', + description: 'Merge-patch body for the mutable metadata of a deployment version.', + examples: [{ name: 'Escalation routing', description: 'Adds the priority escalation branch.' }], + }) +export type V2UpdateWorkflowVersionBody = z.input + +export const v2UpdateWorkflowVersionContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/[workflowId]/versions/[version]', + query: noInputSchema, + params: v2DeploymentVersionParamsSchema, + body: v2UpdateWorkflowVersionBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowVersionMetadataSchema), + }, +}) + +/** + * Activation names its target in the path, so the body carries nothing. It is + * still declared — and still `.strict()` — so that a caller who sends the + * rollback body by mistake is told, rather than silently activating the version + * the path named. + */ +export const v2ActivateWorkflowVersionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[workflowId]/versions/[version]/activate', + query: noInputSchema, + params: v2DeploymentVersionParamsSchema, + body: noInputSchema + .optional() + .default({}) + .meta({ + id: 'ActivateWorkflowVersionRequest', + title: 'Activate workflow version request', + description: 'No body. The version to promote is named by the request path.', + examples: [{}], + }), + response: { + mode: 'json', + schema: v2DataResponse(v2ActivateWorkflowVersionDataSchema), + }, +}) + +/** + * Revert result. `lastSaved` is the draft's new save timestamp, which is what a + * collaborative editor reconciles against — the caller needs it to know its own + * cached draft is stale. + */ +export const v2RevertWorkflowVersionDataSchema = z + .object({ + id: z.string().describe('Unique workflow identifier.'), + version: z + .union([z.number().int().positive(), z.literal('active')]) + .describe('Deployment version loaded into the draft, or `active` for the live version.'), + lastSaved: z + .number() + .int() + .nonnegative() + .describe('Epoch milliseconds at which the overwritten draft was saved.'), + }) + .meta({ + id: 'RevertWorkflowVersionResult', + title: 'Revert workflow version result', + description: 'The draft after it was overwritten by a deployment version.', + }) +export type V2RevertWorkflowVersionData = z.output + +/** + * `version` accepts the literal `active` in addition to a version number, so a + * caller can discard draft edits and return to what is live without first + * reading which version that is. + */ +export const v2RevertWorkflowVersionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[workflowId]/versions/[version]/revert', + query: noInputSchema, + params: v2DeploymentVersionOrActiveParamsSchema, + body: noInputSchema + .optional() + .default({}) + .meta({ + id: 'RevertWorkflowVersionRequest', + title: 'Revert workflow version request', + description: 'No body. The version to load into the draft is named by the request path.', + examples: [{}], + }), + response: { + mode: 'json', + schema: v2DataResponse(v2RevertWorkflowVersionDataSchema), + }, +}) + +export const v2WorkflowPublicApiSchema = z + .object({ + id: z.string().describe('Unique workflow identifier.'), + isPublicApi: z + .boolean() + .describe('Whether the deployed workflow accepts unauthenticated public API execution.'), + }) + .meta({ + id: 'WorkflowPublicApiSettings', + title: 'Workflow public API settings', + description: 'Whether a deployed workflow is executable without an API key.', + }) +export type V2WorkflowPublicApiSettings = z.output + +export const v2UpdateWorkflowPublicApiBodySchema = updatePublicApiBodySchema + .extend({ + isPublicApi: updatePublicApiBodySchema.shape.isPublicApi.describe( + 'Whether the deployed workflow should accept unauthenticated public API execution.' + ), + }) + .strict() + .meta({ + id: 'UpdateWorkflowPublicApiRequest', + title: 'Update workflow public API request', + description: 'Enable or disable unauthenticated public execution of the deployed workflow.', + examples: [{ isPublicApi: true }], + }) +export type V2UpdateWorkflowPublicApiBody = z.input + +export const v2UpdateWorkflowPublicApiContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/[workflowId]/deployment', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + body: v2UpdateWorkflowPublicApiBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowPublicApiSchema), + }, +}) + /** * Structured execution error — mirrors `WorkflowExecutionErrorCode` in * `@/executor/utils/errors` (duplicated literally: contracts are @@ -934,15 +1245,22 @@ export const v2ExecuteWorkflowBodySchema = z .boolean() .optional() .describe('Inline eligible output files as base64 content. Rejected when `async` is true.'), - /** Caps inline base64 file hydration; bounded (v1 leaves it unbounded). */ + /** + * Caps inline base64 file hydration; bounded (v1 leaves it unbounded). + * Shares the run-read ceiling so the two halves of the same round trip + * cannot disagree about how much a caller may inline. + */ base64MaxBytes: z .number() .int() - .positive() - .max(10 * 1024 * 1024) + .positive('base64MaxBytes must be at least 1') + .max( + MAX_INLINE_MATERIALIZATION_BYTES, + `base64MaxBytes cannot exceed ${MAX_INLINE_MATERIALIZATION_BYTES}` + ) .optional() .describe( - 'Maximum total bytes of file content to inline as base64. Rejected when `async` is true.' + 'Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true.' ), }) .strict() @@ -1021,7 +1339,7 @@ export const v2ExecuteWorkflowSuccessSchema = z export const v2ExecuteWorkflowContract = defineRouteContract({ method: 'POST', - path: '/api/v2/workflows/[id]/execute', + path: '/api/v2/workflows/[workflowId]/execute', query: noInputSchema, params: v2WorkflowIdParamsSchema, headers: v2ExecuteWorkflowHeadersSchema, @@ -1085,7 +1403,7 @@ export type V2ResumeWorkflowResponse = z.output export const v2ListWorkflowRunsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/workflows/[id]/runs', + path: '/api/v2/workflows/[workflowId]/runs', params: v2WorkflowIdParamsSchema, query: v2ListWorkflowRunsQuerySchema, response: { @@ -1225,6 +1543,40 @@ export const v2ListWorkflowRunsContract = defineRouteContract({ }, }) +/** + * One file a run produced. + * + * The storage `key` is deliberately absent: a caller addresses a run file by + * `id` at `downloadPath`, and the key is re-derived server side from the run's + * recording on every request, so a request can never name bytes the run did not + * produce. No expiry is published either — the recording carries none, and a + * fabricated one would be worse than the honest advice that execution objects + * are not retained indefinitely. + */ +export const v2RunFileSchema = z + .object({ + id: z.string().describe('Identifier to address this file by on the download endpoint.'), + name: z.string().describe('File name, including its extension.'), + size: z.number().int().nonnegative().describe('File size in bytes.'), + type: z.string().describe('MIME type recorded for the file.'), + downloadPath: z + .string() + .describe("Path to fetch this file's bytes from, relative to the API host."), + base64: z + .string() + .nullable() + .describe( + 'Base64-encoded contents when `includeFileBase64` was requested and the file fits the inline ceiling, otherwise null.' + ), + }) + .strict() + .meta({ + id: 'V2RunFile', + title: 'Workflow run file', + description: 'A file produced by a workflow run.', + }) +export type V2RunFile = z.output + /** * The polled run resource. `queued` is backfilled from the async job * queue before the worker writes the durable log row — v1's jobs endpoint 404 @@ -1293,6 +1645,12 @@ export const v2WorkflowRunStatusSchema = z .describe( 'Outputs of the blocks named by `selectedOutputs`, or null when none were requested. Gated by `selectedOutputs` alone — `includeOutput` governs `output` only.' ), + files: z + .array(v2RunFileSchema) + .nullable() + .describe( + 'Files this run produced, or null when `includeOutput` is false. Matches the nullability of `output`.' + ), }) .meta({ id: 'WorkflowRunStatus', @@ -1301,9 +1659,43 @@ export const v2WorkflowRunStatusSchema = z }) export type V2WorkflowRunStatus = z.output +export const v2DownloadRunFileParamsSchema = z + .object({ + workflowId: z.string().min(1, 'Invalid workflow ID').describe('Unique workflow identifier.'), + runId: v2WorkflowRunIdSchema.describe('Unique workflow run identifier.'), + fileId: z + .string() + .min(1, 'fileId cannot be empty') + .max(256, 'fileId is too long') + .describe('Identifier of a file the run produced, as reported by the run resource.'), + }) + .meta({ + id: 'DownloadRunFileParams', + title: 'Run file path parameters', + description: 'Workflow, run, and run-produced file selected by the request path.', + }) +export type V2DownloadRunFileParams = z.input + +/** + * Downloads one file a run produced. + * + * The file is addressed by the id the run itself reported; a storage key is + * never accepted from the request, so this endpoint cannot be pointed at bytes + * the run did not produce. + */ +export const v2DownloadRunFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]', + params: v2DownloadRunFileParamsSchema, + query: noInputSchema, + response: { + mode: 'binary', + }, +}) + export const v2GetWorkflowRunContract = defineRouteContract({ method: 'GET', - path: '/api/v2/workflows/[id]/runs/[runId]', + path: '/api/v2/workflows/[workflowId]/runs/[runId]', params: v2WorkflowRunParamsSchema, query: workflowExecutionStatusQuerySchema .extend({ @@ -1329,6 +1721,30 @@ export const v2GetWorkflowRunContract = defineRouteContract({ selectedOutputs: workflowExecutionStatusQuerySchema.shape.selectedOutputs.describe( 'Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.' ), + /** + * Allowed here but not on the async execute request, whose rejection is + * correct: at submit time the run has not happened, so there is nothing to + * inline. Reading a finished run is the first moment the question means + * anything. + */ + includeFileBase64: booleanQueryFlagSchema + .describe( + "Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead." + ) + .optional() + .default(false), + base64MaxBytes: z.coerce + .number() + .int() + .min(1, 'base64MaxBytes must be at least 1') + .max( + MAX_INLINE_MATERIALIZATION_BYTES, + `base64MaxBytes cannot exceed ${MAX_INLINE_MATERIALIZATION_BYTES}` + ) + .optional() + .describe( + 'Per-file inline ceiling, lowering but never raising the server limit of 16 MiB.' + ), }) .strict() .meta({ @@ -1377,7 +1793,7 @@ export type V2CancelWorkflowRunData = z.output Object.keys(blocks).length <= MAX_WORKFLOW_GRAPH_BLOCKS, + `blocks cannot exceed ${MAX_WORKFLOW_GRAPH_BLOCKS} entries` + ), + edges: z + .array(v2WorkflowEdgeSchema) + .max(MAX_WORKFLOW_GRAPH_EDGES, `edges cannot exceed ${MAX_WORKFLOW_GRAPH_EDGES} entries`) + .describe('Directed connections between blocks.'), + loops: z + .record(z.string(), v2WorkflowLoopSchema) + .describe('Loop containers keyed by container id; always present, `{}` when there are none.'), + parallels: z + .record(z.string(), v2WorkflowParallelSchema) + .describe( + 'Parallel containers keyed by container id; always present, `{}` when there are none.' + ), + variables: z + .record(z.string(), v2WorkflowVariableSchema) + .describe( + 'Workflow variables keyed by variable id; always present, `{}` when there are none.' + ), + }) + .strict() + .meta({ + id: 'WorkflowGraph', + title: 'Workflow graph', + description: + 'The editable draft graph of a workflow: blocks, edges, derived loop and parallel containers, and variables.', + }) + +export type V2WorkflowGraph = z.output + +/** + * Write-side graph elements. + * + * Structurally identical to the read schemas and deliberately so — what a caller + * reads back is exactly what it may send. They are built separately only to + * carry distinct OpenAPI component ids: a request body is generated in `input` + * mode and a response in `output` mode, and the two spellings of the same object + * genuinely differ, because an output object cannot carry unknown members having + * just had them stripped. + */ +const v2WorkflowBlockInputSchema = workflowBlockSchema('WorkflowBlockInput') +const v2WorkflowEdgeInputSchema = workflowEdgeSchema('WorkflowEdgeInput') +const v2WorkflowLoopInputSchema = workflowLoopSchema('WorkflowLoopInput') +const v2WorkflowParallelInputSchema = workflowParallelSchema('WorkflowParallelInput') +const v2WorkflowVariableInputSchema = workflowVariableSchema('WorkflowVariableInput') + +/** + * Replace body. `loops` and `parallels` are accepted but ignored — both are + * derived from the blocks on write, so declaring them optional keeps a + * read-modify-write round trip working without promising they are honoured. + */ +export const v2ReplaceWorkflowStateBodySchema = z + .object({ + blocks: z + .record(z.string(), v2WorkflowBlockInputSchema) + .describe('Blocks keyed by block id.') + .refine( + (blocks) => Object.keys(blocks).length <= MAX_WORKFLOW_GRAPH_BLOCKS, + `blocks cannot exceed ${MAX_WORKFLOW_GRAPH_BLOCKS} entries` + ), + edges: z + .array(v2WorkflowEdgeInputSchema) + .max(MAX_WORKFLOW_GRAPH_EDGES, `edges cannot exceed ${MAX_WORKFLOW_GRAPH_EDGES} entries`) + .describe('Directed connections between blocks.'), + loops: z + .record(z.string(), v2WorkflowLoopInputSchema) + .optional() + .describe('Ignored on write: loop containers are recomputed from `blocks`.'), + parallels: z + .record(z.string(), v2WorkflowParallelInputSchema) + .optional() + .describe('Ignored on write: parallel containers are recomputed from `blocks`.'), + variables: z + .record(z.string(), v2WorkflowVariableInputSchema) + .optional() + .describe('Replacement variable set. Omit to leave the stored variables untouched.'), + }) + .strict() + .meta({ + id: 'ReplaceWorkflowStateRequest', + title: 'Replace workflow state request', + description: 'A complete replacement draft graph for a workflow.', + examples: [{ blocks: {}, edges: [] }], + }) + +export type V2ReplaceWorkflowStateBody = z.input + +const v2WorkflowGraphWriteResultSchema = z + .object({ + id: z.string().describe('Identifier of the workflow whose draft graph was written.'), + warnings: z + .array(z.string()) + .describe( + 'Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report.' + ), + needsRedeployment: z + .boolean() + .describe( + 'Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it.' + ), + }) + .meta({ + id: 'WorkflowGraphWriteResult', + title: 'Workflow graph write result', + description: 'Outcome of a write against a workflow draft graph.', + }) + +const v2WorkflowLintBlockRefSchema = z.object({ + blockId: z.string().describe('Block the finding is about.'), + blockName: z.string().nullable().describe('Display name of the block, when it has one.'), + blockType: z.string().nullable().describe('Registered type of the block, when it has one.'), +}) + +const v2WorkflowLintSchema = z + .object({ + sources: z + .array(v2WorkflowLintBlockRefSchema) + .describe( + 'Blocks with no incoming edge. A trigger block is naturally a source; anything else here is unreachable.' + ), + sinks: z.array(v2WorkflowLintBlockRefSchema).describe('Blocks with no outgoing edge.'), + orphanBlocks: z + .array(v2WorkflowLintBlockRefSchema) + .describe('Blocks with neither an incoming nor an outgoing edge.'), + emptyOutgoingPorts: z + .array( + v2WorkflowLintBlockRefSchema.extend({ + handle: z.string().describe('Source handle with nothing connected to it.'), + label: z.string().describe('Human-readable name of the port.'), + }) + ) + .describe('Branch and container ports that lead nowhere.'), + invalidBranchPorts: z + .array( + v2WorkflowLintBlockRefSchema.extend({ + sourceHandle: z.string().describe('Source handle that does not match the block.'), + reason: z.string().describe('Why the handle is not valid for this block.'), + }) + ) + .describe('Condition and router edges whose source handle names no real branch.'), + invalidConnectionTargets: z + .array( + z.object({ + sourceBlockId: z.string().describe('Block the edge leaves.'), + sourceBlockName: z.string().nullable().describe('Display name of the source block.'), + sourceHandle: z.string().nullable().describe('Handle the edge leaves from.'), + targetBlockId: z.string().describe('Block the edge points at.'), + reason: z.string().describe('Why the target is not a legal destination.'), + }) + ) + .describe('Edges pointing at a block that cannot legally receive them.'), + fieldIssues: z + .array( + v2WorkflowLintBlockRefSchema.extend({ + missingRequiredFields: z + .array(z.string()) + .describe('Required sub-block fields that resolve empty in the active mode.'), + inactiveModeValues: z + .array( + z.object({ + canonicalId: z + .string() + .describe('Canonical parameter the two sub-block modes share.'), + activeMemberId: z + .string() + .nullable() + .describe('Sub-block the runtime reads, where the value should live.'), + inactiveMemberId: z + .string() + .describe('Sub-block holding the stranded value, which the runtime ignores.'), + kind: z + .enum(['credential', 'resource', 'other']) + .describe('What kind of value is stranded.'), + }) + ) + .describe('Values stranded on the inactive member of a canonical pair.'), + }) + ) + .describe( + 'Per-block configuration problems. The most actionable part of the report for a headless graph builder: a block missing a required field will fail at run time.' + ), + unresolvedReferences: z + .array( + v2WorkflowLintBlockRefSchema.extend({ + field: z.string().describe('Sub-block field holding the reference.'), + value: z + .union([z.string(), z.array(z.string())]) + .describe('The reference, or references, that did not resolve.'), + kind: z + .enum(['credential', 'resource', 'custom-tool', 'mcp-tool', 'skill']) + .describe('What kind of entity the reference was expected to name.'), + reason: z.string().describe('Why the reference does not resolve.'), + }) + ) + .describe( + 'Credential, resource, tool, and skill references that do not resolve. These values are still persisted; they are reported, not dropped.' + ), + notes: z.array(z.string()).describe('Advisory notes about the report itself.'), + }) + .meta({ + id: 'WorkflowLintReport', + title: 'Workflow lint report', + description: + 'Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time.', + }) + +/** + * Ask a graph write to validate and lint without persisting. + * + * A query parameter rather than a body field because the body of `PUT /state` + * IS the graph — a dry-run flag inside it would make "am I committing this" + * part of the resource representation, and a caller round-tripping a `GET` + * into a `PUT` would carry it along. Kubernetes (`?dryRun=`) and Google Cloud + * (`validateOnly`) both keep it outside the represented resource for the same + * reason. + */ +const v2GraphWriteDryRunQuerySchema = z + .object({ + dryRun: booleanQueryFlagSchema + .optional() + .describe( + 'Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified.' + ), + }) + .strict() + +export const v2ReplaceWorkflowStateDataSchema = v2WorkflowGraphWriteResultSchema + .extend({ + lint: v2WorkflowLintSchema, + dryRun: z + .boolean() + .describe( + 'Whether this request only validated. `true` means nothing was persisted; the findings describe what a committed write of the same body would produce.' + ), + }) + .meta({ + id: 'ReplaceWorkflowStateResult', + title: 'Replace workflow state result', + description: 'Outcome of replacing a workflow draft graph, with its advisory findings.', + }) +export type V2ReplaceWorkflowStateData = z.output + +export const v2GetWorkflowStateContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[workflowId]/state', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowGraphSchema), + }, +}) + +export const v2ReplaceWorkflowStateContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/workflows/[workflowId]/state', + query: v2GraphWriteDryRunQuerySchema, + params: v2WorkflowIdParamsSchema, + body: v2ReplaceWorkflowStateBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ReplaceWorkflowStateDataSchema), + }, +}) + +/** + * Every reason the edit engine can decline one operation. Derived from the + * engine's own union, so a new skip reason fails to compile until it is + * published here. + */ +export const v2WorkflowSkippedItemTypeSchema = z + .enum(WORKFLOW_SKIPPED_ITEM_TYPES) + .describe('Machine-readable reason the engine declined an operation.') + +const v2WorkflowSkippedItemSchema = z + .object({ + type: v2WorkflowSkippedItemTypeSchema, + operationType: z.string().describe('The `operation_type` that was declined.'), + blockId: z.string().describe('Block the declined operation targeted.'), + reason: z.string().describe('Human-readable explanation.'), + /** Engine-supplied context; its keys vary by `type`. */ + details: z + .record( + z.string(), + z.unknown().describe('One piece of engine-supplied context for the reason.') + ) + .optional() + .describe('Additional context for the reason; keys depend on `type`.'), + }) + .meta({ + id: 'WorkflowSkippedItem', + title: 'Workflow skipped item', + description: 'One operation the edit engine did not apply.', + }) + +export type V2WorkflowSkippedItem = z.output + +/** + * The envelope every block-configuring `params` shares, published because a + * caller holding only the JSON type `object` cannot discover it. The wording is + * the guidance the Copilot tool catalog has carried for `edit_workflow` since + * before this endpoint existed — the two are the same engine, so they should + * not describe it differently. + */ +const WORKFLOW_OPERATION_PARAM_ENVELOPE = + "`inputs` carries the block's own configuration keyed by sub-block id, for example " + + '`inputs: { model: "gpt-4o", systemPrompt: "..." }` — never wrapped in `subBlocks`. ' + + 'Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, ' + + '`advancedMode`. `connections` is keyed by source handle and each value is a target ' + + 'block id, `{ block, handle }`, or an array of either; `success` is accepted as an ' + + 'alias for the `source` handle.' + +/** + * The keys `edit` accepts. Open because the per-block input set is defined by + * the block registry rather than by this contract, but the envelope around it + * is fixed and worth publishing — a caller that has only the type `object` can + * rename a block and nothing else. + */ +const v2WorkflowOperationParamsSchema = z + .record( + z.string(), + z.unknown().describe('One operation parameter; see the description for the accepted keys.') + ) + .describe( + 'Fields to change on the target block. Send only what changes. Accepted keys: `inputs`, ' + + '`name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, ' + + `\`advancedMode\`. ${WORKFLOW_OPERATION_PARAM_ENVELOPE} Re-sending \`connections\` ` + + "replaces that block's outgoing edges, so use `removeEdges` — " + + '`[{ targetBlockId, sourceHandle? }]`, `sourceHandle` defaulting to `source` — to drop ' + + 'one edge without restating the rest.' + ) + +const v2AddWorkflowBlockParamsSchema = z + .object({ + type: z + .string() + .min(1, 'params.type is required to add a block') + .describe('Registered block type.'), + name: z + .string() + .min(1, 'params.name is required to add a block') + .describe('Block display name.'), + }) + .catchall(z.unknown().describe('One block-specific input or connection descriptor.')) + .describe( + 'Block type and name, plus any block-specific configuration. Beyond `type` and `name` the ' + + 'accepted keys are `inputs`, `connections`, `retry`, `triggerMode`, and `advancedMode`. ' + + WORKFLOW_OPERATION_PARAM_ENVELOPE + ) + +const v2SubflowMembershipParamsSchema = z + .object({ + subflowId: z + .string() + .min(1, 'params.subflowId is required') + .describe('Loop or parallel container the block moves into or out of.'), + }) + .catchall(z.unknown().describe('One block-specific input.')) + .describe('Container identifier, plus any block-specific inputs.') + +const v2InsertIntoSubflowParamsSchema = z + .object({ + subflowId: z + .string() + .min(1, 'params.subflowId is required') + .describe('Loop or parallel container to insert the block into.'), + type: z + .string() + .min(1, 'params.type is required to insert a block') + .describe('Registered block type.'), + name: z + .string() + .min(1, 'params.name is required to insert a block') + .describe('Block display name.'), + }) + .catchall(z.unknown().describe('One block-specific input or connection descriptor.')) + .describe( + 'Container, block type and name, plus any block-specific configuration. Takes the same ' + + 'keys as an `add`: `inputs`, `connections`, `retry`, `triggerMode`, `advancedMode`. ' + + WORKFLOW_OPERATION_PARAM_ENVELOPE + ) + +const v2WorkflowOperationBlockIdSchema = z + .string() + .min(1, 'block_id cannot be empty') + .describe('Block the operation targets. For `add`, the id the new block will be given.') + +/** + * One semantic edit. A discriminated union on `operation_type` so a client gets + * exhaustive narrowing and each variant declares the parameters it actually + * requires — `add` and `insert_into_subflow` cannot omit the block type and + * name, and `delete` accepts no parameters at all. + */ +export const v2WorkflowOperationSchema = z + .discriminatedUnion('operation_type', [ + z + .object({ + operation_type: z.literal('add').describe('Create a new block.'), + block_id: v2WorkflowOperationBlockIdSchema, + params: v2AddWorkflowBlockParamsSchema, + }) + .strict(), + z + .object({ + operation_type: z + .literal('edit') + .describe('Change an existing block: its inputs, name, or connections.'), + block_id: v2WorkflowOperationBlockIdSchema, + params: v2WorkflowOperationParamsSchema, + }) + .strict(), + z + .object({ + operation_type: z.literal('delete').describe('Remove a block and every edge touching it.'), + block_id: v2WorkflowOperationBlockIdSchema, + }) + .strict(), + z + .object({ + operation_type: z + .literal('insert_into_subflow') + .describe('Create a block inside a loop or parallel container.'), + block_id: v2WorkflowOperationBlockIdSchema, + params: v2InsertIntoSubflowParamsSchema, + }) + .strict(), + z + .object({ + operation_type: z + .literal('extract_from_subflow') + .describe('Move a block out of its loop or parallel container.'), + block_id: v2WorkflowOperationBlockIdSchema, + params: v2SubflowMembershipParamsSchema, + }) + .strict(), + ]) + .meta({ + id: 'WorkflowEditOperation', + title: 'Workflow edit operation', + description: 'One semantic edit against a workflow graph.', + }) + +export type V2WorkflowOperation = z.input + +export const v2ApplyWorkflowOperationsBodySchema = z + .object({ + operations: z + .array(v2WorkflowOperationSchema) + .min(1, 'operations cannot be empty') + .max( + MAX_WORKFLOW_EDIT_OPERATIONS, + `operations cannot exceed ${MAX_WORKFLOW_EDIT_OPERATIONS} entries` + ) + .describe('Edits to apply, in a single batch.'), + atomic: z + .boolean() + .optional() + .default(false) + .describe( + 'Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead.' + ), + layout: z + .enum(['targeted', 'none']) + .optional() + .default('targeted') + .describe( + 'Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied.' + ), + setBlockEnabled: z + .array( + z + .object({ + block_id: v2WorkflowOperationBlockIdSchema, + enabled: z.boolean().describe('Whether the block should run.'), + }) + .strict() + ) + .max( + MAX_WORKFLOW_EDIT_OPERATIONS, + `setBlockEnabled cannot exceed ${MAX_WORKFLOW_EDIT_OPERATIONS} entries` + ) + .optional() + .describe( + 'Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined.' + ), + }) + .strict() + .meta({ + id: 'ApplyWorkflowOperationsRequest', + title: 'Apply workflow operations request', + description: 'A batch of semantic edits against a workflow graph.', + examples: [ + { + operations: [ + { operation_type: 'add', block_id: 'agent-1', params: { type: 'agent', name: 'Triage' } }, + ], + }, + ], + }) + +export type V2ApplyWorkflowOperationsBody = z.input + +const v2WorkflowInputValidationErrorSchema = z + .object({ + blockId: z.string().describe('Block whose input was rejected.'), + blockType: z.string().describe('Type of the block whose input was rejected.'), + field: z.string().describe('Sub-block field that was rejected.'), + error: z.string().describe('Why the value was rejected.'), + }) + .meta({ + id: 'WorkflowInputValidationError', + title: 'Workflow input validation error', + description: 'One block input that was dropped rather than persisted.', + }) + +export const v2ApplyWorkflowOperationsDataSchema = v2WorkflowGraphWriteResultSchema + .extend({ + applied: z.number().int().nonnegative().describe('Operations the engine applied.'), + skipped: z + .array(v2WorkflowSkippedItemSchema) + .describe('Operations the engine declined. Empty when everything applied.'), + deferred: z + .array(v2WorkflowSkippedItemSchema) + .describe( + 'Forward-referencing edges the engine recorded rather than applied. These are NOT failures: the engine wires each one as soon as its target block exists, in this batch or a later one. Do not re-issue them.' + ), + inputValidationErrors: z + .array(v2WorkflowInputValidationErrorSchema) + .describe( + 'Block inputs that were dropped rather than persisted, and only those. The rest of the operation still applied. References that merely fail to resolve stay persisted and are reported in `lint.unresolvedReferences` instead.' + ), + mintedBlockIds: z + .record(z.string(), z.string().describe('The id the block was actually given.')) + .describe( + 'The id each newly created block was actually given, keyed by the `block_id` you asked for, and present only for the ones that differ. A `block_id` on an `add` or `insert_into_subflow` that is not already a UUID is replaced with a minted one, so this is how you learn what to reference afterwards. Within a single batch you can keep using your own ids — references between operations are remapped for you — but a later request must use the minted id, so send your own UUIDs when you want an id you chose to survive.' + ), + lint: v2WorkflowLintSchema, + dryRun: z + .boolean() + .describe( + 'Whether this request only evaluated. `true` means nothing was persisted; the outcome describes what a committed apply of the same body would produce.' + ), + }) + .meta({ + id: 'ApplyWorkflowOperationsResult', + title: 'Apply workflow operations result', + description: 'Outcome of a batch of semantic edits against a workflow graph.', + }) + +export type V2ApplyWorkflowOperationsData = z.output + +export const v2ApplyWorkflowOperationsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[workflowId]/operations', + query: v2GraphWriteDryRunQuerySchema, + params: v2WorkflowIdParamsSchema, + body: v2ApplyWorkflowOperationsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ApplyWorkflowOperationsDataSchema), + }, +}) + +export const v2ApplyWorkflowVariablesBodySchema = z + .object({ + operations: z + .array( + z + .discriminatedUnion('operation', [ + z + .object({ + operation: z.literal('add').describe('Create a variable with this name.'), + name: z + .string() + .min(1, 'name cannot be empty') + .max(255, 'name is too long') + .describe('Variable name.'), + type: v2WorkflowVariableInputSchema.shape.type.describe('Declared variable type.'), + value: z.unknown().describe('Variable value, coerced to `type`.'), + }) + .strict(), + z + .object({ + operation: z + .literal('edit') + .describe('Replace the value, and optionally the type, of an existing variable.'), + name: z + .string() + .min(1, 'name cannot be empty') + .max(255, 'name is too long') + .describe('Name of the variable to update.'), + type: v2WorkflowVariableInputSchema.shape.type + .optional() + .describe('Replacement type; the stored type is kept when omitted.'), + value: z.unknown().describe('Replacement value, coerced to the effective type.'), + }) + .strict(), + z + .object({ + operation: z.literal('delete').describe('Remove the variable with this name.'), + name: z + .string() + .min(1, 'name cannot be empty') + .max(255, 'name is too long') + .describe('Name of the variable to remove.'), + }) + .strict(), + ]) + .describe('One variable change.') + ) + .min(1, 'operations cannot be empty') + .max( + MAX_WORKFLOW_VARIABLE_OPERATIONS, + `operations cannot exceed ${MAX_WORKFLOW_VARIABLE_OPERATIONS} entries` + ) + .describe('Variable changes to apply, in order.'), + }) + .strict() + .meta({ + id: 'ApplyWorkflowVariablesRequest', + title: 'Apply workflow variables request', + description: 'Additions, edits, and deletions against a workflow’s variables.', + }) + +export type V2ApplyWorkflowVariablesBody = z.input + +export const v2ApplyWorkflowVariablesDataSchema = z + .object({ + id: z.string().describe('Identifier of the workflow whose variables were updated.'), + variableCount: z.number().int().nonnegative().describe('Variables the workflow now holds.'), + changed: z + .boolean() + .describe('Whether anything actually changed. A no-op batch answers `200` with `false`.'), + }) + .meta({ + id: 'ApplyWorkflowVariablesResult', + title: 'Apply workflow variables result', + description: 'Outcome of a workflow variable update.', + }) + +export type V2ApplyWorkflowVariablesData = z.output + +export const v2ApplyWorkflowVariablesContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/[workflowId]/variables', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + body: v2ApplyWorkflowVariablesBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ApplyWorkflowVariablesDataSchema), + }, +}) + +export const v2DuplicateWorkflowBodySchema = z + .object({ + name: z + .string() + .trim() + .min(1, 'name cannot be empty') + .max(255, 'name is too long') + .optional() + .describe('Name for the copy. Defaults to the source name, deduplicated within the folder.'), + folderPath: v2FolderPathInputSchema + .optional() + .describe("Destination folder path. Defaults to the source workflow's folder."), + }) + .strict() + .meta({ + id: 'DuplicateWorkflowRequest', + title: 'Duplicate workflow request', + description: 'Optional name and destination folder for the copy.', + examples: [{ name: 'Customer support triage (copy)', folderPath: '/Operations' }], + }) + +export type V2DuplicateWorkflowBody = z.input + +export const v2DuplicateWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[workflowId]/duplicate', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + body: v2DuplicateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + status: 201, + }, +}) + +export const v2RestoreWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[workflowId]/restore', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2MoveWorkflowsBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace holding every workflow in the batch.'), + workflowIds: z + .array(z.string().min(1, 'workflowIds entries cannot be empty')) + .min(1, 'workflowIds cannot be empty') + .max(MAX_WORKFLOW_BULK_MOVES, `workflowIds cannot exceed ${MAX_WORKFLOW_BULK_MOVES} entries`) + .describe('Workflows to move. Duplicates are collapsed.'), + folderPath: v2FolderPathInputSchema.describe( + 'Destination folder path; `/` moves the workflows to the workspace root.' + ), + }) + .strict() + .meta({ + id: 'MoveWorkflowsRequest', + title: 'Move workflows request', + description: 'Workflows to relocate and the folder to relocate them into.', + examples: [ + { + workspaceId: 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64', + workflowIds: ['3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36'], + folderPath: '/Operations', + }, + ], + }) + +export type V2MoveWorkflowsBody = z.input + +export const v2MoveWorkflowsDataSchema = z + .object({ + moved: z.array(z.string()).describe('Workflows that were relocated.'), + failed: z + .array(z.string()) + .describe( + 'Workflows that were not relocated — absent from the workspace, archived, or locked. Best-effort by design: the rest of the batch still moved.' + ), + folderPath: v2FolderPathSchema.describe('Canonical destination folder path.'), + }) + .meta({ + id: 'MoveWorkflowsResult', + title: 'Move workflows result', + description: 'Which workflows moved and which did not.', + }) + +export type V2MoveWorkflowsData = z.output + +export const v2MoveWorkflowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/move', + query: noInputSchema, + body: v2MoveWorkflowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2MoveWorkflowsDataSchema), + }, +}) diff --git a/apps/sim/lib/api/list-query.test.ts b/apps/sim/lib/api/list-query.test.ts index 51dedd00025..0b0ca661031 100644 --- a/apps/sim/lib/api/list-query.test.ts +++ b/apps/sim/lib/api/list-query.test.ts @@ -10,8 +10,9 @@ import { describe, expect, it, vi } from 'vitest' vi.unmock('drizzle-orm') -import { integer, PgDialect, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { decimal, integer, PgDialect, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { + decimalKey, encodeKeyset, escapeLikePattern, keysetAfter, @@ -27,6 +28,7 @@ const thing = pgTable('thing', { id: text('id').primaryKey(), name: text('name').notNull(), size: integer('size').notNull(), + cost: decimal('cost'), createdAt: timestamp('created_at').notNull(), }) @@ -129,6 +131,35 @@ describe('timestampKey', () => { }) }) +describe('decimalKey', () => { + const costKey = decimalKey<{ cost: string }>(thing.cost, (r) => r.cost) + + /** + * The regression this exists for: `numeric` is unconstrained, so a cursor + * value that round-trips through a JS number is narrowed to float64 and then + * compared back against full precision. Rows differing beyond that precision + * collapse onto one anchor and the page boundary skips or repeats them. + */ + it('carries the value as the digit string Postgres returned', () => { + const exact = '0.12345678901234567890123' + + expect(costKey.encode({ cost: exact })).toBe(exact) + expect(render(costKey.bind(exact)!).params).toEqual([exact]) + }) + + it('casts the bound value so it compares as numeric rather than as text', () => { + expect(render(costKey.bind('1.5')!).sql).toBe('cast($1 as numeric)') + }) + + it('rejects a value numeric would accept but a keyset cannot order totally', () => { + expect(costKey.bind('NaN')).toBeNull() + expect(costKey.bind('Infinity')).toBeNull() + expect(costKey.bind('1e10')).toBeNull() + expect(costKey.bind(1.5)).toBeNull() + expect(costKey.bind('-1')).not.toBeNull() + }) +}) + describe('cursor key value validation', () => { it('rejects a non-string for a text key', () => { expect(nameKey.bind(42)).toBeNull() diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index e45560a78fc..cc06165d61b 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -94,6 +94,40 @@ export function numberKey(column: SQLWrapper, read: (row: Row) => number): } } +/** + * The spellings of a Postgres `numeric` literal a cursor may carry back. + * + * Deliberately narrower than what `numeric` accepts: `NaN`, `Infinity`, and + * exponent forms all parse as `numeric` but compare in ways a keyset cannot + * order totally, and a cursor value is caller-controlled. + */ +const DECIMAL_CURSOR_PATTERN = /^-?\d+(\.\d+)?$/ + +/** + * An arbitrary-precision key — a `numeric`/`decimal` column, carried through the + * cursor as the digit string Postgres returned. + * + * Never through a JS number. `numeric` is unconstrained, so two rows can differ + * in a place float64 cannot represent; narrowing the anchor to a double and + * comparing it back against full-precision `numeric` collapses those rows onto + * one anchor, and the page boundary then skips or repeats them. + * + * The bound value carries an explicit `::numeric` cast for the same reason + * {@link timestampKey} casts: a bare placeholder arrives as `unknown`, and while + * that infers fine against a bare column, these expressions are wrapped in + * `COALESCE`, whose result type must be resolvable from its arguments. + */ +export function decimalKey(column: SQLWrapper, read: (row: Row) => string): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => + typeof value === 'string' && DECIMAL_CURSOR_PATTERN.test(value) + ? sql`cast(${value} as numeric)` + : null, + } +} + /** * A timestamp key, ordered and compared at millisecond precision. * diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 904f8cd1731..64ee52111bf 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -25,8 +25,11 @@ export { defineV2JsonRoute, V2_PARSE_DEFAULTS, type V2ErrorPolicy, + type V2RolloutGatePolicy, V2RouteInfrastructureError, v2ApiKeyAuth, + /** The media-type-aware 415 a raw route installs over {@link V2_PARSE_DEFAULTS}. */ + v2InvalidBodyResponse, v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes/v2-json-route' diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts index 1ffe79bbf4b..aeebf7b602d 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts @@ -53,12 +53,35 @@ describe('v2 API key authentication', () => { rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], rateLimitSubscription: null, keyType: 'personal', + keyExpiresAt: null, }) expect(mocks.getHighestPrioritySubscription).toHaveBeenCalledWith('user-1', { onError: 'throw', }) }) + /** + * `GET /api/v2/meta` reports the key's expiry. Carrying it here — from the + * row `requireValidRow` has already read and checked — is what keeps the + * application layer out of the `api_key` table. + */ + it('carries the authenticated row expiry so no surface re-reads the key', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: new Date('2027-01-01T00:00:00.000Z'), + userBanned: false, + }, + ]) + + const result = await authenticateV2ApiKey('secret') + + expect(result.keyExpiresAt).toEqual(new Date('2027-01-01T00:00:00.000Z')) + }) + it('normalizes a workspace key as the workspace, not its creator', async () => { queueTableRows(schemaMock.apiKey, [ { @@ -87,6 +110,7 @@ describe('v2 API key authentication', () => { rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], rateLimitSubscription: { plan: 'team', referenceId: 'organization-1' }, keyType: 'workspace', + keyExpiresAt: null, }) expect(mocks.getHighestPrioritySubscription).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts index d647ec9a778..36f2cd98316 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -25,6 +25,13 @@ export interface V2ApiKeyAuthContext { rateLimitSubjectIds: readonly [string, ...string[]] rateLimitSubscription: RateLimitSubscription | null keyType: 'personal' | 'workspace' + /** + * When the authenticated key expires, or `null` when it never does — read + * from the same row `requireValidRow` has just checked, so no surface has to + * go back to the API-key table for it. `/api/v2/meta` reports it, and the + * application layer must never query `api_key` itself to find it out. + */ + keyExpiresAt: Date | null } export class V2ApiKeyUnauthenticatedError extends Error { @@ -72,6 +79,7 @@ export async function authenticateV2ApiKey( rateLimitSubjectIds: [`user:${ANONYMOUS_USER_ID}`], rateLimitSubscription: null, keyType: 'personal', + keyExpiresAt: null, } } if (!apiKeyHeader) { @@ -106,6 +114,7 @@ export async function authenticateV2ApiKey( ? { plan: subscription.plan, referenceId: subscription.referenceId } : null, keyType: 'personal', + keyExpiresAt: row.expiresAt, } } @@ -128,5 +137,6 @@ export async function authenticateV2ApiKey( } : null, keyType: 'workspace', + keyExpiresAt: row.expiresAt, } } diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts index 54755d94317..0701bf5b22e 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -32,8 +32,10 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' import { + admitV2Request, defineV2JsonRoute, type V2ErrorPolicy, + type V2RolloutGatePolicy, v2ApiKeyAuth, v2HeadAuthorizationResponse, v2OrchestrationErrorPolicy, @@ -52,6 +54,7 @@ const auth = { rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], rateLimitSubscription: null, keyType: 'personal', + keyExpiresAt: null, } satisfies V2ApiKeyAuthContext const resetAt = new Date('2026-08-08T20:00:00.000Z') const allowedRate = { allowed: true, remaining: 99, resetAt } @@ -94,6 +97,7 @@ interface HandlerOverrides { present?: (result: Result) => { data: { value: string } } | Promise<{ data: { value: string } }> statusForResult?: (result: Result) => number parseOptions?: Omit + gate?: V2RolloutGatePolicy } function createHandler(overrides: HandlerOverrides = {}) { @@ -118,6 +122,44 @@ function createHandler(overrides: HandlerOverrides = {}) { onSuccess: overrides.onSuccess, statusForResult: overrides.statusForResult, parseOptions: overrides.parseOptions, + gate: overrides.gate, + }) +} + +/** + * A handler on the one contract path the rollout exemption is reserved for. + * `createHandler`'s `/api/v2/widgets` contract cannot carry `gate: 'exempt'` — + * the builder refuses it — which is the property the sweep below pins. + */ +const metaContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/meta', + query: z.object({}).strict(), + response: { + mode: 'json', + status: 200, + schema: z.object({ data: z.object({ value: z.string() }) }), + }, +}) + +function createMetaHandler(gate: V2RolloutGatePolicy) { + return defineV2JsonRoute({ + contract: metaContract, + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: () => ({ value: 'meta' }), + useCase: { operation, execute: async ({ input }) => input }, + present: (result) => ({ data: result }), + gate, + }) +} + +function metaRequest(): NextRequest { + return new NextRequest('http://localhost/api/v2/meta', { + method: 'GET', + headers: { 'x-api-key': 'secret' }, }) } @@ -830,3 +872,93 @@ describe('defineV2JsonRoute presentation', () => { ) }) }) + +/** + * The rollout gate is centralized on the builder so a route cannot invent its + * own rollout policy. `gate: 'exempt'` is the single typed hole in that, and it + * exists for `GET /api/v2/meta` alone — the endpoint whose whole job is to + * report the gate's decision, which a gated version could never do. + */ +describe('defineV2JsonRoute rollout gate policy', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('enforces the gate by default', async () => { + await createHandler()(request()) + + expect(v2RouteMocks.gate).toHaveBeenCalledWith(auth.rolloutUserId) + }) + + it('skips the gate entirely when the route declares itself exempt', async () => { + v2RouteMocks.gate.mockResolvedValue( + NextResponse.json({ error: { code: 'NOT_FOUND', message: 'Not found' } }, { status: 404 }) + ) + + const response = await createMetaHandler('exempt')(metaRequest()) + + expect(response.status).toBe(200) + expect(v2RouteMocks.gate).not.toHaveBeenCalled() + }) + + it('still authenticates and rate-limits an exempt route', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) + + const unauthenticated = await createMetaHandler('exempt')(metaRequest()) + expect(unauthenticated.status).toBe(401) + + v2RouteMocks.operationRate.mockResolvedValue({ allowed: false, remaining: 0, resetAt }) + const limited = await createMetaHandler('exempt')(metaRequest()) + expect(limited.status).toBe(429) + }) +}) + +/** + * The exemption is a hole in the rollout concealment, justified only for the one + * endpoint that reports the concealed fact to the caller it is about. A second + * exempt route would be a silent widening. + * + * This used to be a source-text sweep of the route tree for the literal + * `gate: 'exempt'`, which both over- and under-approximated: `admitV2Request` + * took the gate as an optional trailing positional, so any raw special route + * could un-gate itself without the literal ever appearing, and a + * `const GATE = 'exempt'` evaded it anyway. The property is now enforced where + * it cannot be written around — at definition time, on the only door that + * accepts the option, against the one contract path it is reserved for. + */ +describe('v2 rollout-gate exemptions', () => { + it('refuses gate: exempt on any contract but GET /api/v2/meta', () => { + expect(() => createHandler({ gate: 'exempt' })).toThrow( + "Route POST /api/v2/widgets declares gate: 'exempt', which is reserved for /api/v2/meta" + ) + }) + + it('accepts gate: exempt on the meta contract', () => { + expect(() => createMetaHandler('exempt')).not.toThrow() + }) + + /** + * The raw-route door. `admitV2Request` used to take the gate as an optional + * trailing positional, so `admitV2Request(..., 'exempt')` from any special + * route un-gated it with nothing for a reviewer to see. A stray extra + * argument must now change nothing. + */ + it('gates a raw special route no matter what a caller appends', async () => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + + const admit = admitV2Request as unknown as (...args: unknown[]) => Promise + await admit(metaRequest(), operation, v2ApiKeyAuth, v2RateLimits.publicApi, 'exempt') + + expect(v2RouteMocks.gate).toHaveBeenCalledWith(auth.rolloutUserId) + }) +}) 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 f13eb0416b5..9351fc5aacf 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -16,7 +16,11 @@ import { type V2ApiKeyAuthContext, V2ApiKeyUnauthenticatedError, } from '@/lib/api/server/routes/v2-api-key-auth' -import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' +import { + type ParsedRequest, + type ParseRequestOptions, + parseRequest, +} from '@/lib/api/server/validation' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { getRateLimit, RateLimiter, type SubscriptionPlan } from '@/lib/core/rate-limiter' import { getClientIp } from '@/lib/core/utils/request' @@ -287,11 +291,46 @@ async function enforceV2PreAuthIpLimit(request: NextRequest): Promise { @@ -305,29 +344,53 @@ async function admitAuthenticatedV2Request( throw new V2RouteInfrastructureError('authentication', error) } - let gate - try { - gate = await v2ApiGateError(auth.rolloutUserId) - } catch (error) { - throw new V2RouteInfrastructureError('rollout_gate', error) + if (gatePolicy === 'enforced') { + let gate + try { + gate = await v2ApiGateError(auth.rolloutUserId) + } catch (error) { + throw new V2RouteInfrastructureError('rollout_gate', error) + } + if (gate) return { success: false, response: gate } } - if (gate) return { success: false, response: gate } const limited = await rateLimitPolicy.enforce(request, auth, operation) return limited ? { success: false, response: limited } : { success: true, auth } } -export async function admitV2Request( +async function admitGatedV2Request( request: NextRequest, operation: ApplicationOperation, authPolicy: typeof v2ApiKeyAuth, - rateLimitPolicy: V2RateLimitPolicy + rateLimitPolicy: V2RateLimitPolicy, + gatePolicy: V2RolloutGatePolicy ): Promise< { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } > { const preAuthResponse = await enforceV2PreAuthIpLimit(request) if (preAuthResponse) return { success: false, response: preAuthResponse } - return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy) + return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy, gatePolicy) +} + +/** + * Admission for a v2 route the builders do not cover — a documented special + * route such as the resume leg. + * + * It takes no gate argument, and that omission is the point: with one, any raw + * route could pass `'exempt'` and un-gate itself without ever writing the + * builder option a reviewer looks for. The rollout exemption therefore has + * exactly one door, {@link defineV2JsonRoute}'s `gate`, which in turn accepts it + * for exactly one contract path. + */ +export async function admitV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { + return admitGatedV2Request(request, operation, authPolicy, rateLimitPolicy, 'enforced') } export async function admitOptionalV2Request( @@ -344,8 +407,27 @@ export async function admitOptionalV2Request( return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy) } +/** + * What `mapInput` learns about the authenticated credential. + * + * Deliberately not the whole {@link V2ApiKeyAuthContext}: it carries the + * principal, and a route mapping identity into use-case input would be routing + * an authorization decision around the application boundary. These three are + * facts about the credential rather than about who holds it — + * `rolloutUserId` is the subject the `v2-api` gate is keyed on and is rollout + * context only, never an authorization principal. + * + * One route reads it: `GET /api/v2/meta`, whose resource *is* the calling key. + */ +export interface V2CredentialFacts { + readonly keyType: 'personal' | 'workspace' + readonly keyExpiresAt: Date | null + readonly rolloutUserId: string +} + interface V2JsonRouteOptions - extends JsonRouteDefinition { + extends Omit, 'mapInput'> { + mapInput(input: ParsedRequest, credential: V2CredentialFacts): I auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy @@ -366,6 +448,23 @@ interface V2JsonRouteOptions beforeParse?(args: { request: NextRequest @@ -391,6 +490,7 @@ export function defineV2JsonRoute< options.operation, options.useCase.operation ) + requireGateExemptionIsMeta(options.contract, options.gate) requireHeadAuthorizableUseCase(options.contract, options.headSafe, options.useCase) const wrapped = withRouteHandler( @@ -401,11 +501,12 @@ export function defineV2JsonRoute< ) } - const admission = await admitV2Request( + const admission = await admitGatedV2Request( request, options.operation, options.auth, - options.rateLimit + options.rateLimit, + options.gate ?? 'enforced' ) if (!admission.success) return admission.response const { auth } = admission @@ -429,10 +530,16 @@ export function defineV2JsonRoute< }) if (!parsed.success) return parsed.response + const credentialFacts: V2CredentialFacts = { + keyType: auth.keyType, + keyExpiresAt: auth.keyExpiresAt, + rolloutUserId: auth.rolloutUserId, + } + if (request.method === 'HEAD' && options.headSafe === false) { let input: I try { - input = options.mapInput(parsed.data) + input = options.mapInput(parsed.data, credentialFacts) } catch (error) { const response = options.errorPolicy.render(error) if (response) return response @@ -448,7 +555,7 @@ export function defineV2JsonRoute< } try { - const input = options.mapInput(parsed.data) + const input = options.mapInput(parsed.data, credentialFacts) const result = await options.useCase.execute({ principal: auth.principal, input, diff --git a/apps/sim/lib/catalog/application/catalog-context.ts b/apps/sim/lib/catalog/application/catalog-context.ts new file mode 100644 index 00000000000..859e76cb1a6 --- /dev/null +++ b/apps/sim/lib/catalog/application/catalog-context.ts @@ -0,0 +1,94 @@ +import type { Principal } from '@sim/auth/principal' +import { type BlockVisibilityState, getBlockVisibility } from '@/lib/core/config/block-visibility' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { allowedIntegrationTypes, principalUserId } from '@/lib/integrations/principal-scope.server' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' +import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' +import type { BlockConfig } from '@/blocks/types' +import { isHiddenUnder } from '@/blocks/visibility/context' +import { withBlockVisibility } from '@/blocks/visibility/server-context' + +/** + * The per-caller, per-workspace state every catalog read is filtered through. + * + * A catalog looks like static reference data and is not. Four independent + * policies decide what a caller may see, and all four are resolved here so the + * six catalog use cases cannot answer differently. + */ +export interface CatalogGate { + /** Which unreleased blocks this viewer may see, and which shipped ones are kill-switched. */ + visibility: BlockVisibilityState + /** Lowercased block types the workspace permits, or `null` when unrestricted. */ + allowedIntegrations: ReadonlySet | null + /** Workflows this workspace's organization has deployed as blocks. */ + customBlockRows: Awaited> +} + +/** Loads the canonical workspace, concealing one the caller cannot reach as absent. */ +export async function loadCatalogWorkspaceContext( + workspaceId: string +): Promise { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +/** Resolves every policy that narrows the catalog for this caller and workspace. */ +export async function resolveCatalogGate( + principal: Principal, + context: ActiveWorkspaceApplicationContext +): Promise { + const userId = principalUserId(principal) + const [allowedIntegrations, visibility, customBlockRows] = await Promise.all([ + allowedIntegrationTypes(principal, context.workspaceId), + getBlockVisibility({ + ...(userId ? { userId } : {}), + ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), + }), + listCustomBlocksWithInputsForWorkspace(context.workspaceId), + ]) + return { allowedIntegrations, visibility, customBlockRows } +} + +/** + * Whether this caller may see a block at all. + * + * THE single predicate for the block catalog: the list filters with it and the + * detail route 404s on it. Applying a weaker rule to the detail route would let + * a caller enumerate unrevealed preview blocks one id at a time. + */ +export function isBlockVisibleToCaller(block: BlockConfig, gate: CatalogGate): boolean { + if (block.hideFromToolbar) return false + if (isHiddenUnder(gate.visibility, block)) return false + if (!isIntegrationDeploymentAvailableForVisibility(block.type, gate.visibility)) return false + return isBlockTypeAllowed(block.type, gate) +} + +/** Whether the workspace's permission-group allowlist admits a block type. */ +export function isBlockTypeAllowed(blockType: string, gate: CatalogGate): boolean { + if (gate.allowedIntegrations === null) return true + if (isBlockTypeAccessControlExempt(blockType)) return true + return gate.allowedIntegrations.has(blockType.toLowerCase()) +} + +/** + * Runs `read` with the gate's block scope established. + * + * `getAllBlocks`/`getBlock` are synchronous and resolve both the viewer's + * visibility projection and the workspace's custom blocks from + * AsyncLocalStorage. Outside this scope the visibility resolver returns `null`, + * which is fail-closed for unreleased blocks but does NOT apply the kill switch + * — a disabled shipped block would still be listed. The two scopes are + * independent and nest in either order. + */ +export function withCatalogBlockScope(gate: CatalogGate, read: () => Promise): Promise { + return withBlockVisibility(gate.visibility, () => + withCustomBlockOverlay(gate.customBlockRows, read) + ) +} diff --git a/apps/sim/lib/catalog/application/catalog-page.ts b/apps/sim/lib/catalog/application/catalog-page.ts new file mode 100644 index 00000000000..a280d4b1e20 --- /dev/null +++ b/apps/sim/lib/catalog/application/catalog-page.ts @@ -0,0 +1,92 @@ +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** + * Shared search, sort, and paging for the catalog reads. + * + * Every catalog list is a code-defined set narrowed in memory rather than an + * ordered SQL read, so all three steps happen here and all three are shared — + * two lists sorting the same field differently would make their cursors + * describe different sequences under the same name. + */ + +/** + * Normalizes a search term, rejecting one that is present but blank. + * + * The contracts already trim and reject an empty term, so this is the guard for + * a non-HTTP caller: a blank search that silently matched everything would be a + * filter the caller believes applied and did not. + */ +export function normalizeCatalogSearch(search: string | undefined): string | undefined { + if (search === undefined) return undefined + const normalized = search.trim().toLowerCase() + if (!normalized) throw new OrchestrationError('validation', 'search cannot be empty') + return normalized +} + +/** Whether any of a resource's searchable fields contains the term. */ +export function matchesCatalogSearch( + term: string | undefined, + ...fields: Array +): boolean { + if (term === undefined) return true + return fields.some((field) => field?.toLowerCase().includes(term)) +} + +/** + * Orders two strings by UTF-16 code unit, deliberately not by `localeCompare`. + * + * A bare `localeCompare` reads the process's default locale and ICU data, so two + * app instances started with different `LANG` values order the same set + * differently — and an offset cursor minted on one then names a different row on + * the other, silently skipping or repeating entries. Code-unit order is the same + * everywhere, which is the property a cursor needs; catalog ids and names are + * ASCII, so nothing human-visible changes. + */ +function compareCodeUnits(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +/** + * Sorts a copy by one string field, breaking ties on `id`. + * + * The tie-break is what makes an offset cursor sound: two entries comparing + * equal on the sort field must still hold a fixed order, or the position a + * cursor names moves between requests. `id` is unique across every catalog, so + * it fully orders each one. + */ +export function sortCatalogEntries( + entries: readonly T[], + select: (entry: T) => string, + sortOrder: V2SortOrder +): T[] { + const direction = sortOrder === 'desc' ? -1 : 1 + return [...entries].sort((left, right) => { + const compared = compareCodeUnits(select(left), select(right)) + if (compared !== 0) return compared * direction + return compareCodeUnits(left.id, right.id) * direction + }) +} + +export interface CatalogPage { + entries: T[] + offset: number + limit: number + hasMore: boolean +} + +/** Takes one page out of an ordered sequence, reporting whether more remain. */ +export function takeCatalogPage( + entries: readonly T[], + offset: number, + limit: number +): CatalogPage { + return { + entries: entries.slice(offset, offset + limit), + offset, + limit, + hasMore: offset + limit < entries.length, + } +} diff --git a/apps/sim/lib/catalog/application/catalog-reads.test.ts b/apps/sim/lib/catalog/application/catalog-reads.test.ts new file mode 100644 index 00000000000..9f8aa2d86fa --- /dev/null +++ b/apps/sim/lib/catalog/application/catalog-reads.test.ts @@ -0,0 +1,578 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + allowedIntegrationTypes: vi.fn(), + getBlockVisibility: vi.fn(), + listCustomBlocks: vi.fn(), + isDeploymentAvailable: vi.fn(), + recordAudit: vi.fn(), + getAllBlocks: vi.fn(), + getBlock: vi.fn(), + getLatestBlockForViewer: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: mocks.recordAudit, + AuditAction: {}, + AuditResourceType: {}, +})) + +vi.mock('@/lib/integrations/principal-scope.server', () => ({ + allowedIntegrationTypes: mocks.allowedIntegrationTypes, + principalUserId: (principal: { kind: string; userId?: string }) => + principal.kind === 'session' || principal.kind === 'personal_api_key' + ? principal.userId + : undefined, +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mocks.getBlockVisibility, +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + listCustomBlocksWithInputsForWorkspace: mocks.listCustomBlocks, +})) + +vi.mock('@/lib/integrations/availability.server', () => ({ + isIntegrationDeploymentAvailableForVisibility: mocks.isDeploymentAvailable, +})) + +vi.mock('@/blocks/custom/server-overlay', () => ({ + withCustomBlockOverlay: (_rows: unknown, run: () => Promise) => run(), +})) + +vi.mock('@/blocks/visibility/server-context', () => ({ + withBlockVisibility: (_state: unknown, run: () => Promise) => run(), +})) + +vi.mock('@/blocks/registry', () => ({ + getAllBlocks: mocks.getAllBlocks, + getBlock: mocks.getBlock, + getLatestBlockForViewer: mocks.getLatestBlockForViewer, + getBlockMeta: vi.fn(() => ({ tags: ['messaging'] })), +})) + +vi.mock('@/tools/metadata', () => ({ + getToolMetadata: (toolId: string) => + Object.hasOwn(TOOL_METADATA, toolId) ? TOOL_METADATA[toolId] : undefined, +})) + +vi.mock('@/tools/metadata-outputs', () => ({ + getToolOutputsMetadata: () => ({ ok: { type: 'boolean', description: 'Whether it worked.' } }), +})) + +vi.mock('@/tools/tool-ids', () => ({ + getToolIds: () => Object.freeze(Object.keys(TOOL_METADATA)), + resolveToolId: (toolId: string) => toolId, +})) + +import { getCatalogBlock } from '@/lib/catalog/application/get-block' +import { getCatalogTool } from '@/lib/catalog/application/get-tool' +import { listCatalogBlocks } from '@/lib/catalog/application/list-blocks' +import { listCatalogTools } from '@/lib/catalog/application/list-tools' +import type { BlockConfig } from '@/blocks/types' + +const TOOL_METADATA: Record> = { + slack_message: { + id: 'slack_message', + name: 'Slack Send Message', + description: 'Send a message.', + version: '1.0.0', + params: { text: { type: 'string', required: true } }, + hostedApiKey: 'none', + oauth: { required: true, provider: 'slack' }, + }, + preview_call: { + id: 'preview_call', + name: 'Preview Call', + description: 'Call the preview service.', + version: '1.0.0', + params: {}, + hostedApiKey: 'always', + }, +} + +const WORKSPACE_ID = 'workspace-1' + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const session: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } +const workspaceKey: WorkspaceApiKeyPrincipal = { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} + +function block(overrides: Partial & { type: string }): BlockConfig { + return { + name: overrides.type, + description: `${overrides.type} block`, + category: 'tools', + bgColor: '#000000', + icon: (() => null) as unknown as BlockConfig['icon'], + subBlocks: [], + tools: { access: [] }, + inputs: {}, + outputs: {}, + ...overrides, + } as BlockConfig +} + +const slackBlock = block({ + type: 'slack', + name: 'Slack', + description: 'Send messages in Slack.', + triggerAllowed: true, + subBlocks: [ + { + id: 'operation', + type: 'dropdown', + title: 'Operation', + options: [{ id: 'send', label: 'Send message' }], + }, + { + id: 'text', + type: 'long-input', + title: 'Message', + condition: { field: 'operation', value: 'send' }, + }, + ], + tools: { + access: ['slack_message'], + config: { tool: () => 'slack_message' }, + }, +}) +const notionBlock = block({ type: 'notion', name: 'Notion', description: 'Read Notion pages.' }) +const previewBlock = block({ + type: 'preview_thing', + name: 'Preview thing', + preview: true, + tools: { access: ['preview_call'] }, +}) +const customBlock = block({ + type: 'custom_block_reports', + name: 'Reports', + description: 'Run the reports workflow.', +}) +/** A superseded version: present in the registry, hidden from every discovery surface. */ +const confluenceV1 = block({ + type: 'confluence', + name: 'Confluence', + hideFromToolbar: true, +}) +const confluenceV2 = block({ + type: 'confluence_v2', + name: 'Confluence', + description: 'Read Confluence pages.', +}) + +interface Visibility { + revealed: Set + disabled: Set + previewTagged: Set +} + +const NOTHING_GATED: Visibility = { + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), +} + +/** The visibility document both the gate and the registry stub below resolve against. */ +let visibility: Visibility = NOTHING_GATED + +function setVisibility(state: Visibility): void { + visibility = state + mocks.getBlockVisibility.mockResolvedValue(state) +} + +/** + * Stands in for `getLatestBlockForViewer`: resolves an unversioned base type to + * its highest version and applies the list's " (Preview)" display suffix. Those + * are exactly the two behaviours the detail read used to lack, so the stub has + * to reproduce them or the tests below would pass against the old `getBlock`. + */ +function resolveLatestForViewer(type: string, registry: BlockConfig[]): BlockConfig | undefined { + const versionPattern = new RegExp(`^${type}_v(\\d+)$`) + const latestVersioned = registry + .filter((entry) => versionPattern.test(entry.type)) + .sort((left, right) => left.type.localeCompare(right.type)) + .at(-1) + const block = latestVersioned ?? registry.find((entry) => entry.type === type) + if (!block) return undefined + return block.preview && visibility.previewTagged.has(block.type) + ? { ...block, name: `${block.name} (Preview)` } + : block +} + +const listInput = { + workspaceId: WORKSPACE_ID, + sortBy: 'id' as const, + sortOrder: 'asc' as const, + offset: 0, + limit: 50, +} + +describe('catalog block and tool reads', () => { + afterAll(resetEnvFlagsMock) + + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.allowedIntegrationTypes.mockResolvedValue(null) + setVisibility(NOTHING_GATED) + mocks.listCustomBlocks.mockResolvedValue([]) + mocks.isDeploymentAvailable.mockReturnValue(true) + setEnvFlags({ isHosted: true }) + mocks.getAllBlocks.mockReturnValue([slackBlock, notionBlock, customBlock]) + /** `isBlockTypeAccessControlExempt` reads the pure registry lookup. */ + mocks.getBlock.mockImplementation((type: string) => + [slackBlock, notionBlock, previewBlock, customBlock].find((entry) => entry.type === type) + ) + mocks.getLatestBlockForViewer.mockImplementation((type: string) => + resolveLatestForViewer(type, [ + slackBlock, + notionBlock, + previewBlock, + customBlock, + confluenceV1, + confluenceV2, + ]) + ) + }) + + it('lists blocks for a session principal and records no audit', async () => { + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + + expect(result.entries.map((entry) => entry.id)).toEqual([ + 'custom_block_reports', + 'notion', + 'slack', + ]) + expect(result.hasMore).toBe(false) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('accepts a workspace API key, which has no user for permission groups to key on', async () => { + const result = await listCatalogBlocks.execute({ principal: workspaceKey, input: listInput }) + + expect(result.entries).toHaveLength(3) + expect(mocks.allowedIntegrationTypes).toHaveBeenCalledWith(workspaceKey, WORKSPACE_ID) + expect(mocks.getBlockVisibility).toHaveBeenCalledWith({ orgId: 'org-1' }) + }) + + it('resolves block visibility for the acting user and their organization', async () => { + await listCatalogBlocks.execute({ principal: session, input: listInput }) + + expect(mocks.getBlockVisibility).toHaveBeenCalledWith({ userId: 'user-1', orgId: 'org-1' }) + }) + + it('discriminates a workspace custom block from a shipped one', async () => { + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + + const sources = Object.fromEntries(result.entries.map((entry) => [entry.id, entry.source])) + expect(sources).toEqual({ + custom_block_reports: 'custom', + notion: 'builtin', + slack: 'builtin', + }) + }) + + it('answers not found for a workspace the caller cannot reach', async () => { + mocks.loadWorkspace.mockResolvedValue(null) + + await expect( + listCatalogBlocks.execute({ principal: session, input: listInput }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workspace not found' }) + }) + + it('propagates an integration-allowlist infrastructure failure instead of concealing it', async () => { + mocks.allowedIntegrationTypes.mockRejectedValue(new Error('permission store unavailable')) + + await expect( + listCatalogBlocks.execute({ principal: session, input: listInput }) + ).rejects.toThrow('permission store unavailable') + }) + + it('hides an unrevealed preview block from the list and from its detail read', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) + + await expect( + getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'preview_thing' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Block not found' }) + }) + + it('reveals a preview block once the visibility document names it', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + setVisibility({ + revealed: new Set(['preview_thing']), + disabled: new Set(), + previewTagged: new Set(['preview_thing']), + }) + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).toContain('preview_thing') + }) + + it('drops a kill-switched block from the list and 404s its detail', async () => { + setVisibility({ + revealed: new Set(), + disabled: new Set(['notion']), + previewTagged: new Set(), + }) + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).not.toContain('notion') + + await expect( + getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'notion' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Block not found' }) + }) + + it('drops a block the permission-group allowlist excludes, from list and detail alike', async () => { + mocks.allowedIntegrationTypes.mockResolvedValue(new Set(['slack'])) + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) + + await expect( + getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'notion' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('drops a block this deployment does not ship', async () => { + mocks.isDeploymentAvailable.mockImplementation((type: string) => type !== 'notion') + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'slack']) + }) + + it('narrows to trigger-capable blocks without a second endpoint', async () => { + const result = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, capability: 'trigger' }, + }) + + expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) + }) + + it('rejects a blank search rather than silently matching everything', async () => { + await expect( + listCatalogBlocks.execute({ principal: session, input: { ...listInput, search: ' ' } }) + ).rejects.toMatchObject({ code: 'validation', message: 'search cannot be empty' }) + }) + + it('pages the sorted sequence and reports whether more remain', async () => { + const first = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, limit: 2 }, + }) + expect(first.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'notion']) + expect(first.hasMore).toBe(true) + + const second = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, limit: 2, offset: 2 }, + }) + expect(second.entries.map((entry) => entry.id)).toEqual(['slack']) + expect(second.hasMore).toBe(false) + }) + + it('reads one block with its operations and tools resolved from metadata', async () => { + const { block: detail } = await getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'slack' }, + }) + + expect(detail.id).toBe('slack') + expect(detail.tools.map((tool) => tool.id)).toEqual(['slack_message']) + expect(detail.tools[0].params).toEqual({ text: { type: 'string', required: true } }) + + /** + * The operation's inputs come from the generated tool metadata, not the + * executable registry — that substitution is the whole point of the shared + * projection, so it is pinned rather than assumed. + */ + expect(detail.operationIds).toEqual(['send']) + expect(detail.operations.send.toolId).toBe('slack_message') + expect(detail.operations.send.inputs).toEqual({ text: { type: 'string', required: true } }) + expect(detail.operations.send.outputs).toEqual({ + ok: { type: 'boolean', description: 'Whether it worked.' }, + }) + expect(detail.operationInputSchema.send.map((field) => field.id)).toEqual(['text']) + }) + + it('lists only the tools a visible block exposes', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + + const result = await listCatalogTools.execute({ principal: session, input: listInput }) + + expect(result.entries.map((entry) => entry.id)).toEqual(['slack_message']) + }) + + it('filters tools by how their API key is supplied', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + setVisibility({ + revealed: new Set(['preview_thing']), + disabled: new Set(), + previewTagged: new Set(), + }) + + const hosted = await listCatalogTools.execute({ + principal: session, + input: { ...listInput, hostedApiKey: 'always' }, + }) + expect(hosted.entries.map((entry) => entry.id)).toEqual(['preview_call']) + + const byProvider = await listCatalogTools.execute({ + principal: session, + input: { ...listInput, oauthProvider: 'SLACK' }, + }) + expect(byProvider.entries.map((entry) => entry.id)).toEqual(['slack_message']) + }) + + it('answers not found for an unknown tool and for one no visible block exposes', async () => { + await expect( + getCatalogTool.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, toolId: 'nope_missing' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Tool not found' }) + + mocks.getAllBlocks.mockReturnValue([slackBlock]) + await expect( + getCatalogTool.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, toolId: 'preview_call' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Tool not found' }) + }) + + /** + * The list projects through the viewer's visibility, which renames a revealed + * preview block; the detail read used a bare registry lookup, which does not. + * A caller reading `GET /v2/blocks/preview_thing` after seeing it in the list + * got a different `name` for the same block. + */ + it('names a revealed preview block identically in the list and its detail', async () => { + mocks.getAllBlocks.mockReturnValue([ + slackBlock, + { ...previewBlock, name: `${previewBlock.name} (Preview)` }, + ]) + setVisibility({ + revealed: new Set(['preview_thing']), + disabled: new Set(), + previewTagged: new Set(['preview_thing']), + }) + + const listed = await listCatalogBlocks.execute({ principal: session, input: listInput }) + const summary = listed.entries.find((entry) => entry.id === 'preview_thing') + + const { block: detail } = await getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'preview_thing' }, + }) + + expect(detail.name).toBe('Preview thing (Preview)') + expect(detail.name).toBe(summary?.name) + }) + + /** + * Every versioned family's base type resolves to the superseded v1, which + * carries `hideFromToolbar` — so `GET /v2/blocks/confluence` 404'd while the + * list contained `confluence_v2`. + */ + it('resolves an unversioned block name to its newest version and echoes the resolved id', async () => { + mocks.getAllBlocks.mockReturnValue([confluenceV2]) + + const { block: detail } = await getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'confluence' }, + }) + + expect(detail.id).toBe('confluence_v2') + }) + + it('orders by code unit rather than the process locale', async () => { + mocks.getAllBlocks.mockReturnValue([ + block({ type: 'b_lower', name: 'apple' }), + block({ type: 'a_upper', name: 'Banana' }), + ]) + + const result = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, sortBy: 'name' }, + }) + + /** + * `'Banana'.localeCompare('apple')` is negative under an en locale and + * positive by code unit. Pinning the code-unit answer is what makes an + * offset cursor name the same row on every instance, whatever its `LANG`. + */ + expect(result.entries.map((entry) => entry.name)).toEqual(['Banana', 'apple']) + }) + + it('reports no hosted key on a deployment that supplies none', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + setVisibility({ + revealed: new Set(['preview_thing']), + disabled: new Set(), + previewTagged: new Set(), + }) + setEnvFlags({ isHosted: false }) + + const { tool } = await getCatalogTool.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, toolId: 'preview_call' }, + }) + expect(tool.hostedApiKey).toBe('none') + + const listed = await listCatalogTools.execute({ principal: session, input: listInput }) + expect(listed.entries.map((entry) => entry.hostedApiKey)).toEqual(['none', 'none']) + }) + + it('reads one tool with its params and outputs', async () => { + const { tool } = await getCatalogTool.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, toolId: 'slack_message' }, + }) + + expect(tool.id).toBe('slack_message') + expect(tool.outputs).toEqual({ ok: { type: 'boolean', description: 'Whether it worked.' } }) + }) +}) diff --git a/apps/sim/lib/catalog/application/get-block.ts b/apps/sim/lib/catalog/application/get-block.ts new file mode 100644 index 00000000000..0c9d3505e0c --- /dev/null +++ b/apps/sim/lib/catalog/application/get-block.ts @@ -0,0 +1,54 @@ +import { + isBlockVisibleToCaller, + loadCatalogWorkspaceContext, + resolveCatalogGate, + withCatalogBlockScope, +} from '@/lib/catalog/application/catalog-context' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { type CatalogBlockDetail, projectBlockDetail } from '@/lib/catalog/projection/block-detail' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { isHosted } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getLatestBlockForViewer } from '@/blocks/registry' + +export interface GetCatalogBlockInput { + workspaceId: string + blockId: string +} + +export interface GetCatalogBlockResult { + block: CatalogBlockDetail +} + +/** + * One block's full authoring shape. + * + * An unversioned base type resolves to its newest version, exactly as the tool + * detail read does — `confluence` answers with `confluence_v2` — and the + * response echoes the resolved id. Without that, every one of the 34 versioned + * families 404s on the name the list publishes it under. + * + * Every filter the list applies also produces a 404 here — an unknown type, a + * block hidden from the toolbar, an unrevealed preview block, a kill-switched + * one, a type this deployment does not ship, and one the workspace's permission + * groups exclude all answer identically. Anything softer would let a caller + * enumerate unrevealed blocks one id at a time. + */ +export const getCatalogBlock = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.readBlock, + resolveContext: ({ input }: { input: GetCatalogBlockInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const gate = await resolveCatalogGate(principal, context) + + const detail = await withCatalogBlockScope(gate, async () => { + const block = getLatestBlockForViewer(input.blockId) + if (!block || !isBlockVisibleToCaller(block, gate)) return null + return projectBlockDetail(block, { deployment: { hostedKeys: isHosted } }) + }) + + if (!detail) throw new OrchestrationError('not_found', 'Block not found') + return { block: detail } + }, +}) diff --git a/apps/sim/lib/catalog/application/get-tool.ts b/apps/sim/lib/catalog/application/get-tool.ts new file mode 100644 index 00000000000..d08b181f838 --- /dev/null +++ b/apps/sim/lib/catalog/application/get-tool.ts @@ -0,0 +1,48 @@ +import { + loadCatalogWorkspaceContext, + resolveCatalogGate, +} from '@/lib/catalog/application/catalog-context' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { resolveVisibleToolIds } from '@/lib/catalog/application/tool-scope' +import { type CatalogToolDetail, projectToolDetail } from '@/lib/catalog/projection/tool' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { isHosted } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveToolId } from '@/tools/tool-ids' + +export interface GetCatalogToolInput { + workspaceId: string + toolId: string +} + +export interface GetCatalogToolResult { + tool: CatalogToolDetail +} + +/** + * One built-in tool's parameters and outputs. + * + * An unversioned name resolves to the newest version exactly as execution does, + * and the returned `id` is the resolved one so a caller can see which version + * answered. A tool the workspace's blocks do not expose answers 404 rather than + * 403, for the same enumeration reason as the block detail read. + */ +export const getCatalogTool = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.readTool, + resolveContext: ({ input }: { input: GetCatalogToolInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const resolvedToolId = resolveToolId(input.toolId) + const tool = projectToolDetail(resolvedToolId, { hostedKeys: isHosted }) + if (!tool) throw new OrchestrationError('not_found', 'Tool not found') + + const gate = await resolveCatalogGate(principal, context) + const visibleToolIds = await resolveVisibleToolIds(gate) + if (!visibleToolIds.has(resolvedToolId)) { + throw new OrchestrationError('not_found', 'Tool not found') + } + + return { tool } + }, +}) diff --git a/apps/sim/lib/catalog/application/list-blocks.ts b/apps/sim/lib/catalog/application/list-blocks.ts new file mode 100644 index 00000000000..bbf6df05bef --- /dev/null +++ b/apps/sim/lib/catalog/application/list-blocks.ts @@ -0,0 +1,91 @@ +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { + isBlockVisibleToCaller, + loadCatalogWorkspaceContext, + resolveCatalogGate, + withCatalogBlockScope, +} from '@/lib/catalog/application/catalog-context' +import { + type CatalogPage, + matchesCatalogSearch, + normalizeCatalogSearch, + sortCatalogEntries, + takeCatalogPage, +} from '@/lib/catalog/application/catalog-page' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { + type CatalogBlockSummary, + projectBlockSummary, +} from '@/lib/catalog/projection/block-summary' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { getAllBlocks } from '@/blocks/registry' + +export interface ListCatalogBlocksInput { + workspaceId: string + search?: string + category?: 'blocks' | 'tools' | 'triggers' + capability?: 'trigger' + source?: 'builtin' | 'custom' + sortBy: 'id' | 'name' | 'category' + sortOrder: V2SortOrder + offset: number + limit: number +} + +export type ListCatalogBlocksResult = CatalogPage + +const SORT_FIELDS: Record< + ListCatalogBlocksInput['sortBy'], + (block: CatalogBlockSummary) => string +> = { + id: (block) => block.id, + name: (block) => block.name, + category: (block) => block.category, +} + +function matchesFilters(block: CatalogBlockSummary, input: ListCatalogBlocksInput): boolean { + if (input.category && block.category !== input.category) return false + if (input.capability === 'trigger' && !block.triggerCapable) return false + if (input.source && block.source !== input.source) return false + return true +} + +/** + * The blocks this caller may place in this workspace. + * + * Built-in and custom blocks are one list on purpose: a workflow references + * either by `type`, so "what may I place?" must be answerable in one call. The + * `source` field tells them apart, and `capability=trigger` narrows to the + * blocks that can start a workflow rather than needing a second endpoint. + * + * No audit is projected — reading a catalog is not a semantic event, and no + * shipped v2 read records one. + */ +export const listCatalogBlocks = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.listBlocks, + resolveContext: ({ input }: { input: ListCatalogBlocksInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const search = normalizeCatalogSearch(input.search) + const gate = await resolveCatalogGate(principal, context) + + const summaries = await withCatalogBlockScope(gate, async () => + getAllBlocks() + .filter((block) => isBlockVisibleToCaller(block, gate)) + .map(projectBlockSummary) + ) + + const filtered = summaries.filter( + (block) => + matchesFilters(block, input) && + matchesCatalogSearch(search, block.id, block.name, block.description) + ) + + return takeCatalogPage( + sortCatalogEntries(filtered, SORT_FIELDS[input.sortBy], input.sortOrder), + input.offset, + input.limit + ) + }, +}) diff --git a/apps/sim/lib/catalog/application/list-connector-types.ts b/apps/sim/lib/catalog/application/list-connector-types.ts new file mode 100644 index 00000000000..5bf924cc2e2 --- /dev/null +++ b/apps/sim/lib/catalog/application/list-connector-types.ts @@ -0,0 +1,47 @@ +import { loadCatalogWorkspaceContext } from '@/lib/catalog/application/catalog-context' +import { + matchesCatalogSearch, + normalizeCatalogSearch, +} from '@/lib/catalog/application/catalog-page' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { + type CatalogConnectorType, + projectConnectorType, +} from '@/lib/catalog/projection/connector-type' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' + +export interface ListCatalogConnectorTypesInput { + workspaceId: string + search?: string +} + +export interface ListCatalogConnectorTypesResult { + connectorTypes: CatalogConnectorType[] +} + +/** + * Every knowledge-base connector type, in registry order. + * + * Returned as one page: the set is bounded by the code-defined connector + * registry rather than by workspace content, exactly as the credential-provider + * catalog is. Nothing gates a connector type per workspace today, but the + * operation is still workspace-scoped — retrofitting a required parameter onto + * a shipped v2 contract is a breaking change, and one parameter now is cheap. + */ +export const listCatalogConnectorTypes = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.listConnectorTypes, + resolveContext: ({ input }: { input: ListCatalogConnectorTypesInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ input }): Promise => { + const search = normalizeCatalogSearch(input.search) + const connectorTypes: CatalogConnectorType[] = [] + for (const [connectorType, meta] of Object.entries(CONNECTOR_META_REGISTRY)) { + const projected = projectConnectorType(connectorType, meta) + if (!matchesCatalogSearch(search, projected.name)) continue + connectorTypes.push(projected) + } + return { connectorTypes } + }, +}) diff --git a/apps/sim/lib/catalog/application/list-registries.test.ts b/apps/sim/lib/catalog/application/list-registries.test.ts new file mode 100644 index 00000000000..d3649d79dd2 --- /dev/null +++ b/apps/sim/lib/catalog/application/list-registries.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: mocks.recordAudit, + AuditAction: {}, + AuditResourceType: {}, +})) + +import { listCatalogConnectorTypes } from '@/lib/catalog/application/list-connector-types' + +const WORKSPACE_ID = 'workspace-1' +const session: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } + +describe('connector-type catalog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolvePermission.mockResolvedValue('read') + }) + + it('returns the whole connector-type registry and records no audit', async () => { + const { connectorTypes } = await listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID }, + }) + + expect(connectorTypes.length).toBeGreaterThan(10) + expect(connectorTypes.every((entry) => typeof entry.connectorType === 'string')).toBe(true) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('publishes the multi and canonical-pair config properties a caller cannot infer', async () => { + const { connectorTypes } = await listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID }, + }) + + const fields = connectorTypes.flatMap((entry) => entry.configFields) + expect(fields.some((field) => field.multi === true)).toBe(true) + expect(fields.some((field) => typeof field.canonicalParamId === 'string')).toBe(true) + expect(fields.every((field) => !Object.hasOwn(field, 'icon'))).toBe(true) + }) + + it('searches connector names case-insensitively', async () => { + const { connectorTypes } = await listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, search: 'noTIon' }, + }) + + expect(connectorTypes.map((entry) => entry.connectorType)).toEqual(['notion']) + }) + + it('answers not found for a workspace the caller cannot reach', async () => { + mocks.loadWorkspace.mockResolvedValue(null) + + await expect( + listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workspace not found' }) + }) + + it('rejects a blank search rather than silently matching everything', async () => { + await expect( + listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, search: ' ' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'search cannot be empty' }) + }) +}) diff --git a/apps/sim/lib/catalog/application/list-tools.ts b/apps/sim/lib/catalog/application/list-tools.ts new file mode 100644 index 00000000000..407d9debe9c --- /dev/null +++ b/apps/sim/lib/catalog/application/list-tools.ts @@ -0,0 +1,76 @@ +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { + loadCatalogWorkspaceContext, + resolveCatalogGate, +} from '@/lib/catalog/application/catalog-context' +import { + type CatalogPage, + matchesCatalogSearch, + normalizeCatalogSearch, + sortCatalogEntries, + takeCatalogPage, +} from '@/lib/catalog/application/catalog-page' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { resolveVisibleToolIds } from '@/lib/catalog/application/tool-scope' +import { type CatalogToolSummary, projectToolSummaryById } from '@/lib/catalog/projection/tool' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { isHosted } from '@/lib/core/config/env-flags' +import type { HostedApiKeySupport } from '@/tools/hosted-api-key' +import { getToolIds } from '@/tools/tool-ids' + +export interface ListCatalogToolsInput { + workspaceId: string + search?: string + hostedApiKey?: HostedApiKeySupport + oauthProvider?: string + sortBy: 'id' | 'name' + sortOrder: V2SortOrder + offset: number + limit: number +} + +export type ListCatalogToolsResult = CatalogPage + +const SORT_FIELDS: Record string> = { + id: (tool) => tool.id, + name: (tool) => tool.name, +} + +/** + * The built-in tools this caller may run in this workspace. + * + * Built-in tools only. A workspace's MCP tools are discovered live per server + * and live on `GET /api/v2/mcp-servers/{mcpServerId}/tools`; its code-backed custom tools + * are a CRUD resource on `GET /api/v2/custom-tools`. Three resources with three + * lifecycles, deliberately not unioned. + */ +export const listCatalogTools = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.listTools, + resolveContext: ({ input }: { input: ListCatalogToolsInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const search = normalizeCatalogSearch(input.search) + const oauthProvider = input.oauthProvider?.trim().toLowerCase() + const gate = await resolveCatalogGate(principal, context) + const visibleToolIds = await resolveVisibleToolIds(gate) + + const summaries: CatalogToolSummary[] = [] + /** `getToolIds()` hands out a frozen array — read it, never reorder it in place. */ + for (const toolId of getToolIds()) { + if (!visibleToolIds.has(toolId)) continue + const summary = projectToolSummaryById(toolId, { hostedKeys: isHosted }) + if (!summary) continue + if (input.hostedApiKey && summary.hostedApiKey !== input.hostedApiKey) continue + if (oauthProvider && summary.oauth?.provider.toLowerCase() !== oauthProvider) continue + if (!matchesCatalogSearch(search, summary.id, summary.name, summary.description)) continue + summaries.push(summary) + } + + return takeCatalogPage( + sortCatalogEntries(summaries, SORT_FIELDS[input.sortBy], input.sortOrder), + input.offset, + input.limit + ) + }, +}) diff --git a/apps/sim/lib/catalog/application/operations.test.ts b/apps/sim/lib/catalog/application/operations.test.ts new file mode 100644 index 00000000000..21e4fa1ae93 --- /dev/null +++ b/apps/sim/lib/catalog/application/operations.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { catalogOperations } from '@/lib/catalog/application/operations' + +/** + * Operation metadata is executable policy, not documentation: it decides which + * principals reach the use case and at what role. Pinning it here makes + * widening any of the six a deliberate edit rather than a side effect. + */ +const EXPECTED_OPERATION_IDS = { + listBlocks: 'catalog.blocks.list', + readBlock: 'catalog.blocks.read', + listTools: 'catalog.tools.list', + readTool: 'catalog.tools.read', + listConnectorTypes: 'catalog.connector_types.list', +} as const + +describe('catalogOperations', () => { + it('declares exactly the five catalog reads under their published ids', () => { + expect(Object.keys(catalogOperations).sort()).toEqual( + Object.keys(EXPECTED_OPERATION_IDS).sort() + ) + for (const [key, id] of Object.entries(EXPECTED_OPERATION_IDS)) { + expect(catalogOperations[key as keyof typeof catalogOperations].id).toBe(id) + } + }) + + it('keeps every catalog read at the read role with workspace keys allowed', () => { + for (const operation of Object.values(catalogOperations)) { + expect(operation.minimumRole, operation.id).toBe('read') + expect(operation.workspaceApiKey, operation.id).toBe('allow') + expect([...operation.principalKinds].sort(), operation.id).toEqual([ + 'personal_api_key', + 'session', + 'workspace_api_key', + ]) + } + }) + + it('admits no delegated principal, because no delegated caller exists yet', () => { + for (const operation of Object.values(catalogOperations)) { + expect(operation.principalKinds, operation.id).not.toContain('delegated') + expect(operation.delegatedServices, operation.id).toBeUndefined() + } + }) + + it('freezes each operation so a caller cannot widen it at runtime', () => { + for (const operation of Object.values(catalogOperations)) { + expect(Object.isFrozen(operation), operation.id).toBe(true) + expect(Object.isFrozen(operation.principalKinds), operation.id).toBe(true) + } + }) +}) diff --git a/apps/sim/lib/catalog/application/operations.ts b/apps/sim/lib/catalog/application/operations.ts new file mode 100644 index 00000000000..19a85eddbfa --- /dev/null +++ b/apps/sim/lib/catalog/application/operations.ts @@ -0,0 +1,48 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +/** + * Semantic operations for reading Sim's code-defined catalogs. + * + * All six share the policy of `credentials.providers.list`: a workspace-scoped + * read at the `read` role, reachable by a workspace API key. That is the exact + * shipped precedent for "a code-defined registry whose availability is evaluated + * per workspace", and these catalogs are the same thing — filtered by the + * workspace's integration allowlist, the organization's revealed preview blocks, + * the deployment's allowlist, and the workspace's own deployed custom blocks. + * + * No `delegated` principal kind: Copilot reads these catalogs through its own + * tools, which share the projection rather than the use case, so adding one + * would widen authorization for a caller that does not exist. + */ +export const catalogOperations = { + listBlocks: defineWorkspaceOperation({ + id: 'catalog.blocks.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + readBlock: defineWorkspaceOperation({ + id: 'catalog.blocks.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + listTools: defineWorkspaceOperation({ + id: 'catalog.tools.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + readTool: defineWorkspaceOperation({ + id: 'catalog.tools.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + listConnectorTypes: defineWorkspaceOperation({ + id: 'catalog.connector_types.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/catalog/application/tool-scope.ts b/apps/sim/lib/catalog/application/tool-scope.ts new file mode 100644 index 00000000000..c7c5a0c344a --- /dev/null +++ b/apps/sim/lib/catalog/application/tool-scope.ts @@ -0,0 +1,31 @@ +import { + type CatalogGate, + isBlockVisibleToCaller, + withCatalogBlockScope, +} from '@/lib/catalog/application/catalog-context' +import { getAllBlocks } from '@/blocks/registry' +import { resolveToolId } from '@/tools/tool-ids' + +/** + * The built-in tools this caller may run in this workspace. + * + * A tool's availability is its owning block's: the permission-group allowlist, + * the preview-reveal state, and the deployment allowlist are all expressed + * against block types, so the catalog derives the tool set from the blocks that + * survive the gate rather than restating those policies against tool ids. + * + * A tool no visible block references is therefore absent, which is also the + * right answer for the handful of internal tools no block exposes — they are + * not caller-invokable, so publishing them would advertise an id that cannot be + * used. + */ +export function resolveVisibleToolIds(gate: CatalogGate): Promise> { + return withCatalogBlockScope(gate, async () => { + const toolIds = new Set() + for (const block of getAllBlocks()) { + if (!isBlockVisibleToCaller(block, gate)) continue + for (const toolId of block.tools?.access ?? []) toolIds.add(resolveToolId(toolId)) + } + return toolIds + }) +} diff --git a/apps/sim/lib/catalog/projection/block-detail.ts b/apps/sim/lib/catalog/projection/block-detail.ts new file mode 100644 index 00000000000..1938d006fa2 --- /dev/null +++ b/apps/sim/lib/catalog/projection/block-detail.ts @@ -0,0 +1,451 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + actionSubBlocks, + type CatalogBlockSummary, + projectBlockSummary, + resolveOperationIds, +} from '@/lib/catalog/projection/block-summary' +import { + type CatalogSubBlock, + normalizeCondition, + projectSubBlock, +} from '@/lib/catalog/projection/subblock' +import { + type CatalogDeployment, + type CatalogToolDetail, + type CatalogToolOutput, + type CatalogToolSummary, + projectToolDetail, +} from '@/lib/catalog/projection/tool' +import { isCustomBlockType } from '@/blocks/custom/build-config' +import { type BlockConfig, isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types' +import { getTrigger, isTriggerValid } from '@/triggers' +import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' + +const logger = createLogger('CatalogBlockProjection') + +/** + * Surface-neutral projection of one block's full authoring shape: its + * configuration fields, its per-operation inputs and outputs, its tools, and + * its triggers. + * + * Extracted from the Copilot `get_blocks_metadata` tool so the public catalog + * and the agent describe a block identically. Tool data comes from + * `@/tools/metadata` + `@/tools/metadata-outputs`; reading `@/tools/registry` + * for it is what used to make that tool's module graph 6,754 modules. + */ + +/** A block-level input definition, as declared on `BlockConfig.inputs`. */ +export interface CatalogInputDefinition { + type: string + description?: string + /** JSON-Schema-shaped structure for object and array params. Arbitrarily nested. */ + schema?: unknown +} + +/** A declared output of a block. */ +export interface CatalogBlockOutput { + type: string + description?: string +} + +/** One operation a block exposes, resolved to the tool that performs it. */ +export interface CatalogBlockOperation { + toolId?: string + toolName?: string + description?: string + /** Tool params plus operation-scoped block inputs, minus anything the block supplies itself. */ + inputs: Record + outputs: Record + /** The configuration fields that appear when this operation is selected. */ + inputSchema: CatalogSubBlock[] +} + +/** One trigger a block can run on. */ +export interface CatalogBlockTrigger { + id: string + outputs: Record + configFields: Record +} + +/** + * Projects a trigger's declared outputs. + * + * `TriggerOutput` is an open, self-nesting shape whose extra keys are nested + * outputs. Only the top level is published, with the same `type`/`description` + * projection every other output family gets — a caller reads a trigger's real + * payload from a run, not from a schema that cannot describe it faithfully. + */ +function projectTriggerOutputs( + outputs: Record | undefined +): Record { + const projected: Record = {} + for (const [key, definition] of Object.entries(outputs ?? {})) { + if (!definition || typeof definition !== 'object') continue + const entry: CatalogBlockOutput = { type: String(definition.type ?? 'any') } + if (typeof definition.description === 'string') entry.description = definition.description + projected[key] = entry + } + return projected +} + +/** One configurable field of a trigger. */ +export interface CatalogTriggerConfigField { + type: string + required: boolean + title?: string + description?: string + placeholder?: string + default?: unknown + options?: { id: string; label: string }[] + condition?: CatalogSubBlock['condition'] +} + +/** The full authoring shape of one block. */ +export interface CatalogBlockDetail extends CatalogBlockSummary { + bestPractices?: string + /** Configuration fields that apply regardless of the selected operation. */ + inputSchema: CatalogSubBlock[] + /** Configuration fields keyed by the operation that reveals them. */ + operationInputSchema: Record + /** Block-level input definitions, keyed by param name. */ + inputDefinitions: Record + operations: Record + tools: CatalogToolDetail[] + triggers: CatalogBlockTrigger[] + outputs: Record +} + +/** Per-surface inputs to a block detail projection. */ +export interface BlockDetailProjectionOptions { + /** The deployment whose hosted-key availability the projected tools report. */ + deployment: CatalogDeployment + /** How this surface renders a tool's description. Defaults to the tool's own text. */ + describeTool?: (tool: CatalogToolSummary) => string +} + +/** + * Param keys a block supplies itself rather than accepting from an author. + * + * `hideFromCopilot` marks server-only lifecycle configuration — a webhook's + * stored secret, a poller's cursor — that no authoring surface should publish, + * whether the author is an agent or a human writing against the API. + */ +export function hiddenParamKeys(block: BlockConfig): Set { + const hidden = new Set() + for (const subBlock of block.subBlocks ?? []) { + if (!subBlock.hideFromCopilot) continue + if (subBlock.id) hidden.add(subBlock.id) + if (subBlock.canonicalParamId) hidden.add(subBlock.canonicalParamId) + } + return hidden +} + +/** Sub-blocks an authoring surface may configure: action fields, minus the hidden ones. */ +function authorableSubBlocks(block: BlockConfig): SubBlockConfig[] { + return actionSubBlocks(block).filter((subBlock) => !subBlock.hideFromCopilot) +} + +/** Whether a condition gates its field on a specific operation being selected. */ +function operationGate(subBlock: SubBlockConfig): { values: string[] } | undefined { + const condition = normalizeCondition(subBlock.condition) + if (!condition || condition.field !== 'operation' || condition.not) return undefined + if (condition.value === undefined) return undefined + const values = Array.isArray(condition.value) ? condition.value : [condition.value] + return { values: values.map((value) => String(value)) } +} + +/** + * Splits a block's fields into the ones that always apply and the ones each + * operation reveals. + * + * A field gated on `operation` belongs to every operation it names, so a caller + * reading one operation sees exactly the fields that operation needs. Ungated + * fields take their description from the block's own input definitions when one + * is declared, which is where the authored prose lives. + */ +export function splitFieldsByOperation( + subBlocks: SubBlockConfig[], + inputDefinitions: Record = {} +): { + commonFields: CatalogSubBlock[] + operationFields: Record +} { + const commonFields: CatalogSubBlock[] = [] + const operationFields: Record = {} + + for (const subBlock of subBlocks) { + const projected = projectSubBlock(subBlock) + const gate = operationGate(subBlock) + + if (gate) { + for (const operationId of gate.values) { + operationFields[operationId] ??= [] + operationFields[operationId].push(projected) + } + continue + } + + for (const key of [subBlock.id, subBlock.canonicalParamId]) { + if (!key) continue + const definition = inputDefinitions[key] + if (definition && typeof definition.description === 'string') { + projected.description = definition.description + break + } + } + commonFields.push(projected) + } + + return { commonFields, operationFields } +} + +/** Block-level inputs: those not scoped to a single operation and not block-supplied. */ +export function computeBlockLevelInputs( + block: BlockConfig, + hidden = hiddenParamKeys(block) +): Record { + const subBlocksByParamKey = new Map() + for (const subBlock of authorableSubBlocks(block)) { + for (const key of [subBlock.id, subBlock.canonicalParamId]) { + if (!key) continue + const bucket = subBlocksByParamKey.get(key) + if (bucket) bucket.push(subBlock) + else subBlocksByParamKey.set(key, [subBlock]) + } + } + + const blockInputs: Record = {} + for (const [key, definition] of Object.entries(block.inputs ?? {})) { + if (hidden.has(key)) continue + const gated = (subBlocksByParamKey.get(key) ?? []).some((subBlock) => + Boolean(operationGate(subBlock)) + ) + if (!gated) blockInputs[key] = definition + } + return blockInputs +} + +/** Input definitions scoped to one operation, keyed by operation id. */ +export function computeOperationLevelInputs( + block: BlockConfig +): Record> { + const inputs = block.inputs ?? {} + const operationInputs: Record> = {} + + for (const subBlock of authorableSubBlocks(block)) { + const gate = operationGate(subBlock) + if (!gate) continue + const keys = [subBlock.canonicalParamId, subBlock.id].filter( + (key): key is string => typeof key === 'string' + ) + for (const key of keys) { + if (!(key in inputs)) continue + for (const operationId of gate.values) { + operationInputs[operationId] ??= {} + operationInputs[operationId][key] = inputs[key] + } + } + } + + return operationInputs +} + +/** + * The tool a block runs for one operation. + * + * The selector is an authored function invoked with only `{ operation }`, so a + * selector that reads another param can throw. That is a block-authoring + * problem rather than a caller's, so it degrades to "no tool resolved" and is + * logged, exactly as it did before this projection was extracted. + */ +export function resolveToolIdForOperation( + block: BlockConfig, + operationId: string +): string | undefined { + const selector = block.tools?.config?.tool + if (typeof selector !== 'function') return undefined + try { + const toolId = selector({ operation: operationId }) + return typeof toolId === 'string' ? toolId : undefined + } catch (error) { + logger.warn('Failed to resolve tool ID for operation', { + blockType: block.type, + operationId, + error: toError(error).message, + }) + return undefined + } +} + +/** Projects a block's declared outputs, dropping the ones hidden from display. */ +export function projectBlockOutputs( + outputs: BlockConfig['outputs'] | undefined +): Record { + const projected: Record = {} + for (const [key, definition] of Object.entries(outputs ?? {})) { + if (isHiddenFromDisplay(definition)) continue + if (typeof definition === 'string') { + projected[key] = { type: definition } + continue + } + if (!definition || typeof definition !== 'object') continue + const entry: CatalogBlockOutput = { type: String(definition.type ?? 'any') } + if ('description' in definition && typeof definition.description === 'string') { + entry.description = definition.description + } + projected[key] = entry + } + return projected +} + +/** + * Projects the triggers a block supports, with each trigger's configurable + * fields. + * + * Only ids backed by a `TRIGGER_REGISTRY` entry are projected, because only + * those declare outputs and config fields. The universal entry points name + * theirs by kind instead — `start_trigger` declares `chat`, `manual` and `api`, + * none of which is a registered trigger definition — so those blocks + * legitimately publish an empty `triggers` array while `triggerCapable` and + * `triggerIds` on the summary carry what they can actually start. That is a + * routine registry shape rather than an authoring defect, so it logs at `debug`: + * at `warn` the five core trigger blocks emitted seven warnings on every sweep. + */ +export function projectBlockTriggers(block: BlockConfig): CatalogBlockTrigger[] { + const triggers: CatalogBlockTrigger[] = [] + for (const triggerId of block.triggers?.available ?? []) { + if (!isTriggerValid(triggerId)) { + logger.debug('Block names a trigger kind with no registered definition', { + blockType: block.type, + triggerId, + }) + continue + } + const trigger = getTrigger(triggerId) + const configFields: Record = {} + + for (const subBlock of trigger.subBlocks) { + const isTriggerField = subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' + if (!isTriggerField || SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)) continue + + const field: CatalogTriggerConfigField = { + type: subBlock.type, + required: Boolean(subBlock.required), + } + if (subBlock.title) field.title = subBlock.title + if (subBlock.description) field.description = subBlock.description + if (subBlock.placeholder) field.placeholder = subBlock.placeholder + if (subBlock.defaultValue !== undefined) field.default = subBlock.defaultValue + if (Array.isArray(subBlock.options)) { + field.options = subBlock.options.map((option) => ({ + id: option.id, + label: option.label || option.id, + })) + } + const condition = normalizeCondition(subBlock.condition) + if (condition) field.condition = condition + + configFields[subBlock.id] = field + } + + triggers.push({ + id: triggerId, + outputs: projectTriggerOutputs(trigger.outputs), + configFields, + }) + } + return triggers +} + +/** + * A custom (deploy-as-block) block's detail. + * + * A custom block runs a bound workflow through an internal executor, so it has + * no operations and no author-visible tools — only its own input fields and the + * outputs the bound workflow produces. + */ +function projectCustomBlockDetail(block: BlockConfig): CatalogBlockDetail { + const visibleFields = (block.subBlocks ?? []).filter( + (subBlock) => !subBlock.hidden && !subBlock.hideFromCopilot + ) + return { + ...projectBlockSummary(block), + inputSchema: visibleFields.map(projectSubBlock), + operationInputSchema: {}, + inputDefinitions: {}, + operations: {}, + tools: [], + triggers: [], + outputs: projectBlockOutputs(block.outputs), + ...(block.bestPractices !== undefined ? { bestPractices: block.bestPractices } : {}), + } +} + +/** Projects one block config to its full catalog detail. */ +export function projectBlockDetail( + block: BlockConfig, + options: BlockDetailProjectionOptions +): CatalogBlockDetail { + if (isCustomBlockType(block.type)) return projectCustomBlockDetail(block) + + const describeTool = options.describeTool ?? ((tool: CatalogToolSummary) => tool.description) + const hidden = hiddenParamKeys(block) + const inputDefinitions = computeBlockLevelInputs(block, hidden) + const { commonFields, operationFields } = splitFieldsByOperation( + authorableSubBlocks(block), + inputDefinitions + ) + + const tools: CatalogToolDetail[] = [] + for (const toolId of block.tools?.access ?? []) { + const tool = projectToolDetail(toolId, options.deployment) + tools.push( + tool + ? { ...tool, description: describeTool(tool) } + : { + id: toolId, + name: toolId, + description: '', + hostedApiKey: 'none', + params: {}, + outputs: {}, + } + ) + } + + const operationInputs = computeOperationLevelInputs(block) + const operations: Record = {} + for (const operationId of resolveOperationIds(block)) { + const toolId = resolveToolIdForOperation(block, operationId) + const tool = toolId ? projectToolDetail(toolId, options.deployment) : undefined + + const inputs: CatalogBlockOperation['inputs'] = {} + for (const [key, param] of Object.entries(tool?.params ?? {})) { + if (key in inputDefinitions || hidden.has(key)) continue + inputs[key] = param + } + Object.assign(inputs, operationInputs[operationId] ?? {}) + + operations[operationId] = { + inputs, + outputs: tool?.outputs ?? {}, + inputSchema: operationFields[operationId] ?? [], + ...(toolId !== undefined ? { toolId } : {}), + ...(tool ? { toolName: tool.name, description: describeTool(tool) } : {}), + } + } + + return { + ...projectBlockSummary(block), + inputSchema: commonFields, + operationInputSchema: operationFields, + inputDefinitions, + operations, + tools, + triggers: projectBlockTriggers(block), + outputs: projectBlockOutputs(block.outputs), + ...(block.bestPractices !== undefined ? { bestPractices: block.bestPractices } : {}), + } +} diff --git a/apps/sim/lib/catalog/projection/block-summary.ts b/apps/sim/lib/catalog/projection/block-summary.ts new file mode 100644 index 00000000000..d562d95e8e6 --- /dev/null +++ b/apps/sim/lib/catalog/projection/block-summary.ts @@ -0,0 +1,131 @@ +import { normalizeCondition } from '@/lib/catalog/projection/subblock' +import { isCustomBlockType } from '@/blocks/custom/build-config' +import { getBlockMeta } from '@/blocks/registry' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' + +/** + * Where a block comes from: the code registry, or a workflow this workspace + * deployed as a block. + */ +export type CatalogBlockSource = 'builtin' | 'custom' + +/** Lifecycle state of a shipped block. */ +export interface CatalogBlockSunset { + status: 'legacy' | 'deprecated' + replacedBy?: string +} + +/** + * List-shaped view of a block: everything needed to decide whether to place it, + * and nothing that requires resolving its tools. + * + * `toolIds` and `operationIds` are identifiers only. Resolving them is a + * `GET /api/v2/tools/{toolId}` or `GET /api/v2/blocks/{blockId}` call, which is + * what keeps a 300-block list under a page's worth of bytes. + */ +export interface CatalogBlockSummary { + id: string + name: string + description: string + longDescription?: string + category: string + integrationType?: string + source: CatalogBlockSource + authMode?: string + triggerAllowed: boolean + /** Whether the block can start a workflow — as a trigger block or in trigger mode. */ + triggerCapable: boolean + triggerIds: string[] + toolIds: string[] + operationIds: string[] + preview: boolean + sunset?: CatalogBlockSunset + docsLink?: string + tags: string[] +} + +/** + * Whether a block can start a workflow. + * + * The three-way predicate is the canonical one: a block in the `triggers` + * category, a block that declares `triggerAllowed`, or a block carrying a + * sub-block that only renders in trigger mode. Single-sourced here because the + * public catalog's `capability=trigger` filter and the Copilot + * `get_trigger_blocks` tool must agree on it. + */ +export function isTriggerCapableBlock(block: BlockConfig): boolean { + if (block.category === 'triggers') return true + if (block.triggerAllowed === true) return true + return block.subBlocks?.some((subBlock) => subBlock.mode === 'trigger') ?? false +} + +/** Sub-blocks that configure the block's action, excluding its trigger-mode fields. */ +export function actionSubBlocks(block: BlockConfig): SubBlockConfig[] { + if (!Array.isArray(block.subBlocks)) return [] + return block.subBlocks.filter( + (subBlock) => subBlock.mode !== 'trigger' && subBlock.mode !== 'trigger-advanced' + ) +} + +/** + * The operations a block exposes, in the order its operation dropdown declares + * them. + * + * Falls back to the operations named by its sub-blocks' `operation` conditions + * for blocks that gate fields on an operation without offering a dropdown. + */ +export function resolveOperationIds(block: BlockConfig): string[] { + const operationField = block.subBlocks?.find((subBlock) => subBlock.id === 'operation') + if (operationField && Array.isArray(operationField.options)) { + const ids = operationField.options.map((option) => option.id).filter(Boolean) + if (ids.length > 0) return ids + } + + const derived: string[] = [] + for (const subBlock of actionSubBlocks(block)) { + const condition = normalizeCondition(subBlock.condition) + if (!condition || condition.field !== 'operation' || condition.not) continue + if (condition.value === undefined) continue + for (const value of Array.isArray(condition.value) ? condition.value : [condition.value]) { + const id = String(value) + if (!derived.includes(id)) derived.push(id) + } + } + return derived +} + +/** + * Projects one block config down to its catalog summary. + * + * Every array published here is a copy. `block.triggers.available`, + * `block.tools.access`, and a meta's `tags` are the registry's own arrays, live + * for the whole process, so handing one out would put mutable registry state one + * careless consumer away from corrupting every later request. + */ +export function projectBlockSummary(block: BlockConfig): CatalogBlockSummary { + const summary: CatalogBlockSummary = { + id: block.type, + name: block.name, + description: block.description, + category: block.category, + source: isCustomBlockType(block.type) ? 'custom' : 'builtin', + triggerAllowed: block.triggerAllowed === true, + triggerCapable: isTriggerCapableBlock(block), + triggerIds: [...(block.triggers?.available ?? [])], + toolIds: [...(block.tools?.access ?? [])], + operationIds: resolveOperationIds(block), + preview: block.preview === true, + tags: [...(getBlockMeta(block.type)?.tags ?? [])], + } + + if (block.longDescription !== undefined) summary.longDescription = block.longDescription + if (block.integrationType !== undefined) summary.integrationType = block.integrationType + if (block.authMode !== undefined) summary.authMode = block.authMode + if (block.docsLink !== undefined) summary.docsLink = block.docsLink + if (block.sunset !== undefined) { + summary.sunset = { status: block.sunset.status } + if (block.sunset.replacedBy !== undefined) summary.sunset.replacedBy = block.sunset.replacedBy + } + + return summary +} diff --git a/apps/sim/lib/catalog/projection/catalog-sweep.test.ts b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts new file mode 100644 index 00000000000..dbc538ed6b1 --- /dev/null +++ b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts @@ -0,0 +1,229 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +/** + * Sweeps every code-defined catalog through its projection and its published + * response schema. + * + * v2 `.parse`s a response on the way out, so a projection that emits a field the + * schema does not declare, or that throws while resolving one, is a 500 on a + * perfectly well-formed request — the highest-severity defect class on the + * surface. These sweeps make that a CI failure at authoring time instead. + * + * Three specific hazards are pinned here rather than defended against at + * runtime, because each is a registry-authoring bug that should fail loudly: + * + * 1. A sub-block `condition` declared as `(values?) => …` is called with no + * arguments. One that dereferences `values` unguarded throws. + * 2. A projected field the response schema does not declare is silently + * stripped by Zod on the way out, so the round-trip comparison below is what + * detects it. + * 3. `getToolIds()` hands out a frozen array, so an in-place `sort()` throws. + */ +vi.unmock('@/blocks/registry') + +import { + v2BlockDetailSchema, + v2BlockSummarySchema, + v2ConnectorTypeSchema, + v2ToolDetailSchema, + v2ToolSummarySchema, +} from '@/lib/api/contracts/v2/catalog' +import { projectBlockDetail } from '@/lib/catalog/projection/block-detail' +import { projectBlockSummary } from '@/lib/catalog/projection/block-summary' +import { projectConnectorType } from '@/lib/catalog/projection/connector-type' +import type { CatalogDeployment } from '@/lib/catalog/projection/tool' +import { projectToolDetail, projectToolSummaryById } from '@/lib/catalog/projection/tool' +import { buildCustomBlockConfig } from '@/blocks/custom/build-config' +import { getBlockRegistry } from '@/blocks/registry' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { getToolIds } from '@/tools/tool-ids' + +/** Hosted deployment: the state under which every declared hosted key is published. */ +const HOSTED: CatalogDeployment = { hostedKeys: true } + +/** + * Parses a projection against its published schema and asserts nothing was + * stripped. + * + * `.parse` alone is not enough: a response schema is not deeply strict, so an + * undeclared field passes validation and is quietly dropped from the body the + * caller receives. Comparing the parsed output against the JSON round-trip of + * the input catches that at every nesting level. + */ +function expectPublishedIntact( + schema: { parse: (value: unknown) => unknown }, + projection: unknown, + label: string +): void { + /** + * The exact bytes the route would send, read back. Not a deep clone: a v2 + * response is serialized by `NextResponse.json`, so this is what the caller + * actually receives, and comparing the parsed schema output against it is + * what makes a stripped field visible. + */ + const wire = JSON.stringify(projection) + const serialized = JSON.parse(wire) + let parsed: unknown + try { + parsed = schema.parse(serialized) + } catch (error) { + throw new Error(`${label} failed its response schema: ${(error as Error).message}`) + } + expect(parsed, `${label} projects fields its response schema does not declare`).toEqual( + serialized + ) +} + +/** + * Every value a projection produces must survive JSON, so no closure or React + * component leaks out of a registry entry. + * + * Cycles are tracked against the current ancestor chain rather than a global + * seen-set: a projection legitimately shares one field object across several + * operations, and a seen-set reports that ordinary reuse as a cycle. + */ +function expectSerializable(projection: unknown, label: string): void { + const ancestors = new Set() + const walk = (value: unknown, path: string): void => { + if (typeof value === 'function') throw new Error(`${label} leaks a function at ${path}`) + if (!value || typeof value !== 'object') return + if (ancestors.has(value)) throw new Error(`${label} is cyclic at ${path}`) + ancestors.add(value) + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}[${index}]`)) + } else { + for (const [key, item] of Object.entries(value)) walk(item, `${path}.${key}`) + } + ancestors.delete(value) + } + walk(projection, label) +} + +describe('block catalog projection sweep', () => { + const blocks = Object.values(getBlockRegistry()) + + it('has a non-empty registry to sweep', () => { + expect(blocks.length).toBeGreaterThan(100) + }) + + it('projects every registered block to a publishable summary', () => { + for (const block of blocks) { + const summary = projectBlockSummary(block) + expectSerializable(summary, `block summary ${block.type}`) + expectPublishedIntact(v2BlockSummarySchema, summary, `block summary ${block.type}`) + } + }) + + it('projects every registered block to a publishable detail', () => { + for (const block of blocks) { + const detail = projectBlockDetail(block, { deployment: HOSTED }) + expectSerializable(detail, `block detail ${block.type}`) + expectPublishedIntact(v2BlockDetailSchema, detail, `block detail ${block.type}`) + } + }) +}) + +/** + * Custom (deploy-as-block) blocks, which the registry sweep above cannot reach. + * + * `projectCustomBlockDetail` is a separate branch with its own field set — and + * the only one whose `inputSchema` includes `mode: 'trigger'` sub-blocks — yet + * it is caller-reachable through `GET /api/v2/blocks/custom_block_*`. Built from + * the same `buildCustomBlockConfig` the overlay uses, so a change to the synthesized + * shape shows up here rather than as a 500 on a well-formed request. + */ +const CUSTOM_BLOCK_FIXTURES = [ + { + label: 'curated outputs and every input field kind', + row: { + type: 'custom_block_reports', + name: 'Reports', + description: 'Run the reports workflow.', + workflowId: 'workflow-1', + workspaceName: 'Analytics', + exposedOutputs: [{ blockId: 'block-1', path: 'result.summary', name: 'summary' }], + }, + fields: [ + { id: 'field-1', name: 'Region', type: 'string', required: true, placeholder: 'us-east' }, + { id: 'field-2', name: 'Rows', type: 'number', description: 'How many rows.' }, + { id: 'field-3', name: 'Dry run', type: 'boolean' }, + { id: 'field-4', name: 'Filters', type: 'object' }, + { id: 'field-5', name: 'Attachments', type: 'file[]' }, + ], + }, + { + label: 'no curated outputs and no input fields', + row: { + type: 'custom_block_empty', + name: 'Empty', + description: 'A block with nothing curated.', + workflowId: 'workflow-2', + }, + fields: [], + }, +] as const + +describe('custom block catalog projection sweep', () => { + const icon = (() => null) as unknown as Parameters[2]['icon'] + + it.each(CUSTOM_BLOCK_FIXTURES)( + 'projects a custom block with $label to a publishable summary and detail', + ({ row, fields }) => { + const block = buildCustomBlockConfig(row, [...fields], { icon }) + + const summary = projectBlockSummary(block) + expect(summary.source).toBe('custom') + expectSerializable(summary, `custom block summary ${row.type}`) + expectPublishedIntact(v2BlockSummarySchema, summary, `custom block summary ${row.type}`) + + const detail = projectBlockDetail(block, { deployment: HOSTED }) + expectSerializable(detail, `custom block detail ${row.type}`) + expectPublishedIntact(v2BlockDetailSchema, detail, `custom block detail ${row.type}`) + + /** A custom block runs a bound workflow, so it publishes no operations or tools. */ + expect(detail.operations).toEqual({}) + expect(detail.tools).toEqual([]) + /** Hidden wiring sub-blocks stay out of the published shape. */ + expect(detail.inputSchema.map((field) => field.id)).toEqual(fields.map((field) => field.id)) + } + ) +}) + +describe('tool catalog projection sweep', () => { + const toolIds = getToolIds() + + it('hands out a frozen id list, so a caller must copy before sorting', () => { + expect(Object.isFrozen(toolIds)).toBe(true) + expect(() => (toolIds as string[]).sort()).toThrow(TypeError) + expect(() => [...toolIds].sort()).not.toThrow() + }) + + it('projects every registered tool to a publishable summary and detail', () => { + expect(toolIds.length).toBeGreaterThan(1000) + for (const toolId of toolIds) { + const summary = projectToolSummaryById(toolId, HOSTED) + expect(summary, `tool ${toolId} has no metadata`).toBeDefined() + expectPublishedIntact(v2ToolSummarySchema, summary, `tool summary ${toolId}`) + + const detail = projectToolDetail(toolId, HOSTED) + expect(detail, `tool ${toolId} has no detail`).toBeDefined() + expectSerializable(detail, `tool detail ${toolId}`) + expectPublishedIntact(v2ToolDetailSchema, detail, `tool detail ${toolId}`) + } + }) +}) + +describe('connector-type catalog projection sweep', () => { + it('projects every registered connector type to a publishable entry', () => { + const entries = Object.entries(CONNECTOR_META_REGISTRY) + expect(entries.length).toBeGreaterThan(10) + for (const [connectorType, meta] of entries) { + const projected = projectConnectorType(connectorType, meta) + expectSerializable(projected, `connector ${connectorType}`) + expectPublishedIntact(v2ConnectorTypeSchema, projected, `connector ${connectorType}`) + } + }) +}) diff --git a/apps/sim/lib/catalog/projection/connector-type.ts b/apps/sim/lib/catalog/projection/connector-type.ts new file mode 100644 index 00000000000..116dfaf92c6 --- /dev/null +++ b/apps/sim/lib/catalog/projection/connector-type.ts @@ -0,0 +1,145 @@ +import type { + ConnectorConfigField, + ConnectorMeta, + ConnectorTagDefinition, +} from '@/connectors/types' + +/** + * Surface-neutral projection of a knowledge-base connector type. + * + * Reads `@/connectors/registry` — the client-safe meta registry — never + * `@/connectors/registry.server`, whose `listDocuments`/`getDocument`/ + * `validateConfig` closures carry `undici` and the server-only input + * validators. The projection also drops `icon`, which is a React component. + */ + +/** How a connector authenticates against its source. */ +export type CatalogConnectorAuth = + | { mode: 'oauth'; provider: string; requiredScopes?: string[] } + | { mode: 'apiKey'; label?: string; placeholder?: string; optional: boolean } + +/** + * One field of a connector's `sourceConfig`. + * + * Two properties decide how a caller sends the value and are not inferable from + * the rest of the field: + * + * - `multi: true` — the persisted `sourceConfig` value is a `string[]`, not a + * `string`. A `selector` field renders a multi-select picker; a `short-input` + * accepts a comma-separated list. Either way the stored value is an array. + * - `canonicalParamId` — links a `selector` field and a manual `short-input` + * field that resolve to the SAME `sourceConfig` key. Send exactly one of the + * pair, keyed by `canonicalParamId` rather than by the field's own `id`. + * `mode` says which half a field is: `basic` is the picker, `advanced` the + * manual entry. + */ +export interface CatalogConnectorConfigField { + id: string + title: string + type: 'short-input' | 'dropdown' | 'selector' + placeholder?: string + required?: boolean + description?: string + options?: { label: string; id: string }[] + /** Names the picker a `selector` field renders. Its options are fetched per workspace. */ + selectorKey?: string + mimeType?: string + dependsOn?: string[] | { all?: string[]; any?: string[] } + mode?: 'basic' | 'advanced' + canonicalParamId?: string + multi?: boolean +} + +/** A tag slot a connector populates on the documents it syncs. */ +export interface CatalogConnectorTagDefinition { + id: string + displayName: string + fieldType: 'text' | 'number' | 'date' | 'boolean' +} + +/** A connector type, as a caller configuring one needs to see it. */ +export interface CatalogConnectorType { + /** Registry key — the exact `connectorType` value to send when creating a connector. */ + connectorType: string + name: string + description: string + version: string + auth: CatalogConnectorAuth + configFields: CatalogConnectorConfigField[] + supportsIncrementalSync: boolean + tagDefinitions: CatalogConnectorTagDefinition[] +} + +function projectAuth(auth: ConnectorMeta['auth']): CatalogConnectorAuth { + if (auth.mode === 'oauth') { + return { + mode: 'oauth', + provider: auth.provider, + ...(auth.requiredScopes !== undefined ? { requiredScopes: [...auth.requiredScopes] } : {}), + } + } + return { + mode: 'apiKey', + optional: auth.optional === true, + ...(auth.label !== undefined ? { label: auth.label } : {}), + ...(auth.placeholder !== undefined ? { placeholder: auth.placeholder } : {}), + } +} + +/** + * Copies a `dependsOn` hint. + * + * The connector registry's arrays live for the whole process, so publishing one + * by reference would hand every caller a mutable handle on shared state. The + * neighbouring `options` and `tags` projections copy for the same reason. + */ +function copyDependsOn( + dependsOn: NonNullable +): NonNullable { + if (Array.isArray(dependsOn)) return [...dependsOn] + const copied: { all?: string[]; any?: string[] } = {} + if (dependsOn.all) copied.all = [...dependsOn.all] + if (dependsOn.any) copied.any = [...dependsOn.any] + return copied +} + +function projectConfigField(field: ConnectorConfigField): CatalogConnectorConfigField { + const projected: CatalogConnectorConfigField = { + id: field.id, + title: field.title, + type: field.type, + } + if (field.placeholder !== undefined) projected.placeholder = field.placeholder + if (field.required !== undefined) projected.required = field.required + if (field.description !== undefined) projected.description = field.description + if (field.options !== undefined) + projected.options = field.options.map((option) => ({ ...option })) + if (field.selectorKey !== undefined) projected.selectorKey = field.selectorKey + if (field.mimeType !== undefined) projected.mimeType = field.mimeType + if (field.dependsOn !== undefined) projected.dependsOn = copyDependsOn(field.dependsOn) + if (field.mode !== undefined) projected.mode = field.mode + if (field.canonicalParamId !== undefined) projected.canonicalParamId = field.canonicalParamId + if (field.multi !== undefined) projected.multi = field.multi + return projected +} + +function projectTagDefinition(tag: ConnectorTagDefinition): CatalogConnectorTagDefinition { + return { id: tag.id, displayName: tag.displayName, fieldType: tag.fieldType } +} + +/** Projects one connector meta to its catalog entry. */ +export function projectConnectorType( + connectorType: string, + meta: ConnectorMeta +): CatalogConnectorType { + return { + connectorType, + name: meta.name, + description: meta.description, + version: meta.version, + auth: projectAuth(meta.auth), + configFields: meta.configFields.map(projectConfigField), + supportsIncrementalSync: meta.supportsIncrementalSync === true, + tagDefinitions: (meta.tagDefinitions ?? []).map(projectTagDefinition), + } +} diff --git a/apps/sim/lib/catalog/projection/projection-invariants.test.ts b/apps/sim/lib/catalog/projection/projection-invariants.test.ts new file mode 100644 index 00000000000..044235e0781 --- /dev/null +++ b/apps/sim/lib/catalog/projection/projection-invariants.test.ts @@ -0,0 +1,185 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Invariants of the projection layer that no schema can express. + * + * Each one has been a real defect: a projection handing out the registry's own + * arrays, a hosted-key answer that ignored the deployment, an options function + * that could leave a process-global store stubbed, and a routine registry shape + * logged as a warning on every sweep. + */ +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => mockLogger, + logger: mockLogger, + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, + setRequestTraceId: () => undefined, +})) + +vi.mock('@/tools/metadata', () => ({ + getToolMetadata: (toolId: string) => + toolId === 'hosted_tool' + ? { id: 'hosted_tool', name: 'Hosted', description: 'Hosted.', hostedApiKey: 'always' } + : undefined, +})) +vi.mock('@/tools/metadata-outputs', () => ({ getToolOutputsMetadata: () => ({}) })) +vi.mock('@/tools/tool-ids', () => ({ resolveToolId: (toolId: string) => toolId })) +vi.mock('@/blocks/registry', () => ({ getBlockMeta: () => ({ tags: ['messaging'] }) })) + +import { projectBlockTriggers } from '@/lib/catalog/projection/block-detail' +import { projectBlockSummary } from '@/lib/catalog/projection/block-summary' +import { projectConnectorType } from '@/lib/catalog/projection/connector-type' +import { + AsyncOptionsFunctionError, + projectSubBlock, + resolveSubBlockOptions, +} from '@/lib/catalog/projection/subblock' +import { projectToolDetail } from '@/lib/catalog/projection/tool' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import type { ConnectorMeta } from '@/connectors/types' + +function block(overrides: Partial & { type: string }): BlockConfig { + return { + name: overrides.type, + description: 'A block.', + category: 'tools', + bgColor: '#000000', + icon: (() => null) as unknown as BlockConfig['icon'], + subBlocks: [], + tools: { access: [] }, + inputs: {}, + outputs: {}, + ...overrides, + } as BlockConfig +} + +describe('hosted-key answers follow the deployment', () => { + it('publishes a declared hosted key on a hosted deployment', () => { + expect(projectToolDetail('hosted_tool', { hostedKeys: true })?.hostedApiKey).toBe('always') + }) + + /** + * `injectHostedKeyIfNeeded` returns early on `!isHosted`, so a self-hosted + * deployment supplies no key at all. Reporting `always` there tells a caller + * they need no key of their own when they do. + */ + it('reports none where the deployment supplies no hosted keys', () => { + expect(projectToolDetail('hosted_tool', { hostedKeys: false })?.hostedApiKey).toBe('none') + }) +}) + +describe('projections never hand out registry state', () => { + it('copies the arrays a block summary publishes', () => { + const config = block({ + type: 'slack', + triggers: { enabled: true, available: ['slack_webhook'] }, + tools: { access: ['slack_message'] }, + }) + + const summary = projectBlockSummary(config) + summary.triggerIds.push('injected') + summary.toolIds.push('injected') + summary.tags.push('injected') + + expect(config.triggers?.available).toEqual(['slack_webhook']) + expect(config.tools?.access).toEqual(['slack_message']) + }) + + it('copies the arrays a sub-block publishes', () => { + const subBlock: SubBlockConfig = { + id: 'files', + type: 'file-upload', + requiredScopes: ['drive.readonly'], + columns: ['name'], + dependsOn: ['folderId'], + } + + const projected = projectSubBlock(subBlock) + ;(projected.requiredScopes as string[]).push('injected') + ;(projected.columns as string[]).push('injected') + ;(projected.dependsOn as string[]).push('injected') + + expect(subBlock.requiredScopes).toEqual(['drive.readonly']) + expect(subBlock.columns).toEqual(['name']) + expect(subBlock.dependsOn).toEqual(['folderId']) + }) + + it('copies a connector config field’s dependsOn, in both of its shapes', () => { + const meta = { + name: 'Drive', + description: 'Sync Drive.', + auth: { type: 'oauth', providerId: 'google-drive', scopes: ['drive.readonly'] }, + configFields: [ + { id: 'folderId', title: 'Folder', type: 'text', dependsOn: ['accountId'] }, + { id: 'fileId', title: 'File', type: 'text', dependsOn: { all: ['folderId'] } }, + ], + supportsIncrementalSync: true, + tagDefinitions: [], + } as unknown as ConnectorMeta + + const projected = projectConnectorType('google_drive', meta) + ;(projected.configFields[0].dependsOn as string[]).push('injected') + ;((projected.configFields[1].dependsOn as { all: string[] }).all as string[]).push('injected') + + expect(meta.configFields[0].dependsOn).toEqual(['accountId']) + expect(meta.configFields[1].dependsOn).toEqual({ all: ['folderId'] }) + }) +}) + +describe('options functions must be synchronous', () => { + /** + * The providers store is substituted process-wide for the duration of the + * call, so an asynchronous options function would expose its substitute state + * to every other caller. It fails loudly rather than degrading to no options. + */ + it('throws rather than swallowing a thenable result', () => { + const subBlock = { + id: 'model', + type: 'combobox', + options: () => Promise.resolve([{ id: 'gpt', label: 'gpt' }]), + } as unknown as SubBlockConfig + + expect(() => resolveSubBlockOptions(subBlock)).toThrow(AsyncOptionsFunctionError) + }) + + it('still degrades an ordinary options failure to no options', () => { + const subBlock = { + id: 'model', + type: 'combobox', + options: () => { + throw new Error('no store here') + }, + } as unknown as SubBlockConfig + + expect(resolveSubBlockOptions(subBlock)).toBeUndefined() + }) +}) + +describe('trigger kinds with no registered definition', () => { + beforeEach(() => { + mockLogger.warn.mockClear() + mockLogger.debug.mockClear() + }) + + /** + * `start_trigger` names `chat`, `manual` and `api` — entry-point kinds, not + * registered trigger definitions. That is the shape of every core trigger + * block, so at `warn` the real registry emitted seven warnings per sweep. + */ + it('logs at debug, not warn', () => { + const triggers = projectBlockTriggers( + block({ type: 'start_trigger', triggers: { enabled: true, available: ['chat', 'manual'] } }) + ) + + expect(triggers).toEqual([]) + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockLogger.debug).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/catalog/projection/subblock.ts b/apps/sim/lib/catalog/projection/subblock.ts new file mode 100644 index 00000000000..2ddfaf3ff68 --- /dev/null +++ b/apps/sim/lib/catalog/projection/subblock.ts @@ -0,0 +1,337 @@ +import type { SubBlockConfig } from '@/blocks/types' +import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' + +/** + * Surface-neutral projection of a block's sub-block (its configuration fields) + * down to plain, serializable data. + * + * Pure by construction: no auth, no database, no `next/server`, and no + * `@/tools/registry`. Both the public catalog API and the Copilot + * `get_blocks_metadata` tool read a block's shape through here, so the two can + * never describe the same field differently. + */ + +/** One selectable option on a dropdown, combobox, or multi-select field. */ +export interface CatalogSubBlockOption { + id: string + label?: string + /** Whether the option renders with an icon. The icon component itself is never published. */ + hasIcon?: boolean +} + +/** Scalar a condition compares against. */ +export type CatalogConditionValue = string | number | boolean | Array + +/** + * A resolved visibility or requirement condition on a sub-block: "this field + * applies when `field` holds `value`". + */ +export interface CatalogCondition { + field: string + value: CatalogConditionValue + /** When true, the condition matches every value EXCEPT `value`. */ + not?: boolean + /** A second clause that must hold as well. */ + and?: { + field: string + value: CatalogConditionValue | undefined + not?: boolean + } +} + +/** Declarative dependency hint: which sibling fields must hold a value. */ +export type CatalogDependsOn = string[] | { all?: string[]; any?: string[] } + +/** A block configuration field, projected to serializable data. */ +export interface CatalogSubBlock { + id: string + type: string + title?: string + /** Whether the field must be supplied. A conditionally-required field reports `true`. */ + required?: boolean + /** The condition under which the field is required, when requirement is conditional. */ + requiredWhen?: CatalogCondition + description?: string + placeholder?: string + mode?: string + hidden?: boolean + /** The condition under which the field applies at all. */ + condition?: CatalogCondition + options?: CatalogSubBlockOption[] + min?: number + max?: number + step?: number + integer?: boolean + rows?: number + password?: boolean + multiSelect?: boolean + language?: string + generationType?: string + serviceId?: string + requiredScopes?: string[] + mimeType?: string + acceptedTypes?: string + multiple?: boolean + maxSize?: number + connectionDroppable?: boolean + columns?: string[] + dependsOn?: CatalogDependsOn + canonicalParamId?: string + defaultValue?: string | number | boolean | Record | Array + /** + * Whether the field derives its value from the block's other values rather + * than holding one of its own. The deriving function is never published. + */ + hasComputedDefault?: boolean +} + +/** + * Resolves a condition to plain data, evaluating the function form. + * + * The function form is declared `(values?: Record) => …`, so + * calling it with no arguments is exactly what its signature permits. It is + * deliberately NOT wrapped in a `try`/`catch`: a condition that dereferences + * `values` without guarding it is a block-authoring bug, and swallowing it here + * would drop the field's condition silently on every surface. `block-detail`'s + * registry sweep asserts no registered block has one. + */ +export function normalizeCondition( + condition: SubBlockConfig['condition'] +): CatalogCondition | undefined { + if (!condition) return undefined + return typeof condition === 'function' ? condition() : condition +} + +/** + * Whether a field is required, and under what condition. + * + * `required` shares the condition shape with `condition`, so a conditionally + * required field resolves to `required: true` plus the clause that decides it — + * never the raw object or function, which is not serializable. + */ +function normalizeRequired(required: SubBlockConfig['required']): { + required?: boolean + requiredWhen?: CatalogCondition +} { + if (required === undefined) return {} + if (typeof required === 'boolean') return { required } + const requiredWhen = typeof required === 'function' ? required() : required + return { required: true, requiredWhen } +} + +/** + * Models offered as static dropdown options when no provider store is available. + * + * Providers whose model list is fetched at runtime are skipped — a catalog must + * not publish an option set it cannot know — and retired models are excluded so + * a caller never receives one whose API calls fail. + */ +function staticModelOptions(): CatalogSubBlockOption[] { + const models: CatalogSubBlockOption[] = [] + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + if (DYNAMIC_MODEL_PROVIDER_IDS.has(provider.id)) continue + for (const model of provider.models ?? []) { + if (model.sunset?.status === 'deprecated') continue + models.push({ id: model.id, label: model.id }) + } + } + return models +} + +/** + * Providers whose model list is fetched at runtime rather than declared in code. + * + * Derived from the canonical list rather than restated: the local copy had + * drifted by one member (`litellm`), and a projection that disagrees with the + * registry about which providers are dynamic answers a different question than + * the app does. + */ +const DYNAMIC_MODEL_PROVIDER_IDS = new Set(DYNAMIC_MODEL_PROVIDERS) + +/** Shape of the providers store this projection substitutes while resolving options. */ +interface ProvidersStateLike { + providers: Record +} + +/** + * Thrown when an options function breaks the synchronous precondition below. + * + * Deliberately its own class so `resolveSubBlockOptions` re-throws it instead of + * degrading it to "no options": every registered block's options run through the + * `catalog-sweep` test, so this surfaces as a CI failure rather than a field that + * quietly stops publishing its choices. + */ +export class AsyncOptionsFunctionError extends Error { + constructor(message: string) { + super(message) + this.name = 'AsyncOptionsFunctionError' + } +} + +/** + * Calls a dynamic options function with static provider data substituted for the + * client store it would otherwise read. + * + * The model dropdowns read `useProvidersStore`, which has no state outside the + * browser. Substituting the code-defined model list is what lets a server-side + * projection publish the same options a user sees, instead of an empty list. + * + * PRECONDITION: every options function is synchronous, and this is the only + * reason the substitution is safe. `useProvidersStore` is a process-global, and + * `getState` is swapped for the duration of the call — so the window in which + * one caller's substitute state is visible to every other caller is exactly the + * synchronous body of `optionsFn`. An options function that awaited anything + * would widen that window across the event loop and hand its stub to unrelated + * requests. The substitution cannot be passed as an argument instead: the + * options functions call `getModelOptions()` in `@/blocks/utils`, which reads + * the store directly and takes no state parameter. So the precondition is + * enforced rather than designed away — a thenable result throws + * {@link AsyncOptionsFunctionError}. + */ +function callOptionsWithFallback( + optionsFn: () => CatalogSubBlockOption[] +): CatalogSubBlockOption[] | undefined { + const staticModels = staticModelOptions() + const substituteState: ProvidersStateLike = { + providers: { + base: { models: staticModels.map((model) => model.id) }, + ...Object.fromEntries([...DYNAMIC_MODEL_PROVIDERS].map((id) => [id, { models: [] }])), + litellm: { models: [] }, + }, + } + + let store: { useProvidersStore?: { getState: () => unknown } } | undefined + let originalGetState: (() => unknown) | undefined + + try { + store = require('@/stores/providers') + if (store?.useProvidersStore?.getState) { + originalGetState = store.useProvidersStore.getState + store.useProvidersStore.getState = () => substituteState + } + } catch { + /* The store module is unavailable in this environment; the fallback stands alone. */ + } + + try { + const options = optionsFn() + if (typeof (options as { then?: unknown } | undefined)?.then === 'function') { + throw new AsyncOptionsFunctionError( + 'A sub-block options function returned a thenable. Options functions must be ' + + 'synchronous: the providers store is substituted process-wide for the duration of ' + + 'the call, so an asynchronous one would expose its substitute state to every other ' + + 'caller. Move the I/O behind a `selectorKey` instead.' + ) + } + return options + } finally { + if (store?.useProvidersStore && originalGetState) { + store.useProvidersStore.getState = originalGetState + } + } +} + +/** + * Resolves a field's selectable options, or `undefined` when it has none the + * catalog can know. + * + * A `selectorKey` field fetches its options from a live API per workspace, so it + * has no static option set to publish. An options *function* is called, and a + * failure yields no options rather than propagating: unlike `condition`, these + * functions legitimately reach for client state that may not exist. + */ +export function resolveSubBlockOptions( + subBlock: SubBlockConfig +): CatalogSubBlockOption[] | undefined { + let rawOptions: SubBlockConfig['options'] + try { + rawOptions = + typeof subBlock.options === 'function' + ? (callOptionsWithFallback(subBlock.options as () => CatalogSubBlockOption[]) as + | SubBlockConfig['options'] + | undefined) + : subBlock.options + } catch (error) { + if (error instanceof AsyncOptionsFunctionError) throw error + return undefined + } + + if (!Array.isArray(rawOptions) || rawOptions.length === 0) return undefined + + const normalized: CatalogSubBlockOption[] = [] + for (const option of rawOptions) { + if (!option || option.id === undefined || option.id === null) continue + const projected: CatalogSubBlockOption = { id: String(option.id) } + if (typeof option.label === 'string') projected.label = option.label + if (option.icon) projected.hasIcon = true + normalized.push(projected) + } + + return normalized.length > 0 ? normalized : undefined +} + +/** + * Copies a `dependsOn` hint. + * + * The registry's own arrays are process-global and shared by every request, so a + * projection that returned them would put mutable registry state one careless + * consumer away from corruption. Every array this module publishes is a copy for + * that reason. + */ +function copyDependsOn(dependsOn: NonNullable): CatalogDependsOn { + if (Array.isArray(dependsOn)) return [...dependsOn] + const copied: { all?: string[]; any?: string[] } = {} + if (dependsOn.all) copied.all = [...dependsOn.all] + if (dependsOn.any) copied.any = [...dependsOn.any] + return copied +} + +/** Assigns `key` only when `value` is neither `undefined` nor `null`. */ +function assignDefined(target: T, key: K, value: T[K]): void { + if (value !== undefined && value !== null) target[key] = value +} + +/** Projects one sub-block config down to serializable catalog data. */ +export function projectSubBlock(subBlock: SubBlockConfig): CatalogSubBlock { + const projected: CatalogSubBlock = { id: subBlock.id, type: subBlock.type } + + assignDefined(projected, 'title', subBlock.title) + assignDefined(projected, 'description', subBlock.description) + assignDefined(projected, 'placeholder', subBlock.placeholder) + assignDefined(projected, 'mode', subBlock.mode) + assignDefined(projected, 'hidden', subBlock.hidden) + assignDefined(projected, 'canonicalParamId', subBlock.canonicalParamId) + assignDefined(projected, 'defaultValue', subBlock.defaultValue) + assignDefined(projected, 'min', subBlock.min) + assignDefined(projected, 'max', subBlock.max) + assignDefined(projected, 'step', subBlock.step) + assignDefined(projected, 'integer', subBlock.integer) + assignDefined(projected, 'rows', subBlock.rows) + assignDefined(projected, 'password', subBlock.password) + assignDefined(projected, 'multiSelect', subBlock.multiSelect) + assignDefined(projected, 'language', subBlock.language) + assignDefined(projected, 'generationType', subBlock.generationType) + assignDefined(projected, 'serviceId', subBlock.serviceId) + if (subBlock.requiredScopes) projected.requiredScopes = [...subBlock.requiredScopes] + assignDefined(projected, 'mimeType', subBlock.mimeType) + assignDefined(projected, 'acceptedTypes', subBlock.acceptedTypes) + assignDefined(projected, 'multiple', subBlock.multiple) + assignDefined(projected, 'maxSize', subBlock.maxSize) + assignDefined(projected, 'connectionDroppable', subBlock.connectionDroppable) + if (subBlock.columns) projected.columns = [...subBlock.columns] + if (subBlock.dependsOn) projected.dependsOn = copyDependsOn(subBlock.dependsOn) + + const { required, requiredWhen } = normalizeRequired(subBlock.required) + assignDefined(projected, 'required', required) + assignDefined(projected, 'requiredWhen', requiredWhen) + + const condition = normalizeCondition(subBlock.condition) + if (condition !== undefined) projected.condition = condition + + if (typeof subBlock.value === 'function') projected.hasComputedDefault = true + + const options = resolveSubBlockOptions(subBlock) + if (options) projected.options = options + + return projected +} diff --git a/apps/sim/lib/catalog/projection/tool.ts b/apps/sim/lib/catalog/projection/tool.ts new file mode 100644 index 00000000000..c9637fac7bb --- /dev/null +++ b/apps/sim/lib/catalog/projection/tool.ts @@ -0,0 +1,179 @@ +import { isHiddenFromDisplay } from '@/blocks/types' +import type { HostedApiKeySupport } from '@/tools/hosted-api-key' +import { getToolMetadata, type ToolMetadata } from '@/tools/metadata' +import { getToolOutputsMetadata } from '@/tools/metadata-outputs' +import { resolveToolId } from '@/tools/tool-ids' +import type { ToolConfig } from '@/tools/types' + +/** + * Surface-neutral projection of a built-in tool. + * + * Reads `@/tools/metadata`, `@/tools/metadata-outputs`, and `@/tools/tool-ids` — + * never `@/tools/registry`. Everything published here is plain data the + * generator already emits; reaching the executable registry for it would add + * ~4,700 modules to every graph that touches this module. + */ + +/** One declared parameter of a tool. */ +export interface CatalogToolParam { + type: string + required?: boolean + visibility?: string + description?: string + default?: unknown + /** JSON-Schema-shaped constraints for structured params. Provider-defined and arbitrarily nested. */ + items?: unknown +} + +/** One declared output field of a tool. */ +export interface CatalogToolOutput { + type: string + description?: string + optional?: boolean + nullable?: boolean + properties?: Record + items?: { type: string; description?: string; properties?: Record } + fileConfig?: { mimeType?: string; extension?: string } +} + +/** + * Deployment facts a projection needs but must not read for itself. + * + * Passed in rather than imported so this module stays a pure function of its + * arguments — the same reason `describeTool` is an option on the block detail + * projection rather than a branch inside it. + */ +export interface CatalogDeployment { + /** + * Whether Sim supplies hosted API keys at all. + * + * False on every self-hosted deployment, where `injectHostedKeyIfNeeded` + * short-circuits on `isHosted` and no tool ever receives a Sim-supplied key — + * so a tool that *declares* hosted-key support still requires the caller to + * bring one. Publishing the raw declaration there would tell 127 tools' worth + * of callers they need no key when they do. + */ + hostedKeys: boolean +} + +/** OAuth requirement declared by a tool. */ +export interface CatalogToolOAuth { + required: boolean + provider: string + requiredScopes?: string[] +} + +/** List-shaped view of a tool: identity, auth, and how its API key is supplied. */ +export interface CatalogToolSummary { + id: string + name: string + description: string + version?: string + hostedApiKey: HostedApiKeySupport + oauth?: CatalogToolOAuth +} + +/** A tool plus the parameters it accepts and the outputs it declares. */ +export interface CatalogToolDetail extends CatalogToolSummary { + params: Record + outputs: Record +} + +function projectOAuth(oauth: ToolMetadata['oauth']): CatalogToolOAuth | undefined { + if (!oauth) return undefined + const projected: CatalogToolOAuth = { required: oauth.required, provider: oauth.provider } + if (oauth.requiredScopes !== undefined) projected.requiredScopes = [...oauth.requiredScopes] + return projected +} + +/** + * Projects tool metadata to its catalog summary under a resolved registry id. + * + * The id is passed in rather than read off the metadata because the registry + * key is what `tools.access` and every caller reference, and the metadata's own + * `id` field is authored separately from it. + * + * `name` and `description` fall back to the id: both are optional on the + * generated artifact, and a catalog entry with an empty name is unusable. + * `hostedApiKey` falls back to `none`, which is what an artifact generated + * before the field existed means, and is forced to `none` wherever the + * deployment supplies no hosted keys. + */ +export function projectToolSummary( + toolId: string, + metadata: ToolMetadata, + deployment: CatalogDeployment +): CatalogToolSummary { + const summary: CatalogToolSummary = { + id: toolId, + name: metadata.name ?? toolId, + description: metadata.description ?? '', + hostedApiKey: deployment.hostedKeys ? (metadata.hostedApiKey ?? 'none') : 'none', + } + if (metadata.version !== undefined) summary.version = metadata.version + const oauth = projectOAuth(metadata.oauth) + if (oauth) summary.oauth = oauth + return summary +} + +/** Projects one declared tool parameter. */ +export function projectToolParams( + params: ToolConfig['params'] | undefined +): Record { + const projected: Record = {} + for (const [id, param] of Object.entries(params ?? {})) { + if (!param) continue + const entry: CatalogToolParam = { type: param.type } + if (param.required !== undefined) entry.required = param.required + if (param.visibility !== undefined) entry.visibility = param.visibility + if (param.description !== undefined) entry.description = param.description + if (param.default !== undefined) entry.default = param.default + if (param.items !== undefined) entry.items = param.items + projected[id] = entry + } + return projected +} + +/** Projects a tool's declared outputs, dropping any marked hidden from display. */ +export function projectToolOutputs( + outputs: NonNullable | undefined +): Record { + const projected: Record = {} + for (const [id, output] of Object.entries(outputs ?? {})) { + if (!output || isHiddenFromDisplay(output)) continue + projected[id] = output as CatalogToolOutput + } + return projected +} + +/** + * Projects a tool to its full catalog entry, or `undefined` when no such tool + * exists. + * + * The lookup resolves an unversioned name onto the newest version, exactly as + * execution does, so `gmail_send` finds `gmail_send_v2`. The returned `id` is + * the resolved one, so a caller can always see which version answered. + */ +export function projectToolDetail( + toolId: string, + deployment: CatalogDeployment +): CatalogToolDetail | undefined { + const resolved = resolveToolId(toolId) + const metadata = getToolMetadata(resolved) + if (!metadata) return undefined + return { + ...projectToolSummary(resolved, metadata, deployment), + params: projectToolParams(metadata.params), + outputs: projectToolOutputs(getToolOutputsMetadata(resolved)), + } +} + +/** Projects a tool to its catalog summary, or `undefined` when no such tool exists. */ +export function projectToolSummaryById( + toolId: string, + deployment: CatalogDeployment +): CatalogToolSummary | undefined { + const resolved = resolveToolId(toolId) + const metadata = getToolMetadata(resolved) + return metadata ? projectToolSummary(resolved, metadata, deployment) : undefined +} diff --git a/apps/sim/lib/catalog/registry-boundary.test.ts b/apps/sim/lib/catalog/registry-boundary.test.ts new file mode 100644 index 00000000000..e13c7741885 --- /dev/null +++ b/apps/sim/lib/catalog/registry-boundary.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Pins the import boundary the catalog exists to protect. + * + * `@/tools/registry` is a barrel over 4,300+ tools whose configs hold closures; + * reaching it costs ~4,700 modules. Everything the catalog reads — a tool's + * params, outputs, name, or existence — is exactly what the generated metadata + * artifacts carry, so a single `getTool` import here would be pure cost. The + * same applies to `@/connectors/registry.server`, whose fetch closures pull + * `undici` and the server-only input validators in behind them. + * + * `scripts/check-tool-registry-boundary.ts` walks these same entries and is the + * authoritative half: it follows every edge transitively, so it catches a + * registry import reintroduced one hop away, which the direct-specifier scan + * below cannot. This test is the cheaper, faster half — it runs in the normal + * suite, names the offending file directly, and additionally holds `projection/` + * to the stricter rule that it stay free of HTTP and database imports so it + * stays surface-neutral. + */ + +const APP_ROOT = join(import.meta.dirname, '..', '..') + +const CATALOG_ROOTS = [ + 'lib/catalog', + 'app/api/v2/blocks', + 'app/api/v2/tools', + 'app/api/v2/connector-types', + /** The Copilot tool the shared projection was extracted for: ~6,756 modules down to ~1,321. */ + 'lib/copilot/tools/server/blocks', +] as const + +/** Modules no catalog file may import, with what each would drag in. */ +const FORBIDDEN_EVERYWHERE: Record = { + '@/tools/registry': 'the executable tool registry (~4,700 modules of tool closures)', + '@/connectors/registry.server': + 'the server connector registry (fetch closures, undici, server-only validators)', + '@/tools/utils': 'getTool, which resolves through the executable tool registry', +} + +/** Additional modules the pure projection layer may not import. */ +const FORBIDDEN_IN_PROJECTION: Record = { + 'next/server': 'the HTTP surface; a projection must stay surface-neutral', + '@sim/db': 'the database; a projection reads code-defined registries only', + '@/enrichments/run': 'the enrichment cascade runner, which executes tools', +} + +function collectSourceFiles(root: string): string[] { + const found: string[] = [] + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (/\.tsx?$/.test(entry.name)) found.push(full) + } + } + walk(join(APP_ROOT, root)) + return found +} + +function importedModules(source: string): string[] { + const specifiers: string[] = [] + const patterns = [ + /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)??['"]([^'"]+)['"]/g, + /(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g, + /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + ] + for (const pattern of patterns) { + pattern.lastIndex = 0 + let match = pattern.exec(source) + while (match !== null) { + specifiers.push(match[1]) + match = pattern.exec(source) + } + } + return specifiers +} + +describe('catalog registry boundary', () => { + const files = CATALOG_ROOTS.flatMap(collectSourceFiles) + + it('finds catalog sources to check', () => { + expect(files.length).toBeGreaterThan(10) + }) + + it('never imports the executable tool or connector registries', () => { + for (const file of files) { + if (file.endsWith('registry-boundary.test.ts')) continue + const imports = importedModules(readFileSync(file, 'utf8')) + for (const [specifier, reason] of Object.entries(FORBIDDEN_EVERYWHERE)) { + expect( + imports.includes(specifier), + `${relative(APP_ROOT, file)} imports ${specifier} — ${reason}` + ).toBe(false) + } + } + }) + + it('keeps the projection layer free of HTTP and database imports', () => { + for (const file of collectSourceFiles('lib/catalog/projection')) { + if (file.endsWith('.test.ts')) continue + const imports = importedModules(readFileSync(file, 'utf8')) + for (const [specifier, reason] of Object.entries(FORBIDDEN_IN_PROJECTION)) { + expect( + imports.includes(specifier), + `${relative(APP_ROOT, file)} imports ${specifier} — ${reason}` + ).toBe(false) + } + } + }) +}) diff --git a/apps/sim/lib/chat-deployments/application/context.ts b/apps/sim/lib/chat-deployments/application/context.ts new file mode 100644 index 00000000000..3345a64d668 --- /dev/null +++ b/apps/sim/lib/chat-deployments/application/context.ts @@ -0,0 +1,114 @@ +import type { Principal } from '@sim/auth/principal' +import { + type ChatDeploymentRow, + getChatDeploymentWithWorkspace, + getLiveChatDeploymentForWorkflow, +} from '@/lib/chat-deployments/queries' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkflowApplicationContext, + resolveActiveWorkflowApplicationContext, +} from '@/lib/workflows/application/context' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export const CHAT_DEPLOYMENT_NOT_FOUND_MESSAGE = 'Chat deployment not found' + +export interface ActiveChatDeploymentApplicationContext extends ActiveWorkspaceApplicationContext { + chatDeploymentId: string + chatDeployment: ChatDeploymentRow +} + +/** + * Canonical context for one chat deployment. + * + * The workspace is derived from the deployment's workflow, never from the + * caller, and an `assertedWorkspaceId` that disagrees with the derived one is a + * not-found rather than a forbidden — the caller must learn nothing about a + * deployment in a workspace it did not name. + */ +export async function resolveActiveChatDeploymentApplicationContext(input: { + chatDeploymentId: string + assertedWorkspaceId?: string +}): Promise { + const canonical = await getChatDeploymentWithWorkspace(input.chatDeploymentId) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', CHAT_DEPLOYMENT_NOT_FOUND_MESSAGE) + } + + const workspaceContext = await loadActiveWorkspaceApplicationContext(canonical.workspaceId) + if (!workspaceContext) { + throw new OrchestrationError('not_found', CHAT_DEPLOYMENT_NOT_FOUND_MESSAGE) + } + return { + ...workspaceContext, + chatDeploymentId: canonical.chat.id, + chatDeployment: canonical.chat, + } +} + +/** + * The workspace assertion to compare canonical scope against, or `undefined` + * when the principal already carries its own. + * + * A workspace API key and a delegated principal are scoped at issue time, so + * their mismatches are left to canonical authorization rather than compared + * here — and that costs nothing, because both surfaces of this domain conceal + * `WorkspaceApiKeyScopeAuthorizationError` and + * `DelegatedWorkspaceAuthorizationError` as the same not-found this function + * would have produced. Every other principal names its workspace per request, + * and a mismatch there must not reveal that the deployment exists. + */ +export function assertedChatDeploymentWorkspaceId( + principal: Principal, + assertedWorkspaceId?: string +): string | undefined { + if (principal.kind === 'workspace_api_key' || principal.kind === 'delegated') return undefined + return assertedWorkspaceId +} + +/** + * Canonical context for the chat singleton of one workflow. + * + * The parent is the workflow, so the workspace is derived from it rather than + * from the deployment — which is what lets `PUT` authorize before any + * deployment row exists. `chatDeployment` is therefore nullable by design: it is + * `null` on a create, and every caller that requires one says so itself. + * + * A workflow the caller cannot reach is already concealed as a not-found by + * {@link resolveActiveWorkflowApplicationContext}, so no separate assertion is + * compared here. That is also why the singleton takes no `workspaceId` query + * param where the deployment-id-keyed reads do: the path already names a + * resource whose workspace is canonical, so there is no id-alone authorization + * to defend against. + */ +export interface WorkflowChatDeploymentApplicationContext extends ActiveWorkflowApplicationContext { + chatDeployment: ChatDeploymentRow | null +} + +export async function resolveWorkflowChatDeploymentApplicationContext(input: { + workflowId: string +}): Promise { + const workflowContext = await resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + }) + return { + ...workflowContext, + chatDeployment: await getLiveChatDeploymentForWorkflow(workflowContext.workflowId), + } +} + +/** The context's deployment, or the not-found a caller requiring one must answer. */ +export function requireWorkflowChatDeployment( + context: WorkflowChatDeploymentApplicationContext +): ChatDeploymentRow { + if (!context.chatDeployment) { + throw new OrchestrationError('not_found', CHAT_DEPLOYMENT_NOT_FOUND_MESSAGE) + } + return context.chatDeployment +} diff --git a/apps/sim/lib/chat-deployments/application/delete-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/delete-chat-deployment.ts new file mode 100644 index 00000000000..5d545d45308 --- /dev/null +++ b/apps/sim/lib/chat-deployments/application/delete-chat-deployment.ts @@ -0,0 +1,79 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { + assertedChatDeploymentWorkspaceId, + resolveActiveChatDeploymentApplicationContext, +} from '@/lib/chat-deployments/application/context' +import { chatDeploymentOperations } from '@/lib/chat-deployments/application/operations' +import { toChatDeploymentView } from '@/lib/chat-deployments/application/read-chat-deployments' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { performChatUndeploy } from '@/lib/workflows/orchestration' + +export interface DeleteChatDeploymentInput { + chatDeploymentId: string + assertedWorkspaceId?: string +} + +/** + * Stops one chat deployment serving. + * + * Keyed on the deployment rather than on its workflow, which is what + * `workflows.chat.undeploy` takes. Both end in `performChatUndeploy`; they stay + * separate operations because a caller holding a deployment id cannot name the + * workflow the other requires, and the reverse. + * + * The workflow's own deployment is untouched — only the chat surface stops. + */ +export const deleteChatDeployment = defineAuthorizedWorkspaceUseCase({ + operation: chatDeploymentOperations.delete, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: DeleteChatDeploymentInput + }) => + resolveActiveChatDeploymentApplicationContext({ + chatDeploymentId: input.chatDeploymentId, + assertedWorkspaceId: assertedChatDeploymentWorkspaceId(principal, input.assertedWorkspaceId), + }), + authorizationOptions: {}, + async execute({ principal, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performChatUndeploy({ + chatId: context.chatDeploymentId, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + projectLegacyAudit: false, + }) + if (!result.success) { + /** + * Only a genuinely absent deployment is concealed. `performChatUndeploy` + * also fails for infrastructure reasons, and rendering one of those as a + * `404` tells the caller the deployment is gone while it is still serving. + */ + const message = result.error ?? 'Failed to delete chat deployment' + if (result.errorCode !== 'not_found') throw new Error(message) + throw new OrchestrationError('not_found', message) + } + return { + deployment: toChatDeploymentView(context.chatDeployment), + workspaceId: context.workspaceId, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CHAT_DELETED, + resourceType: AuditResourceType.CHAT, + resourceId: result.deployment.id, + resourceName: result.deployment.title, + description: `Deleted chat deployment "${result.deployment.title}"`, + metadata: { + workflowId: result.deployment.workflowId, + identifier: result.deployment.identifier, + authType: result.deployment.authType, + }, + }), +}) diff --git a/apps/sim/lib/chat-deployments/application/errors.ts b/apps/sim/lib/chat-deployments/application/errors.ts new file mode 100644 index 00000000000..d34f8f54737 --- /dev/null +++ b/apps/sim/lib/chat-deployments/application/errors.ts @@ -0,0 +1,59 @@ +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** + * The requested chat identifier is already taken by another live deployment. + * + * A distinct class rather than a bare conflict because the two surfaces answer + * it differently: the public API reports the `409` the condition actually is, + * while the internal editor keeps the `400` it has always sent, which its + * client recognises. A shared error policy cannot tell one conflict from + * another, so the distinction has to be a type rather than a message. + */ +export class ChatIdentifierInUseError extends OrchestrationError { + constructor(message = 'Identifier already in use') { + super('conflict', message) + this.name = 'ChatIdentifierInUseError' + } +} + +/** + * The partial unique index the `chat` table enforces the identifier on. + * + * `uniqueIndex('identifier_idx') ON chat (identifier) WHERE archived_at IS NULL` + * in the schema. Matched by name so an unrelated `23505` on this table — one + * a future index introduces — is not mislabelled as an identifier collision. + */ +const CHAT_IDENTIFIER_UNIQUE_INDEX = 'identifier_idx' + +/** + * Classifies a write that lost the identifier race. + * + * Every identifier check in this domain is a check-then-act: the read that + * proves an identifier free and the write that claims it are separate + * statements, so two callers claiming the same identifier concurrently both + * pass the check and the second one's `INSERT`/`UPDATE` trips + * {@link CHAT_IDENTIFIER_UNIQUE_INDEX}. The database is what actually holds the + * invariant; the pre-check only turns the common case into a clean refusal. + * + * Unclassified, that loss surfaced as an unhandled driver error and therefore + * as a `500` — a caller-supplied value producing a server fault, which the v2 + * surface treats as its highest-severity defect class. It is the same condition + * the pre-check already reports, so it answers the same `409`. + * + * Anything else propagates untouched: a foreign-key or not-null violation is a + * real fault and must not be reported back as the caller's conflict. + */ +export function chatIdentifierUniquenessConflict(identifier: string) { + return (error: unknown): never => { + if ( + getPostgresErrorCode(error) === '23505' && + getPostgresConstraintName(error) === CHAT_IDENTIFIER_UNIQUE_INDEX + ) { + throw new ChatIdentifierInUseError( + `The identifier "${identifier}" was claimed by another chat deployment; choose a different identifier.` + ) + } + throw error + } +} diff --git a/apps/sim/lib/chat-deployments/application/index.ts b/apps/sim/lib/chat-deployments/application/index.ts new file mode 100644 index 00000000000..8a2e35aaf34 --- /dev/null +++ b/apps/sim/lib/chat-deployments/application/index.ts @@ -0,0 +1,41 @@ +export { + type ActiveChatDeploymentApplicationContext, + CHAT_DEPLOYMENT_NOT_FOUND_MESSAGE, + requireWorkflowChatDeployment, + resolveActiveChatDeploymentApplicationContext, + resolveWorkflowChatDeploymentApplicationContext, + type WorkflowChatDeploymentApplicationContext, +} from '@/lib/chat-deployments/application/context' +export { + type DeleteChatDeploymentInput, + deleteChatDeployment, +} from '@/lib/chat-deployments/application/delete-chat-deployment' +export { + ChatIdentifierInUseError, + chatIdentifierUniquenessConflict, +} from '@/lib/chat-deployments/application/errors' +export { + type ChatDeploymentOperation, + chatDeploymentOperations, +} from '@/lib/chat-deployments/application/operations' +export { + type ChatDeploymentView, + type ListChatDeploymentsInput, + listChatDeployments, + type ReadChatDeploymentInput, + readChatDeployment, + toChatDeploymentView, +} from '@/lib/chat-deployments/application/read-chat-deployments' +export { + type UpdateChatDeploymentInput, + type UpdateChatDeploymentResult, + updateChatDeployment, +} from '@/lib/chat-deployments/application/update-chat-deployment' +export { + deleteWorkflowChatDeployment, + type ReplaceWorkflowChatDeploymentInput, + readWorkflowChatDeployment, + replaceWorkflowChatDeployment, + type WorkflowChatDeploymentInput, + type WorkflowChatDeploymentResult, +} from '@/lib/chat-deployments/application/workflow-chat-deployment' diff --git a/apps/sim/lib/chat-deployments/application/operations.ts b/apps/sim/lib/chat-deployments/application/operations.ts new file mode 100644 index 00000000000..6776bbe9926 --- /dev/null +++ b/apps/sim/lib/chat-deployments/application/operations.ts @@ -0,0 +1,74 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +/** + * Semantic operations on a chat deployment as a resource in its own right. + * + * A workflow carries at most one live chat, so the public surface addresses it + * as a singleton under its workflow — `/api/v2/workflows/{workflowId}/deployments/chat` + * — and every operation there is one of these. `read`, `update`, and `delete` + * are also what the internal editor calls when it addresses the same deployment + * by its own id; the resource and its policy are the same either way, so the + * operation is too. + * + * `workflows.chat.deploy` and `workflows.chat.undeploy` remain the entry points + * for the surfaces that name a workflow and ask for it to be published — the + * internal deploy route and the Copilot tool. They converge on the same domain + * effect as `replace` and `delete` but authorize a workflow the caller is + * deploying rather than a chat surface the caller is configuring. + */ +const CHAT_DEPLOYMENT_LIST_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const + +/** + * Reading one deployment, and every write, needs an accountable human at + * workspace admin. + * + * A chat deployment controls who may reach a workflow from the open internet, + * and `public` removes the gate entirely — so its detail is gate configuration: + * `authType`, `hasPassword`, the `allowedEmails` allow-list, and the full + * customization blob. Admin is also the role the internal editor has always + * required of this read, and `defineWorkspaceOperation` therefore excludes + * workspace API keys, which cannot exceed the write ceiling. + */ +const CHAT_DEPLOYMENT_ADMIN_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const + +export const chatDeploymentOperations = { + list: defineWorkspaceOperation({ + id: 'chat_deployments.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + ...CHAT_DEPLOYMENT_LIST_POLICY, + }), + replace: defineWorkspaceOperation({ + id: 'chat_deployments.replace', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...CHAT_DEPLOYMENT_ADMIN_POLICY, + }), + read: defineWorkspaceOperation({ + id: 'chat_deployments.read', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...CHAT_DEPLOYMENT_ADMIN_POLICY, + }), + update: defineWorkspaceOperation({ + id: 'chat_deployments.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...CHAT_DEPLOYMENT_ADMIN_POLICY, + }), + delete: defineWorkspaceOperation({ + id: 'chat_deployments.delete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...CHAT_DEPLOYMENT_ADMIN_POLICY, + }), +} as const + +export type ChatDeploymentOperation = + (typeof chatDeploymentOperations)[keyof typeof chatDeploymentOperations] diff --git a/apps/sim/lib/chat-deployments/application/read-chat-deployments.ts b/apps/sim/lib/chat-deployments/application/read-chat-deployments.ts new file mode 100644 index 00000000000..a6a277ffbd9 --- /dev/null +++ b/apps/sim/lib/chat-deployments/application/read-chat-deployments.ts @@ -0,0 +1,90 @@ +import type { Principal } from '@sim/auth/principal' +import { + assertedChatDeploymentWorkspaceId, + resolveActiveChatDeploymentApplicationContext, +} from '@/lib/chat-deployments/application/context' +import { chatDeploymentOperations } from '@/lib/chat-deployments/application/operations' +import { + type ChatDeploymentRow, + type ChatDeploymentSortBy, + listWorkspaceChatDeployments, +} from '@/lib/chat-deployments/queries' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +/** + * Chat deployments carry an encrypted password. It is stripped here, once, so + * no surface can serve it by forgetting to: every read in this domain returns + * this projection, and `hasPassword` is the only fact about it a caller learns. + */ +export interface ChatDeploymentView extends Omit { + hasPassword: boolean +} + +export function toChatDeploymentView(row: ChatDeploymentRow): ChatDeploymentView { + const { password, ...rest } = row + return { + ...rest, + includeToolCalls: rest.includeToolCalls ?? false, + hasPassword: Boolean(password), + } +} + +export interface ListChatDeploymentsInput { + workspaceId: string + workflowId?: string + isActive?: boolean + sortBy?: ChatDeploymentSortBy + sortOrder?: 'asc' | 'desc' + limit: number + cursorKeys?: Parameters[0]['cursorKeys'] +} + +export const listChatDeployments = defineAuthorizedWorkspaceUseCase({ + operation: chatDeploymentOperations.list, + resolveContext: ({ input }: { input: ListChatDeploymentsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + const page = await listWorkspaceChatDeployments({ + workspaceId: context.workspaceId, + workflowId: input.workflowId, + isActive: input.isActive, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit, + cursorKeys: input.cursorKeys, + }) + return { + deployments: page.data.map(toChatDeploymentView), + nextCursorKeys: page.nextCursorKeys, + } + }, +}) + +export interface ReadChatDeploymentInput { + chatDeploymentId: string + assertedWorkspaceId?: string +} + +export const readChatDeployment = defineAuthorizedWorkspaceUseCase({ + operation: chatDeploymentOperations.read, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReadChatDeploymentInput + }) => + resolveActiveChatDeploymentApplicationContext({ + chatDeploymentId: input.chatDeploymentId, + assertedWorkspaceId: assertedChatDeploymentWorkspaceId(principal, input.assertedWorkspaceId), + }), + authorizationOptions: {}, + async execute({ context }) { + return { + deployment: toChatDeploymentView(context.chatDeployment), + workspaceId: context.workspaceId, + } + }, +}) diff --git a/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts new file mode 100644 index 00000000000..0f6b5b2e990 --- /dev/null +++ b/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts @@ -0,0 +1,256 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { + assertedChatDeploymentWorkspaceId, + resolveActiveChatDeploymentApplicationContext, +} from '@/lib/chat-deployments/application/context' +import { + ChatIdentifierInUseError, + chatIdentifierUniquenessConflict, +} from '@/lib/chat-deployments/application/errors' +import { chatDeploymentOperations } from '@/lib/chat-deployments/application/operations' +import { + type ChatDeploymentView, + toChatDeploymentView, +} from '@/lib/chat-deployments/application/read-chat-deployments' +import { + type ChatDeploymentRow, + getChatDeploymentIdOwningIdentifier, + updateChatDeploymentRow, +} from '@/lib/chat-deployments/queries' +import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { encryptSecret } from '@/lib/core/security/encryption' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import { getWorkflowDeploymentSummary, performFullDeploy } from '@/lib/workflows/orchestration' +import { + ChatDeployAuthNotAllowedError, + validateChatDeployAuth, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('UpdateChatDeployment') + +type ChatAuthType = 'public' | 'password' | 'email' | 'sso' + +export interface UpdateChatDeploymentInput { + chatDeploymentId: string + assertedWorkspaceId?: string + /** Accepted only when it equals the deployment's current workflow; see below. */ + workflowId?: string + identifier?: string + title?: string + description?: string + customizations?: { primaryColor?: string; welcomeMessage?: string; imageUrl?: string } + authType?: ChatAuthType + password?: string + allowedEmails?: string[] + outputConfigs?: Array<{ blockId: string; path: string }> + includeThinking?: boolean + includeToolCalls?: boolean +} + +export interface UpdateChatDeploymentResult { + deployment: ChatDeploymentView + workspaceId: string + chatUrl: string +} + +/** + * Resolves the password column from the requested change. + * + * Two rules, both of which shipped as bugs once: + * + * - A `password` auth type with no supplied password is only legal when one is + * already stored, otherwise the deployment fails closed at login with an + * opaque configuration error. + * - A supplied password is stored *only* when the deployment ends up + * password-protected. Applying it unconditionally re-armed the secret the + * auth-type matrix had just cleared, so `PATCH { authType: 'email', password }` + * persisted a password on an email-gated chat. + */ +async function resolvePasswordUpdate( + existing: ChatDeploymentRow, + input: UpdateChatDeploymentInput +): Promise { + const effectiveAuthType = input.authType ?? (existing.authType as ChatAuthType) + + if (input.password) { + if (effectiveAuthType !== 'password') return undefined + const { encrypted } = await encryptSecret(input.password) + return encrypted + } + + if (input.authType === 'password' && (existing.authType !== 'password' || !existing.password)) { + throw new OrchestrationError( + 'validation', + 'Password is required when using password protection' + ) + } + return undefined +} + +/** + * The auth-type field-clearing matrix. + * + * Each mode owns exactly one of the two gate columns, so switching modes must + * clear the other — a leftover password on an email-gated chat, or a leftover + * allow-list on a public one, is a stale gate nothing else erases. + */ +function clearedGateColumnsFor(authType: ChatAuthType): Partial { + switch (authType) { + case 'public': + return { password: null, allowedEmails: [] } + case 'password': + return { allowedEmails: [] } + case 'email': + case 'sso': + return { password: null } + } +} + +/** + * Redeploys the underlying workflow when the draft has drifted, and refuses + * while another attempt is in flight. + * + * Both refusals are conflicts rather than errors: the caller's request is + * well-formed and will succeed once the pending deployment settles. The + * post-deploy check is the load-bearing one — a deploy settles asynchronously + * and `success` only admits the attempt, so advancing the chat row before + * cutover would strand it on the previous version with no error. This is the + * invariant `performChatDeploy` enforces on the create path; keeping both in + * application code is what stops them drifting apart. + */ +async function redeployWorkflowIfDrifted(workflowId: string, userId: string): Promise { + const deploymentSummary = await getWorkflowDeploymentSummary(workflowId) + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + if (attemptStatus === 'preparing' || attemptStatus === 'activating') { + throw new OrchestrationError( + 'conflict', + 'A workflow deployment is still preparing. Retry the chat update after it becomes active.' + ) + } + + const needsRedeploy = + !deploymentSummary.activeDeployment || (await checkNeedsRedeployment(workflowId)) + if (!needsRedeploy) return + + const deployResult = await performFullDeploy({ workflowId, userId }) + if (!deployResult.success) { + logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`) + const message = deployResult.error || 'Failed to redeploy workflow' + if (deployResult.errorCode === 'validation' || deployResult.errorCode === 'not_found') { + throw new OrchestrationError(deployResult.errorCode, message) + } + throw new Error(message) + } + if (deployResult.latestDeploymentAttempt?.status !== 'active') { + throw new OrchestrationError( + 'conflict', + deployResult.warnings?.[0] ?? + 'Workflow deployment is still preparing. Retry the chat update after it becomes active.' + ) + } + logger.info(`Redeployed workflow ${workflowId} for chat update (v${deployResult.version})`) +} + +export const updateChatDeployment = defineAuthorizedWorkspaceUseCase({ + operation: chatDeploymentOperations.update, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: UpdateChatDeploymentInput + }) => + resolveActiveChatDeploymentApplicationContext({ + chatDeploymentId: input.chatDeploymentId, + assertedWorkspaceId: assertedChatDeploymentWorkspaceId(principal, input.assertedWorkspaceId), + }), + authorizationOptions: {}, + async execute({ principal, input, context }): Promise { + const existing = context.chatDeployment + + /** + * A chat deployment is bound to its workflow for its whole life: the + * deployed URL, the pinned version, and the generated input schema all + * derive from it. Re-pointing one is a new deployment, not an edit. + */ + if (input.workflowId && input.workflowId !== existing.workflowId) { + throw new OrchestrationError( + 'validation', + 'Changing the workflow of a chat deployment is not allowed' + ) + } + + const actingUserId = requirePrincipalSubjectUserId(principal) + + /** + * The permission group's auth-mode allow-list applies only when the mode + * actually changes, so a grandfathered mode already saved on this chat can + * be re-saved by a title-only edit without a refusal. + */ + if (input.authType && input.authType !== existing.authType) { + try { + await validateChatDeployAuth(actingUserId, context.workspaceId, input.authType) + } catch (error) { + if (error instanceof ChatDeployAuthNotAllowedError) { + throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) + } + throw error + } + } + + if (input.identifier && input.identifier !== existing.identifier) { + const owner = await getChatDeploymentIdOwningIdentifier(input.identifier) + if (owner && owner !== existing.id) { + throw new ChatIdentifierInUseError() + } + } + + const encryptedPassword = await resolvePasswordUpdate(existing, input) + + await redeployWorkflowIfDrifted(existing.workflowId, actingUserId) + + const values: Partial = {} + if (input.identifier) values.identifier = input.identifier + if (input.title) values.title = input.title + if (input.description !== undefined) values.description = input.description + if (input.customizations) values.customizations = input.customizations + if (input.authType) { + values.authType = input.authType + Object.assign(values, clearedGateColumnsFor(input.authType)) + } + if (encryptedPassword !== undefined) values.password = encryptedPassword + if (input.allowedEmails) values.allowedEmails = input.allowedEmails + if (input.outputConfigs) values.outputConfigs = input.outputConfigs + if (input.includeThinking !== undefined) values.includeThinking = input.includeThinking + /** Partial updates keep the stored value; a row predating the column reads false. */ + values.includeToolCalls = input.includeToolCalls ?? existing.includeToolCalls ?? false + + const updated = await updateChatDeploymentRow(existing.id, values).catch( + chatIdentifierUniquenessConflict(values.identifier ?? existing.identifier) + ) + if (!updated) throw new OrchestrationError('not_found', 'Chat deployment not found') + + return { + deployment: toChatDeploymentView(updated), + workspaceId: context.workspaceId, + chatUrl: buildChatDeploymentUrl(updated.identifier), + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CHAT_UPDATED, + resourceType: AuditResourceType.CHAT, + resourceId: result.deployment.id, + resourceName: result.deployment.title, + description: `Updated chat deployment "${result.deployment.title}"`, + metadata: { + identifier: result.deployment.identifier, + authType: result.deployment.authType, + workflowId: result.deployment.workflowId, + chatUrl: result.chatUrl, + }, + }), +}) diff --git a/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts new file mode 100644 index 00000000000..e2f5be29d2f --- /dev/null +++ b/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts @@ -0,0 +1,296 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { + type Principal, + requirePrincipalSubjectUserId, + resolvePrincipalAttribution, + toPrincipalActor, +} from '@sim/auth/principal' +import { + requireWorkflowChatDeployment, + resolveWorkflowChatDeploymentApplicationContext, + type WorkflowChatDeploymentApplicationContext, +} from '@/lib/chat-deployments/application/context' +import { + ChatIdentifierInUseError, + chatIdentifierUniquenessConflict, +} from '@/lib/chat-deployments/application/errors' +import { chatDeploymentOperations } from '@/lib/chat-deployments/application/operations' +import { + type ChatDeploymentView, + toChatDeploymentView, +} from '@/lib/chat-deployments/application/read-chat-deployments' +import { + getChatDeploymentIdOwningIdentifier, + getLiveChatDeploymentForWorkflow, +} from '@/lib/chat-deployments/queries' +import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { performChatDeploy, performChatUndeploy } from '@/lib/workflows/orchestration' +import { + ChatDeployAuthNotAllowedError, + validateChatDeployAuth, +} from '@/ee/access-control/utils/permission-check' + +/** + * The chat singleton of a workflow. + * + * A chat deployment is strictly 1:1 with the workflow it publishes — `workflowId` + * is `NOT NULL` and cascades, and nothing can re-point one — so the workflow + * already addresses it uniquely and the deployment's own id is a synthetic key + * for a resource that needs none. The public surface therefore addresses it as a + * singleton, and a singleton has no separate create verb: `PUT` is + * create-or-replace, which is the *only* write, so one effect cannot be reached + * through two authorization paths. + * + * These are keyed on the workflow. The deployment-id-keyed use cases beside them + * stay for the internal editor, which addresses a chat it already holds the id + * of; both bind the same {@link chatDeploymentOperations}, so the policy for an + * effect is stated once regardless of how the resource was named. + */ + +type ChatAuthType = 'public' | 'password' | 'email' | 'sso' + +/** Platform defaults for the presentation fields a replace may omit. */ +const DEFAULT_PRIMARY_COLOR = 'var(--brand-hover)' +const DEFAULT_WELCOME_MESSAGE = 'Hi there! How can I help you today?' + +export interface WorkflowChatDeploymentInput { + workflowId: string +} + +export interface ReplaceWorkflowChatDeploymentInput extends WorkflowChatDeploymentInput { + identifier: string + title: string + description?: string + customizations?: { primaryColor?: string; welcomeMessage?: string; imageUrl?: string } + authType?: ChatAuthType + password?: string + allowedEmails?: string[] + outputConfigs?: Array<{ blockId: string; path: string }> + includeThinking?: boolean + includeToolCalls?: boolean + requestId: string +} + +export interface WorkflowChatDeploymentResult { + deployment: ChatDeploymentView + workspaceId: string + workflowId: string +} + +function resolveContext({ input }: { input: WorkflowChatDeploymentInput }) { + return resolveWorkflowChatDeploymentApplicationContext({ workflowId: input.workflowId }) +} + +/** + * GET — the workflow's chat, or a not-found when it publishes none. + * + * Bound to the same `chat_deployments.read` the deployment-id-keyed read is, so + * the gate configuration this carries — `authType`, `hasPassword`, and the + * `allowedEmails` allow-list — is admin-gated by the same policy on both paths. + */ +export const readWorkflowChatDeployment = defineAuthorizedWorkspaceUseCase({ + operation: chatDeploymentOperations.read, + resolveContext, + authorizationOptions: {}, + async execute({ context }): Promise { + return { + deployment: toChatDeploymentView(requireWorkflowChatDeployment(context)), + workspaceId: context.workspaceId, + workflowId: context.workflowId, + } + }, +}) + +/** + * The permission group's auth-mode allow-list, applied only when the mode + * actually changes. + * + * A mode already saved on this chat can be re-saved by a replace that does not + * touch it, so a grandfathered configuration is not refused by an edit to some + * other field. + */ +async function assertAuthModePermitted( + context: WorkflowChatDeploymentApplicationContext, + principal: Principal, + authType: ChatAuthType +): Promise { + if (authType === context.chatDeployment?.authType) return + try { + await validateChatDeployAuth( + requirePrincipalSubjectUserId(principal), + context.workspaceId, + authType + ) + } catch (error) { + if (error instanceof ChatDeployAuthNotAllowedError) { + throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) + } + throw error + } +} + +/** + * PUT — create the workflow's chat, or replace it wholesale. + * + * Replace, not merge: the stored deployment ends up as exactly what the body + * describes, so an omitted optional field takes its platform default rather than + * whatever the previous deployment happened to carry. That is what makes the + * verb idempotent, and it is the reason `password` is required by the contract + * whenever the result is password-gated — a write-only field cannot be read back + * and re-sent, so carrying one over implicitly would be the one place replace + * quietly stopped meaning replace. + * + * This also deploys the workflow, because a chat serves the live version: a + * draft that has drifted is republished as part of the call, and a call landing + * while another deployment attempt is still preparing is a `409` rather than a + * second admitted version. + */ +export const replaceWorkflowChatDeployment = defineAuthorizedWorkspaceUseCase({ + operation: chatDeploymentOperations.replace, + resolveContext: ({ input }: { input: ReplaceWorkflowChatDeploymentInput }) => + resolveWorkflowChatDeploymentApplicationContext({ workflowId: input.workflowId }), + authorizationOptions: {}, + async execute({ principal, input, context }) { + const existing = context.chatDeployment + const authType = input.authType ?? 'public' + + /** + * The pre-check that turns the common collision into a clean refusal. The + * uncommon one — another caller claiming the identifier between here and the + * write — is caught by {@link chatIdentifierUniquenessConflict} below and + * answers the same conflict. + */ + const identifierOwnerId = await getChatDeploymentIdOwningIdentifier(input.identifier) + if (identifierOwnerId && identifierOwnerId !== existing?.id) { + throw new ChatIdentifierInUseError() + } + + await assertAuthModePermitted(context, principal, authType) + + const allowedEmails = input.allowedEmails ?? [] + const outputConfigs = input.outputConfigs ?? [] + const customizations = { + primaryColor: input.customizations?.primaryColor ?? DEFAULT_PRIMARY_COLOR, + welcomeMessage: input.customizations?.welcomeMessage ?? DEFAULT_WELCOME_MESSAGE, + ...(input.customizations?.imageUrl ? { imageUrl: input.customizations.imageUrl } : {}), + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performChatDeploy({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + actor: toPrincipalActor(principal), + identifier: input.identifier, + title: input.title, + description: input.description ?? '', + customizations, + authType, + /** Replace semantics: a mode that owns no password stores none. */ + password: authType === 'password' ? input.password : null, + allowedEmails, + outputConfigs, + includeThinking: input.includeThinking ?? false, + includeToolCalls: input.includeToolCalls ?? false, + workspaceId: context.workspaceId, + requestId: input.requestId, + projectLegacyAudit: false, + }).catch(chatIdentifierUniquenessConflict(input.identifier)) + + if (!result.success) { + /** + * Classified by the orchestration rather than flattened to a `400`: an + * in-flight deployment is a `409` the caller can retry, and an invariant + * failure is a `500` rather than a claim that the request was malformed. + */ + const message = result.error ?? 'Failed to deploy chat' + if (!result.errorCode || result.errorCode === 'internal') throw new Error(message) + throw new OrchestrationError(result.errorCode, message) + } + + /** + * Re-read the settled row so callers present what was actually stored rather + * than what was requested — the orchestration normalizes several fields on + * the way in. + */ + const deployment = await getLiveChatDeploymentForWorkflow(context.workflowId) + if (!deployment) throw new Error('Chat deployment succeeded without leaving a deployment row') + + return { + deployment: toChatDeploymentView(deployment), + workspaceId: context.workspaceId, + workflowId: context.workflowId, + created: !existing, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CHAT_DEPLOYED, + resourceType: AuditResourceType.CHAT, + resourceId: result.deployment.id, + resourceName: result.deployment.title, + description: `${result.created ? 'Deployed' : 'Replaced'} chat "${result.deployment.title}"`, + metadata: { + workflowId: result.workflowId, + identifier: result.deployment.identifier, + authType: result.deployment.authType, + chatUrl: buildChatDeploymentUrl(result.deployment.identifier), + isUpdate: !result.created, + }, + }), +}) + +/** + * DELETE — stop serving the workflow's chat. + * + * The workflow's own deployment is untouched: it stays live and executable + * through the workflow API. That is the whole distinction between this and + * `DELETE /api/v2/workflows/{workflowId}/deployment`. + */ +export const deleteWorkflowChatDeployment = defineAuthorizedWorkspaceUseCase({ + operation: chatDeploymentOperations.delete, + resolveContext, + authorizationOptions: {}, + async execute({ principal, context }): Promise { + const deployment = requireWorkflowChatDeployment(context) + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performChatUndeploy({ + chatId: deployment.id, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + projectLegacyAudit: false, + }) + if (!result.success) { + /** + * Only a genuinely absent deployment is concealed. `performChatUndeploy` + * also fails for infrastructure reasons, and rendering one of those as a + * `404` tells the caller the chat is gone while it is still serving. + */ + const message = result.error ?? 'Failed to delete chat deployment' + if (result.errorCode !== 'not_found') throw new Error(message) + throw new OrchestrationError('not_found', message) + } + return { + deployment: toChatDeploymentView(deployment), + workspaceId: context.workspaceId, + workflowId: context.workflowId, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CHAT_DELETED, + resourceType: AuditResourceType.CHAT, + resourceId: result.deployment.id, + resourceName: result.deployment.title, + description: `Deleted chat deployment "${result.deployment.title}"`, + metadata: { + workflowId: result.workflowId, + identifier: result.deployment.identifier, + authType: result.deployment.authType, + }, + }), +}) diff --git a/apps/sim/lib/chat-deployments/queries.ts b/apps/sim/lib/chat-deployments/queries.ts new file mode 100644 index 00000000000..97eeaf825fe --- /dev/null +++ b/apps/sim/lib/chat-deployments/queries.ts @@ -0,0 +1,169 @@ +import { db } from '@sim/db' +import { chat, workflow } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { + type CursorKey, + type KeysetKey, + type KeysetPage, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + textKey, + timestampKey, +} from '@/lib/api/list-query' + +/** + * Workspace-scoped chat-deployment reads. + * + * `chat` has no `workspaceId` column — scope is derived by joining the workflow + * it deploys — so every predicate here goes through that join rather than + * trusting a caller-supplied workspace. + */ + +export type ChatDeploymentRow = typeof chat.$inferSelect +export type ChatDeploymentSortBy = 'identifier' | 'createdAt' | 'updatedAt' + +const chatDeploymentId = textKey(chat.id, (row) => row.id) + +/** + * Keyset orderings for the public list's sortable fields, made total over the + * contract enum by `satisfies`. Each ends in `id` so deployments sharing an + * identifier prefix or a timestamp still come back in a stable order. + */ +const CHAT_DEPLOYMENT_SORTS = { + identifier: [ + textKey(chat.identifier, (row) => row.identifier), + chatDeploymentId, + ], + createdAt: [ + timestampKey(chat.createdAt, (row) => row.createdAt), + chatDeploymentId, + ], + updatedAt: [ + timestampKey(chat.updatedAt, (row) => row.updatedAt), + chatDeploymentId, + ], +} satisfies Record[]> + +/** One keyset page of live chat deployments whose workflow lives in a workspace. */ +export async function listWorkspaceChatDeployments(params: { + workspaceId: string + workflowId?: string + isActive?: boolean + sortBy?: ChatDeploymentSortBy + sortOrder?: ListSortOrder + limit: number + cursorKeys?: CursorKey[] +}): Promise> { + const { sortBy = 'createdAt', sortOrder = 'desc', limit } = params + const keys = CHAT_DEPLOYMENT_SORTS[sortBy] + const resumeAfter = resumeKeyset(keys, params.cursorKeys, sortOrder) + + const rows = await db + .select({ chat }) + .from(chat) + .innerJoin(workflow, eq(chat.workflowId, workflow.id)) + .where( + and( + eq(workflow.workspaceId, params.workspaceId), + isNull(workflow.archivedAt), + isNull(chat.archivedAt), + params.workflowId === undefined ? undefined : eq(chat.workflowId, params.workflowId), + params.isActive === undefined ? undefined : eq(chat.isActive, params.isActive), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + .limit(limit + 1) + + return keysetPage( + keys, + rows.map((row) => row.chat), + limit + ) +} + +/** + * A live chat deployment together with the workspace derived from its workflow, + * or null when neither the deployment nor its workflow is live. + */ +export async function getChatDeploymentWithWorkspace( + chatDeploymentId: string +): Promise<{ chat: ChatDeploymentRow; workspaceId: string } | null> { + const [row] = await db + .select({ chat, workspaceId: workflow.workspaceId }) + .from(chat) + .innerJoin(workflow, eq(chat.workflowId, workflow.id)) + .where(and(eq(chat.id, chatDeploymentId), isNull(chat.archivedAt))) + .limit(1) + + if (!row?.workspaceId) return null + return { chat: row.chat, workspaceId: row.workspaceId } +} + +/** + * The live chat deployment of a workflow, or null when it has none. + * + * `.limit(1)` is the only thing expressing the 1:1 invariant between a workflow + * and its chat. It is a projection of that invariant, not an enforcement of it: + * there is no unique constraint on `chat(workflow_id)`, so this read is one half + * of a check-then-act and the guarantees split in two. + * + * **Guaranteed.** Two concurrent writers claiming the same `identifier` cannot + * both win: the partial unique index `identifier_idx ON chat (identifier) WHERE + * archived_at IS NULL` rejects the loser, and + * {@link chatIdentifierUniquenessConflict} classifies that rejection as the same + * `409` the pre-check reports. + * + * **Not guaranteed.** Two concurrent writers publishing the *same workflow* + * under *different* identifiers both read `null` here and both insert. Nothing + * rejects the second, so the workflow ends up with two live chat rows and every + * subsequent read of it silently resolves to whichever one this `.limit(1)` + * happens to return. The window is narrow — it spans this read to the insert in + * `performChatDeploy` — but it is real, and it is not closable in application + * code: the insert is not transactional and is shared with the internal editor + * and the Copilot deploy tool, so a lock taken here would not span it. + * + * The fix is a partial unique index, `chat(workflow_id) WHERE archived_at IS + * NULL`, which makes the loser a `23505` this domain can classify exactly as it + * already classifies the identifier collision. It ships as its own migration, + * behind a preflight count of workflows already carrying more than one live + * chat row, because the constraint cannot be created while a duplicate exists. + */ +export async function getLiveChatDeploymentForWorkflow( + workflowId: string +): Promise { + const [row] = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + .limit(1) + return row ?? null +} + +/** The live deployment holding an identifier, or null when the identifier is free. */ +export async function getChatDeploymentIdOwningIdentifier( + identifier: string +): Promise { + const [row] = await db + .select({ id: chat.id }) + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) + return row?.id ?? null +} + +/** Applies a settled update to one chat deployment and returns the authoritative row. */ +export async function updateChatDeploymentRow( + chatDeploymentId: string, + values: Partial +): Promise { + const [row] = await db + .update(chat) + .set({ ...values, updatedAt: new Date() }) + .where(and(eq(chat.id, chatDeploymentId), isNull(chat.archivedAt))) + .returning() + return row ?? null +} diff --git a/apps/sim/lib/chat-deployments/urls.test.ts b/apps/sim/lib/chat-deployments/urls.test.ts new file mode 100644 index 00000000000..9605caa04c8 --- /dev/null +++ b/apps/sim/lib/chat-deployments/urls.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' + +describe('buildChatDeploymentUrl', () => { + afterEach(() => { + resetEnvMock() + resetEnvFlagsMock() + }) + + it('serves the chat from the app host on the /chat/ path', () => { + setEnv({ NEXT_PUBLIC_APP_URL: 'https://sim.ai' }) + + expect(buildChatDeploymentUrl('support')).toBe('https://sim.ai/chat/support') + }) + + it('strips the www prefix, because the deployed chat answers on the bare host', () => { + setEnv({ NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' }) + + expect(buildChatDeploymentUrl('support')).toBe('https://sim.ai/chat/support') + }) + + /** + * `getBaseUrl` throws when the variable is unset, so a self-host missing it + * would otherwise fail every chat read and update with a `500` — where the + * derivation this consolidated already fell back instead. + */ + it('falls back instead of throwing when NEXT_PUBLIC_APP_URL is unset', () => { + setEnv({ NEXT_PUBLIC_APP_URL: undefined }) + setEnvFlags({ isDev: true }) + + expect(buildChatDeploymentUrl('support')).toBe('http://localhost:3000/chat/support') + }) +}) diff --git a/apps/sim/lib/chat-deployments/urls.ts b/apps/sim/lib/chat-deployments/urls.ts new file mode 100644 index 00000000000..b0c49c38e4b --- /dev/null +++ b/apps/sim/lib/chat-deployments/urls.ts @@ -0,0 +1,35 @@ +import { isDev } from '@/lib/core/config/env-flags' +import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls' + +/** + * The public URL a deployed chat answers on. + * + * There is no chat subdomain: `proxy.ts` routes chat purely by the `/chat/` + * path, so the URL is the app host plus the identifier. The `www.` prefix is + * stripped because the deployed chat is served on the bare host. + * + * Single source of truth for the previously independent constructions — the + * deploy orchestration, the manage read, and the manage write — which had + * already drifted onto two different host helpers. + * + * `getBaseUrl` throws when `NEXT_PUBLIC_APP_URL` is unset, so it is called + * inside a guard: a self-host missing that variable must still be able to read + * and update a chat deployment, which is what `getEmailDomain` — the helper the + * manage routes derived their host from before this consolidation — already + * falls back for. + */ +export function buildChatDeploymentUrl(identifier: string): string { + let baseUrl: string + try { + baseUrl = getBaseUrl() + } catch { + return `${isDev ? 'http' : 'https'}://${getEmailDomain()}/chat/${identifier}` + } + try { + const url = new URL(baseUrl) + const host = url.host.startsWith('www.') ? url.host.slice('www.'.length) : url.host + return `${url.protocol}//${host}/chat/${identifier}` + } catch { + return `${baseUrl}/chat/${identifier}` + } +} diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 5f185b683f3..38ca4508f90 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -29,6 +29,7 @@ import { import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils' +import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' const logger = createLogger('CopilotChatPayload') const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000 @@ -247,6 +248,7 @@ async function buildIntegrationToolSchemasUncached( operation, description: getCopilotToolDescription(toolConfig, { isHosted, + hostedApiKey: deriveHostedApiKeySupport(toolConfig.hosting), fallbackName: toolId, appendEmailTagline: shouldAppendEmailTagline, }), diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index b41ce842b05..0067a672d99 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -26,14 +26,18 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/audit-logs.mdx', 'cli/authentication.mdx', 'cli/billing.mdx', + 'cli/blocks.mdx', + 'cli/chat-deployments.mdx', 'cli/commands.mdx', 'cli/configuration.mdx', + 'cli/connector-types.mdx', 'cli/credentials.mdx', 'cli/custom-tools.mdx', 'cli/files.mdx', 'cli/knowledge.mdx', 'cli/logs.mdx', 'cli/mcp-servers.mdx', + 'cli/meta.mdx', 'cli/output.mdx', 'cli/profiles.mdx', 'cli/reference.mdx', @@ -41,7 +45,9 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/secrets.mdx', 'cli/skills.mdx', 'cli/tables.mdx', + 'cli/tools.mdx', 'cli/troubleshooting.mdx', + 'cli/workflow-mcp-servers.mdx', 'cli/workflows.mdx', 'cli/workspaces.mdx', 'files.mdx', diff --git a/apps/sim/lib/copilot/sim-sandbox-projection.ts b/apps/sim/lib/copilot/sim-sandbox-projection.ts index 03f2edef75c..822fb3933ce 100644 --- a/apps/sim/lib/copilot/sim-sandbox-projection.ts +++ b/apps/sim/lib/copilot/sim-sandbox-projection.ts @@ -10,13 +10,3 @@ export const RESTRICTED_SIM_SANDBOX_INPUTS = new Map([ }, ], ]) - -/** Whether an edit_workflow operation tries to set or clear Function sandboxId. */ -export function operationsReferenceSimSandbox( - operations: ReadonlyArray<{ params?: Record }> -): boolean { - return operations.some((operation) => { - const inputs = operation.params?.inputs - return Boolean(inputs && typeof inputs === 'object' && 'sandboxId' in inputs) - }) -} diff --git a/apps/sim/lib/copilot/tools/descriptions.test.ts b/apps/sim/lib/copilot/tools/descriptions.test.ts index ef5e3a3871b..6ac4dc43c9c 100644 --- a/apps/sim/lib/copilot/tools/descriptions.test.ts +++ b/apps/sim/lib/copilot/tools/descriptions.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' import { getCopilotToolDescription } from './descriptions' describe('getCopilotToolDescription', () => { @@ -9,9 +10,11 @@ describe('getCopilotToolDescription', () => { id: 'brandfetch_search', name: 'Brandfetch Search', description: 'Search for brands by company name', - hosting: { apiKeyParam: 'apiKey' } as never, }, - { isHosted: false } + { + isHosted: false, + hostedApiKey: deriveHostedApiKeySupport({ apiKeyParam: 'apiKey' } as never), + } ) ).toBe('Search for brands by company name') }) @@ -23,9 +26,11 @@ describe('getCopilotToolDescription', () => { id: 'brandfetch_search', name: 'Brandfetch Search', description: 'Search for brands by company name', - hosting: { apiKeyParam: 'apiKey' } as never, }, - { isHosted: true } + { + isHosted: true, + hostedApiKey: deriveHostedApiKeySupport({ apiKeyParam: 'apiKey' } as never), + } ) ).toBe('Search for brands by company name API key is hosted by Sim.') }) @@ -37,9 +42,14 @@ describe('getCopilotToolDescription', () => { id: 'image_generate', name: 'Image Generate', description: 'Generate an image', - hosting: { apiKeyParam: 'apiKey', enabled: () => true } as never, }, - { isHosted: true } + { + isHosted: true, + hostedApiKey: deriveHostedApiKeySupport({ + apiKeyParam: 'apiKey', + enabled: () => true, + } as never), + } ) ).toBe( 'Generate an image API key is hosted by Sim when hosted-key support applies to the selected configuration.' @@ -53,9 +63,12 @@ describe('getCopilotToolDescription', () => { id: 'brandfetch_search', name: '', description: '', - hosting: { apiKeyParam: 'apiKey' } as never, }, - { isHosted: true, fallbackName: 'brandfetch_search' } + { + isHosted: true, + hostedApiKey: deriveHostedApiKeySupport({ apiKeyParam: 'apiKey' } as never), + fallbackName: 'brandfetch_search', + } ) ).toBe('brandfetch_search API key is hosted by Sim.') }) diff --git a/apps/sim/lib/copilot/tools/descriptions.ts b/apps/sim/lib/copilot/tools/descriptions.ts index 0defd9013c3..5b89d1e4871 100644 --- a/apps/sim/lib/copilot/tools/descriptions.ts +++ b/apps/sim/lib/copilot/tools/descriptions.ts @@ -1,3 +1,4 @@ +import type { HostedApiKeySupport } from '@/tools/hosted-api-key' import type { ToolConfig } from '@/tools/types' const HOSTED_API_KEY_NOTE = 'API key is hosted by Sim.' @@ -7,10 +8,18 @@ const EMAIL_TAGLINE_NOTE = 'Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.' const EMAIL_TAGLINE_TOOL_IDS = new Set(['gmail_send', 'gmail_send_v2', 'outlook_send']) +/** + * `hostedApiKey` is an option rather than a field read off `tool` because the + * two sources that can answer it differ: an executable `ToolConfig` carries the + * `hosting` closure (project it with `deriveHostedApiKeySupport`), while the + * generated tool metadata carries the derived answer directly. Taking it as an + * argument keeps one branch here and lets either source supply it. + */ export function getCopilotToolDescription( - tool: Pick, + tool: Pick, options?: { isHosted?: boolean + hostedApiKey?: HostedApiKeySupport fallbackName?: string appendEmailTagline?: boolean } @@ -18,13 +27,16 @@ export function getCopilotToolDescription( const baseDescription = tool.description || tool.name || options?.fallbackName || '' const notes: string[] = [] + const hostedApiKey = options?.hostedApiKey ?? 'none' if ( options?.isHosted && - tool.hosting && + hostedApiKey !== 'none' && !baseDescription.includes(HOSTED_API_KEY_NOTE) && !baseDescription.includes(CONDITIONAL_HOSTED_API_KEY_NOTE) ) { - notes.push(tool.hosting.enabled ? CONDITIONAL_HOSTED_API_KEY_NOTE : HOSTED_API_KEY_NOTE) + notes.push( + hostedApiKey === 'conditional' ? CONDITIONAL_HOSTED_API_KEY_NOTE : HOSTED_API_KEY_NOTE + ) } if ( diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index c8cc2fc7d1a..3413077b490 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -11,6 +11,7 @@ import { deployWorkflowMcpTool, undeployWorkflowMcpTool, } from '@/lib/mcp/application/workflow-deployments' +import { buildWorkflowMcpApiEndpoint, buildWorkflowMcpServerUrl } from '@/lib/mcp/urls' import { deployWorkflowChat, undeployWorkflowChat, @@ -19,10 +20,6 @@ import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/de import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' -function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { - return `${baseUrl}/api/v2/workflows/${workflowId}/execute` -} - function buildWorkflowRunStatusEndpoint( baseUrl: string, apiEndpoint: string, @@ -161,7 +158,7 @@ export async function executeDeployApi( return { success: false, error: result.error || 'Failed to undeploy workflow' } } const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) + const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) return { success: true, output: { @@ -228,7 +225,7 @@ export async function executeDeployApi( } const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) + const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) const isDeployed = Boolean(result.activeDeployment) @@ -289,7 +286,7 @@ export async function executeDeployChat( assertedWorkspaceId: context.workspaceId, }) const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) + const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) return { @@ -395,7 +392,7 @@ export async function executeDeployChat( }) const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) + const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) return { @@ -512,8 +509,8 @@ export async function executeDeployMcp( parameterDescriptions: params.parameterDescriptions, }) const baseUrl = getBaseUrl() - const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}` - const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) + const mcpServerUrl = buildWorkflowMcpServerUrl(serverId) + const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) const clientExamples = buildMcpClientExamples(result.server.name, mcpServerUrl) const toolId = result.tool.id const toolName = result.tool.toolName @@ -620,7 +617,7 @@ export async function executeRedeploy( return { success: false, error: unconfirmedDeploymentError } } const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) + const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) const isDeployed = Boolean(result.activeDeployment) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts new file mode 100644 index 00000000000..4c32b93369a --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Pins that the agent's block metadata still resolves tool params, outputs, and + * the hosted-key note after the projection moved to the shared catalog layer and + * off `@/tools/registry`. + * + * The sibling suite exercises this tool's gating against a mocked registry; this + * one runs it against the real block registry and the real generated tool + * metadata, because the thing worth proving is exactly that the metadata + * artifacts can answer everything the executable registry used to. + */ +vi.unmock('@/blocks/registry') + +const mocks = vi.hoisted(() => ({ + getUserPermissionConfig: vi.fn(), + isDeploymentAvailable: vi.fn(() => true), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/integrations/availability.server', () => ({ + isIntegrationDeploymentAvailableForVisibility: mocks.isDeploymentAvailable, +})) + +import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' + +interface AgentBlockMetadata { + blockType: string + name: string + description: string + operations?: Record< + string, + { name: string; description?: string; inputs: { required: unknown[]; optional: unknown[] } } + > + inputs?: { required: unknown[]; optional: unknown[] } +} + +describe('get_blocks_metadata against the real registries', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null }) + mocks.isDeploymentAvailable.mockReturnValue(true) + }) + + it('resolves an integration block’s operations and their tool-derived inputs', async () => { + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['slack'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + const slack = result.metadata.slack as AgentBlockMetadata + expect(slack.blockType).toBe('slack') + expect(slack.name).toBe('Slack') + + const operations = slack.operations ?? {} + expect(Object.keys(operations).length).toBeGreaterThan(0) + for (const [operationId, operation] of Object.entries(operations)) { + expect(operation.name, operationId).toBeTruthy() + expect(operation.inputs, operationId).toBeDefined() + } + + /** + * The point of the rewrite: these inputs come from the generated tool + * metadata. An empty set everywhere means the tool params stopped being + * resolved, which is what reaching for the executable registry used to buy. + */ + const parameterCount = Object.values(operations).reduce( + (total, operation) => + total + operation.inputs.required.length + operation.inputs.optional.length, + 0 + ) + expect(parameterCount).toBeGreaterThan(0) + }) + + it('still describes the control-flow blocks it defines itself', async () => { + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['loop'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + const loop = result.metadata.loop as AgentBlockMetadata + expect(loop.blockType).toBe('loop') + expect(loop.inputs?.required.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts index f286dc7a0c5..e83beb2ba4c 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -17,11 +17,11 @@ vi.mock('@/lib/integrations/availability.server', () => ({ isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable, })) -import { - computeBlockLevelInputs, - getBlocksMetadataServerTool, -} from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' +import { computeBlockLevelInputs } from '@/lib/catalog/projection/block-detail' +import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' import { MothershipBlock } from '@/blocks/blocks/mothership' +import { getBlock } from '@/blocks/registry' +import type { BlockConfig } from '@/blocks/types' describe('get blocks metadata', () => { beforeEach(() => { @@ -37,6 +37,55 @@ describe('get blocks metadata', () => { expect(definitions).not.toHaveProperty('mountedSecrets') }) + /** + * A sub-block `condition` declared as a function is invoked during projection. + * A throwing one is an authoring defect worth surfacing, but it must cost the + * agent one block rather than every block it asked for — the projection call + * used to sit outside the per-block guard, so one bad condition emptied the + * whole response. + */ + it('drops only the block whose projection throws', async () => { + const healthy = { + type: 'slack', + name: 'Slack', + description: 'Send messages.', + category: 'tools', + bgColor: '#000000', + icon: () => null, + subBlocks: [], + tools: { access: [] }, + inputs: {}, + outputs: {}, + } as unknown as BlockConfig + const poisoned = { + ...healthy, + type: 'slack_broken', + name: 'Broken', + subBlocks: [ + { + id: 'text', + type: 'long-input', + condition: () => { + throw new Error('condition dereferences values') + }, + }, + ], + } as unknown as BlockConfig + + mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null }) + vi.mocked(getBlock).mockImplementation((type: string) => + type === 'slack_broken' ? poisoned : healthy + ) + + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['slack_broken', 'slack'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.metadata).not.toHaveProperty('slack_broken') + expect(result.metadata).toHaveProperty('slack') + }) + it('keeps access-control-exempt and special blocks under a restrictive allowlist', async () => { const result = await getBlocksMetadataServerTool.execute( { blockIds: ['start_trigger', 'loop', 'slack', 'notion'] }, diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 9c46dc976cc..301116d7c7e 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -3,6 +3,14 @@ import { join } from 'path' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { z } from 'zod' +import { + type CatalogBlockDetail, + type CatalogInputDefinition, + projectBlockDetail, + splitFieldsByOperation, +} from '@/lib/catalog/projection/block-detail' +import type { CatalogSubBlock } from '@/lib/catalog/projection/subblock' +import type { CatalogToolSummary } from '@/lib/catalog/projection/tool' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-flags' @@ -10,73 +18,33 @@ import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integration import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { isCustomBlockType } from '@/blocks/custom/build-config' import { getBlock } from '@/blocks/registry' -import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' +import { AuthMode, type BlockConfig, type SubBlockConfig } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' -import { PROVIDER_DEFINITIONS } from '@/providers/models' -import { tools as toolsRegistry } from '@/tools/registry' -import { getTrigger, isTriggerValid } from '@/triggers' -import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' -interface CopilotSubblockMetadata { - id: string - type: string - title?: string - required?: boolean - description?: string - placeholder?: string - layout?: string - mode?: string - hidden?: boolean - condition?: any - // Dropdown/combobox options - options?: { id: string; label?: string; hasIcon?: boolean }[] - // Numeric constraints - min?: number - max?: number - step?: number - integer?: boolean - // Text input properties - rows?: number - password?: boolean - multiSelect?: boolean - // Code/generation properties - language?: string - generationType?: string - // OAuth/credential properties - serviceId?: string - requiredScopes?: string[] - // File properties - mimeType?: string - acceptedTypes?: string - multiple?: boolean - maxSize?: number - // Other properties - connectionDroppable?: boolean - columns?: string[] - wandConfig?: any - availableTriggers?: string[] - triggerProvider?: string - dependsOn?: string[] - canonicalParamId?: string - defaultValue?: any - value?: string // 'function' if it's a function, undefined otherwise -} +/** + * The block shape this tool reports, projected by the shared catalog projection + * (`@/lib/catalog/projection`) and then reshaped for the agent below. + * + * The projection reads tool params and outputs from `@/tools/metadata` and + * `@/tools/metadata-outputs` rather than the executable registry, which is what + * keeps this module's graph off the ~4,700 modules `@/tools/registry` costs. + */ +type CopilotSubblockMetadata = CatalogSubBlock interface CopilotToolMetadata { id: string name: string description?: string - inputs?: any - outputs?: any + inputs?: Record + outputs?: Record } interface CopilotTriggerMetadata { id: string - outputs?: any - configFields?: any + outputs?: Record + configFields?: Record } interface CopilotBlockMetadata { @@ -109,6 +77,45 @@ interface CopilotBlockMetadata { const GetBlocksMetadataInputSchema = z.object({ blockIds: z.array(z.string()).min(1) }) const GetBlocksMetadataResultSchema = z.object({ metadata: z.record(z.string(), z.any()) }) +/** + * Prompt-shaped tool description: the raw text plus the hosted-key note the + * agent needs. The public catalog publishes the raw description and a structured + * `hostedApiKey` instead, which is why this stays a Copilot concern rather than + * moving into the shared projection. + */ +function describeToolForAgent(tool: CatalogToolSummary): string { + return getCopilotToolDescription(tool, { + isHosted, + hostedApiKey: tool.hostedApiKey, + fallbackName: tool.id, + }) +} + +/** Reshapes the shared block projection into the agent-facing metadata above. */ +function toCopilotBlockMetadata(detail: CatalogBlockDetail): CopilotBlockMetadata { + return removeNullish({ + id: detail.id, + name: detail.name, + description: detail.longDescription || detail.description || '', + bestPractices: detail.bestPractices, + inputSchema: detail.inputSchema, + inputDefinitions: detail.inputDefinitions, + triggerAllowed: detail.triggerAllowed, + authType: resolveAuthType(detail.authMode as AuthMode | undefined), + tools: detail.tools.map((tool) => ({ + id: tool.id, + name: tool.name, + description: tool.description, + inputs: tool.params, + outputs: tool.outputs, + })), + triggers: detail.triggers, + operationInputSchema: detail.operationInputSchema, + operations: detail.operations, + outputs: detail.outputs, + }) as CopilotBlockMetadata +} + export const getBlocksMetadataServerTool: BaseServerTool< z.infer, z.infer @@ -150,25 +157,25 @@ export const getBlocksMetadataServerTool: BaseServerTool< continue } - let metadata: any + let metadata: CopilotBlockMetadata if (specialBlock) { - const { commonParameters, operationParameters } = splitParametersByOperation( - specialBlock.subBlocks || [], - specialBlock.inputs || {} + const inputDefinitions: Record = specialBlock.inputs || {} + const { commonFields, operationFields } = splitFieldsByOperation( + (specialBlock.subBlocks || []) as SubBlockConfig[], + inputDefinitions ) metadata = { id: specialBlock.id, name: specialBlock.name, description: specialBlock.description || '', - inputSchema: commonParameters, - inputDefinitions: specialBlock.inputs || {}, + inputSchema: commonFields, + inputDefinitions, tools: [], triggers: [], - operationInputSchema: operationParameters, + operationInputSchema: operationFields, outputs: specialBlock.outputs, } - ;(metadata as any).subBlocks = undefined } else { const blockConfig: BlockConfig | undefined = getBlock(blockId) if (!blockConfig) { @@ -190,169 +197,27 @@ export const getBlocksMetadataServerTool: BaseServerTool< continue } - if (isCustomBlockType(blockId)) { - // Custom (deploy-as-block) blocks run a bound workflow via an internal - // `workflow_executor`; the agent never configures a workflowId/inputMapping. - // Present it as self-contained: its visible input fields + curated outputs, - // no tools/operations. - const visibleSubBlocks = (blockConfig.subBlocks || []).filter( - (sb) => !sb.hidden && !sb.hideFromCopilot - ) - const outputs = blockConfig.outputs - ? Object.fromEntries( - Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) - ) - : undefined - metadata = { - id: blockId, - name: blockConfig.name || blockId, - description: blockConfig.longDescription || blockConfig.description || '', - bestPractices: blockConfig.bestPractices, - inputSchema: visibleSubBlocks.map(processSubBlock), - inputDefinitions: {}, - tools: [], - triggers: [], - operationInputSchema: {}, - outputs, - } - result[blockId] = removeNullish(metadata) as CopilotBlockMetadata - continue - } - - const tools: CopilotToolMetadata[] = Array.isArray(blockConfig.tools?.access) - ? blockConfig.tools!.access.map((toolId) => { - const tool = toolsRegistry[toolId] - if (!tool) return { id: toolId, name: toolId } - return { - id: toolId, - name: tool.name || toolId, - description: getCopilotToolDescription(tool, { - isHosted, - fallbackName: toolId, - }), - inputs: tool.params || {}, - outputs: tool.outputs || {}, - } + /** + * One block's projection must not fail the whole call. A sub-block + * `condition` declared as a function is invoked during projection, and a + * throwing one propagates — the `catalog-sweep` test proves no + * registered block has one, but a runtime-built custom (deploy-as-block) + * config is not swept, so this keeps the blast radius to the block that + * carries the defect rather than every block the agent asked for. + */ + try { + metadata = toCopilotBlockMetadata( + projectBlockDetail(blockConfig, { + deployment: { hostedKeys: isHosted }, + describeTool: describeToolForAgent, }) - : [] - - const triggers: CopilotTriggerMetadata[] = [] - const availableTriggerIds = blockConfig.triggers?.available || [] - for (const tid of availableTriggerIds) { - if (!isTriggerValid(tid)) { - logger.debug('Invalid trigger ID found in block config', { blockId, triggerId: tid }) - continue - } - - const trig = getTrigger(tid) - - const configFields: Record = {} - for (const subBlock of trig.subBlocks) { - if ( - (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') && - !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id) - ) { - const fieldDef: any = { - type: subBlock.type, - required: subBlock.required || false, - } - - if (subBlock.title) fieldDef.title = subBlock.title - if (subBlock.description) fieldDef.description = subBlock.description - if (subBlock.placeholder) fieldDef.placeholder = subBlock.placeholder - if (subBlock.defaultValue !== undefined) fieldDef.default = subBlock.defaultValue - - if (subBlock.options && Array.isArray(subBlock.options)) { - fieldDef.options = subBlock.options.map((opt: any) => ({ - id: opt.id, - label: opt.label || opt.id, - })) - } - - if (subBlock.condition) { - const cond = - typeof subBlock.condition === 'function' - ? subBlock.condition() - : subBlock.condition - if (cond) { - fieldDef.condition = cond - } - } - - configFields[subBlock.id] = fieldDef - } - } - - triggers.push({ - id: tid, - outputs: trig.outputs || {}, - configFields, + ) + } catch (error) { + logger.error('Failed to project block metadata', { + blockId, + error: toError(error).message, }) - } - - const hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) - const blockInputs = computeBlockLevelInputs(blockConfig, hiddenParamKeys) - const { commonParameters, operationParameters } = splitParametersByOperation( - Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter( - (sb) => - !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' - ) - : [], - blockInputs - ) - - const operationInputs = computeOperationLevelInputs(blockConfig) - const operationIds = resolveOperationIds(blockConfig, operationParameters) - const operations: Record = {} - for (const opId of operationIds) { - const resolvedToolId = resolveToolIdForOperation(blockConfig, opId) - const toolCfg = resolvedToolId ? toolsRegistry[resolvedToolId] : undefined - const toolParams: Record = toolCfg?.params || {} - const toolOutputs: Record = toolCfg?.outputs - ? Object.fromEntries( - Object.entries(toolCfg.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) - ) - : {} - const filteredToolParams: Record = {} - for (const [k, v] of Object.entries(toolParams)) { - if (!(k in blockInputs) && !hiddenParamKeys.has(k)) filteredToolParams[k] = v - } - operations[opId] = { - toolId: resolvedToolId, - toolName: toolCfg?.name || resolvedToolId, - description: toolCfg - ? getCopilotToolDescription(toolCfg, { - isHosted, - fallbackName: resolvedToolId, - }) - : undefined, - inputs: { ...filteredToolParams, ...(operationInputs[opId] || {}) }, - outputs: toolOutputs, - inputSchema: operationParameters[opId] || [], - } - } - - const filteredOutputs = blockConfig.outputs - ? Object.fromEntries( - Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) - ) - : undefined - - metadata = { - id: blockId, - name: blockConfig.name || blockId, - description: blockConfig.longDescription || blockConfig.description || '', - bestPractices: blockConfig.bestPractices, - inputSchema: commonParameters, - inputDefinitions: blockInputs, - triggerAllowed: !!blockConfig.triggerAllowed, - authType: resolveAuthType(blockConfig.authMode), - tools, - triggers, - operationInputSchema: operationParameters, - operations, - outputs: filteredOutputs, + continue } } @@ -379,9 +244,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< }) } - if (metadata) { - result[blockId] = removeNullish(metadata) as CopilotBlockMetadata - } + result[blockId] = metadata } const transformedResult: Record = {} @@ -689,85 +552,6 @@ function generateInputExample(schema: CopilotSubblockMetadata, inputDef?: any): return undefined } } - -function processSubBlock(sb: any): CopilotSubblockMetadata { - const processed: CopilotSubblockMetadata = { - id: sb.id, - type: sb.type, - } - - const optionalFields = { - title: sb.title, - required: sb.required, - description: sb.description, - placeholder: sb.placeholder, - layout: sb.layout, - mode: sb.mode, - hidden: sb.hidden, - canonicalParamId: sb.canonicalParamId, - defaultValue: sb.defaultValue, - - // Numeric constraints - min: sb.min, - max: sb.max, - step: sb.step, - integer: sb.integer, - - // Text input properties - rows: sb.rows, - password: sb.password, - multiSelect: sb.multiSelect, - - // Code/generation properties - language: sb.language, - generationType: sb.generationType, - - // OAuth/credential properties - serviceId: sb.serviceId, - requiredScopes: sb.requiredScopes, - - // File properties - mimeType: sb.mimeType, - acceptedTypes: sb.acceptedTypes, - multiple: sb.multiple, - maxSize: sb.maxSize, - - // Other properties - connectionDroppable: sb.connectionDroppable, - columns: sb.columns, - wandConfig: sb.wandConfig, - availableTriggers: sb.availableTriggers, - triggerProvider: sb.triggerProvider, - dependsOn: sb.dependsOn, - } - - // Add non-null optional fields - for (const [key, value] of Object.entries(optionalFields)) { - if (value !== undefined && value !== null) { - ;(processed as any)[key] = value - } - } - - // Handle condition normalization - const condition = normalizeCondition(sb.condition) - if (condition !== undefined) { - processed.condition = condition - } - - // Handle value field (check if it's a function) - if (typeof sb.value === 'function') { - processed.value = 'function' - } - - // Process options with icon detection - const options = resolveSubblockOptions(sb) - if (options) { - processed.options = options - } - - return processed -} - function resolveAuthType( authMode: AuthMode | undefined ): 'OAuth' | 'API Key' | 'Bot Token' | undefined { @@ -777,150 +561,6 @@ function resolveAuthType( if (authMode === AuthMode.BotToken) return 'Bot Token' return undefined } - -/** - * Gets all available models from PROVIDER_DEFINITIONS as static options. - * This provides fallback data when store state is not available server-side. - * Excludes dynamic providers (ollama, ollama-cloud, vllm, openrouter, fireworks) which require runtime fetching. - */ -function getStaticModelOptions(): { id: string; label?: string }[] { - const models: { id: string; label?: string }[] = [] - - for (const provider of Object.values(PROVIDER_DEFINITIONS)) { - // Skip providers with dynamic/fetched models - if ( - provider.id === 'ollama' || - provider.id === 'ollama-cloud' || - provider.id === 'vllm' || - provider.id === 'openrouter' || - provider.id === 'fireworks' || - provider.id === 'together' || - provider.id === 'baseten' - ) { - continue - } - if (provider?.models) { - for (const model of provider.models) { - // Exclude retired models — the agent must not receive a model whose API - // calls fail (mirrors the user picker + VFS menu). - if (model.sunset?.status === 'deprecated') continue - models.push({ id: model.id, label: model.id }) - } - } - } - - return models -} - -/** - * Attempts to call a dynamic options function with fallback data injected. - * When the function accesses store state that's unavailable server-side, - * this provides static fallback data from known sources. - * - * @param optionsFn - The options function to call - * @returns Options array or undefined if options cannot be resolved - */ -function callOptionsWithFallback( - optionsFn: () => any[] -): { id: string; label?: string; hasIcon?: boolean }[] | undefined { - // Get static model data to use as fallback - const staticModels = getStaticModelOptions() - - // Create a mock providers state with static data - const mockProvidersState = { - providers: { - base: { models: staticModels.map((m) => m.id) }, - ollama: { models: [] }, - 'ollama-cloud': { models: [] }, - vllm: { models: [] }, - litellm: { models: [] }, - openrouter: { models: [] }, - fireworks: { models: [] }, - together: { models: [] }, - baseten: { models: [] }, - }, - } - - // Store original getState if it exists - let originalGetState: (() => any) | undefined - let store: any - - try { - // Try to get the providers store module - // eslint-disable-next-line @typescript-eslint/no-require-imports - store = require('@/stores/providers') - if (store?.useProvidersStore?.getState) { - originalGetState = store.useProvidersStore.getState - // Temporarily replace getState with our mock - store.useProvidersStore.getState = () => mockProvidersState - } - } catch { - // Store module not available, continue with mock - } - - try { - const result = optionsFn() - return result - } finally { - // Restore original getState - if (store?.useProvidersStore && originalGetState) { - store.useProvidersStore.getState = originalGetState - } - } -} - -function resolveSubblockOptions( - sb: any -): { id: string; label?: string; hasIcon?: boolean }[] | undefined { - // Skip if subblock uses fetchOptions (async network calls) - if (sb.fetchOptions) { - return undefined - } - - let rawOptions: any[] | undefined - - try { - if (typeof sb.options === 'function') { - // Try calling with fallback data injection for store-dependent options - rawOptions = callOptionsWithFallback(sb.options) - } else { - rawOptions = sb.options - } - } catch { - // Options function failed even with fallback, skip - return undefined - } - - if (!Array.isArray(rawOptions) || rawOptions.length === 0) { - return undefined - } - - const normalized = rawOptions - .map((opt: any) => { - if (!opt) return undefined - - const id = typeof opt === 'object' ? opt.id : opt - if (id === undefined || id === null) return undefined - - const result: { id: string; label?: string; hasIcon?: boolean } = { - id: String(id), - } - - if (typeof opt === 'object' && typeof opt.label === 'string') { - result.label = opt.label - } - - if (typeof opt === 'object' && opt.icon) { - result.hasIcon = true - } - - return result - }) - .filter((o): o is { id: string; label?: string; hasIcon?: boolean } => o !== undefined) - - return normalized.length > 0 ? normalized : undefined -} - function removeNullish(obj: any): any { if (!obj || typeof obj !== 'object') return obj @@ -934,168 +574,6 @@ function removeNullish(obj: any): any { return cleaned } - -function normalizeCondition(condition: any): any | undefined { - try { - if (!condition) return undefined - if (typeof condition === 'function') { - return condition() - } - return condition - } catch { - return undefined - } -} - -function splitParametersByOperation( - subBlocks: any[], - blockInputsForDescriptions?: Record -): { - commonParameters: CopilotSubblockMetadata[] - operationParameters: Record -} { - const commonParameters: CopilotSubblockMetadata[] = [] - const operationParameters: Record = {} - - for (const sb of subBlocks || []) { - const cond = normalizeCondition(sb.condition) - const processed = processSubBlock(sb) - - if (cond && cond.field === 'operation' && !cond.not && cond.value !== undefined) { - const values: any[] = Array.isArray(cond.value) ? cond.value : [cond.value] - for (const v of values) { - const key = String(v) - if (!operationParameters[key]) operationParameters[key] = [] - operationParameters[key].push(processed) - } - } else { - // Override description from inputDefinitions if available (by id or canonicalParamId) - if (blockInputsForDescriptions) { - const candidates = [sb.id, sb.canonicalParamId].filter(Boolean) - for (const key of candidates) { - const bi = (blockInputsForDescriptions as any)[key as string] - if (bi && typeof bi.description === 'string') { - processed.description = bi.description - break - } - } - } - commonParameters.push(processed) - } - } - - return { commonParameters, operationParameters } -} - -function getCopilotHiddenParamKeys(blockConfig: BlockConfig): Set { - const hiddenParamKeys = new Set() - for (const subBlock of blockConfig.subBlocks ?? []) { - if (!subBlock.hideFromCopilot) continue - if (subBlock.id) hiddenParamKeys.add(subBlock.id) - if (subBlock.canonicalParamId) hiddenParamKeys.add(subBlock.canonicalParamId) - } - return hiddenParamKeys -} - -export function computeBlockLevelInputs( - blockConfig: BlockConfig, - hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) -): Record { - const inputs = blockConfig.inputs || {} - const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter( - (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' - ) - : [] - - const byParamKey: Record = {} - for (const sb of subBlocks) { - if (sb.id) { - byParamKey[sb.id] = byParamKey[sb.id] || [] - byParamKey[sb.id].push(sb) - } - if (sb.canonicalParamId) { - byParamKey[sb.canonicalParamId] = byParamKey[sb.canonicalParamId] || [] - byParamKey[sb.canonicalParamId].push(sb) - } - } - - const blockInputs: Record = {} - for (const key of Object.keys(inputs)) { - if (hiddenParamKeys.has(key)) continue - const sbs = byParamKey[key] || [] - const isOperationGated = sbs.some((sb) => { - const cond = normalizeCondition(sb.condition) - return cond && cond.field === 'operation' && !cond.not && cond.value !== undefined - }) - if (!isOperationGated) { - blockInputs[key] = inputs[key] - } - } - - return blockInputs -} - -function computeOperationLevelInputs( - blockConfig: BlockConfig -): Record> { - const inputs = blockConfig.inputs || {} - const subBlocks = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter( - (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' - ) - : [] - - const opInputs: Record> = {} - - for (const sb of subBlocks) { - const cond = normalizeCondition(sb.condition) - if (!cond || cond.field !== 'operation' || cond.not) continue - const keys: string[] = [] - if (sb.canonicalParamId) keys.push(sb.canonicalParamId) - if (sb.id) keys.push(sb.id) - const values = Array.isArray(cond.value) ? cond.value : [cond.value] - for (const key of keys) { - if (!(key in inputs)) continue - for (const v of values) { - const op = String(v) - if (!opInputs[op]) opInputs[op] = {} - opInputs[op][key] = inputs[key] - } - } - } - - return opInputs -} - -function resolveOperationIds( - blockConfig: BlockConfig, - operationParameters: Record -): string[] { - const opBlock = (blockConfig.subBlocks || []).find((sb) => sb.id === 'operation') - if (opBlock && Array.isArray(opBlock.options)) { - const ids = opBlock.options.map((o) => o.id).filter(Boolean) - if (ids.length > 0) return ids - } - return Object.keys(operationParameters) -} - -function resolveToolIdForOperation(blockConfig: BlockConfig, opId: string): string | undefined { - try { - const toolSelector = blockConfig.tools?.config?.tool - if (typeof toolSelector === 'function') { - const maybeToolId = toolSelector({ operation: opId }) - if (typeof maybeToolId === 'string') return maybeToolId - } - } catch (error) { - const toolLogger = createLogger('GetBlocksMetadataServerTool') - toolLogger.warn('Failed to resolve tool ID for operation', { - error: toError(error).message, - }) - } - return undefined -} - const DOCS_FILE_MAPPING: Record = {} const SPECIAL_BLOCKS_METADATA: Record = { diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index f6ce6df1cb5..712c9e85f60 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -26,7 +26,6 @@ import { updateTableColumnUseCase, } from '@/lib/table/application/columns' import { - copilotBatchUpdateRows, copilotDeleteRowsByFilter, copilotUpdateRowsByFilter, } from '@/lib/table/application/copilot-bulk-rows' @@ -35,6 +34,7 @@ import { deleteTableGroupUseCase, } from '@/lib/table/application/groups' import { + batchUpdateTableRows, createTableRows, deleteTableRow, deleteTableRows, @@ -79,6 +79,13 @@ type UserTableResult = { data?: any } +/** + * Copilot's own batch ceiling, deliberately looser than the 1000 the internal + * and v2 batch-update contracts declare: Copilot parses no contract, so this is + * the only place the tool can refuse an oversized batch with a message the model + * can act on rather than a thrown domain error — and a batch it accepts today + * must keep working. + */ const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE function resolveAuthorizedWorkflowOutputs( @@ -707,11 +714,13 @@ export const userTableServerTool: BaseServerTool assertNotAborted() const result = await executeCopilotTableUseCase( context, - copilotBatchUpdateRows, + batchUpdateTableRows, { tableId: args.tableId, assertedWorkspaceId: workspaceId, updates: updates as Array<{ rowId: string; data: RowData }>, + strictWrite: false, + dataKeying: 'names' as const, }, { tableId: args.tableId } ) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index bfc08de1e36..f7016538bdc 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -1,430 +1,135 @@ -import { db } from '@sim/db' -import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - assertWorkflowMutable, - authorizeWorkflowByWorkspacePermission, -} from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' +import { executeCopilotWorkflowUseCase } from '@/lib/copilot/application/execute-workflow-use-case' import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' -import { operationsReferenceSimSandbox } from '@/lib/copilot/sim-sandbox-projection' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { env } from '@/lib/core/config/env' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { getSocketServerUrl } from '@/lib/core/utils/urls' -import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { - applyTargetedLayout, - getTargetedLayoutImpact, - transferBlockHeights, -} from '@/lib/workflows/autolayout' -import { - DEFAULT_HORIZONTAL_SPACING, - DEFAULT_VERTICAL_SPACING, -} from '@/lib/workflows/autolayout/constants' -import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { - loadWorkflowFromNormalizedTables, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/persistence/utils' + type ApplyWorkflowOperationsResult, + applyWorkflowOperations, +} from '@/lib/workflows/application/apply-workflow-operations' +import { formatWorkflowLintMessage, hasWorkflowLintIssues } from '@/lib/workflows/editing/lint' +import type { EditWorkflowParams, SkippedItem } from '@/lib/workflows/editing/types' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' -import { withBlockVisibility } from '@/blocks/visibility/server-context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation' -import { applyOperationsToWorkflowState } from './engine' -import { - collectWorkflowFieldIssues, - formatWorkflowLintMessage, - hasWorkflowLintIssues, - lintEditedWorkflowState, - type WorkflowLintReport, - type WorkflowLintUnresolvedReference, -} from './lint' -import { type EditWorkflowParams, isDeferredSkippedItem, type ValidationError } from './types' -import { - collectUnresolvedAgentToolReferences, - collectUnresolvedReferences, - preValidateCredentialInputs, - UNRESOLVABLE_AT_LINT_NOTE, -} from './validation' -async function getCurrentWorkflowStateFromDb( - workflowId: string -): Promise<{ workflowState: any; subBlockValues: Record> }> { - const logger = createLogger('EditWorkflowServerTool') - const [workflowRecord] = await db - .select() - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) - if (!workflowRecord) - throw new OrchestrationError('not_found', `Workflow ${workflowId} not found in database`) - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) throw new Error('Workflow has no normalized data') - - const { state: validatedState, warnings } = normalizeWorkflowState({ - blocks: normalized.blocks, - edges: normalized.edges, - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - }) +const logger = createLogger('EditWorkflowServerTool') + +/** + * Re-states a `not_found` from the use case in terms the model can act on (#6918). + * + * The message is deliberately Copilot's rather than the use case's: it names + * `workflows/**` + '/meta.json', a path that exists only in the copilot VFS, so an + * HTTP caller hitting `POST /api/v2/workflows/{workflowId}/operations` would be + * told to look somewhere it cannot reach. Every other classification is passed + * through untouched. + */ +function enrichWorkflowNotFound(error: unknown, workflowId: string): unknown { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return new OrchestrationError( + 'not_found', + `Workflow not found: ${workflowId}. Pass the workflow's canonical id (copy it from ` + + `workflows/**` + + `/meta.json or the tool result that created it) — a workflow name or @-mention is not an id.` + ) + } + return error +} - if (warnings.length > 0) { - logger.warn('Normalized workflow state loaded from DB for copilot', { - workflowId, - warningCount: warnings.length, - warnings, - }) +function mapSkippedItem(item: SkippedItem) { + return { + type: item.type, + operationType: item.operationType, + blockId: item.blockId, + reason: item.reason, + ...(item.details && { details: item.details }), } +} - const subBlockValues: Record> = {} - Object.entries(validatedState.blocks).forEach(([blockId, block]) => { - subBlockValues[blockId] = {} - Object.entries((block as any).subBlocks || {}).forEach(([subId, sub]) => { - if ((sub as any).value !== undefined) subBlockValues[blockId][subId] = (sub as any).value - }) - }) - return { workflowState: validatedState, subBlockValues } +function parseCurrentUserWorkflow(currentUserWorkflow: string): Record { + try { + return JSON.parse(currentUserWorkflow) + } catch (error) { + logger.error('Failed to parse currentUserWorkflow', error) + throw new OrchestrationError('validation', 'Invalid currentUserWorkflow format') + } } +/** + * Copilot's surface over the shared `workflows.operations.apply` use case. + * + * Owns only what a surface owns: argument shaping, abort checkpoints, and the + * tool result the model reads. Authorization, the lock and plan gates, the edit + * engine, persistence, semantic audit, and the realtime notification all live in + * the application use case, which `POST /api/v2/workflows/{workflowId}/operations` + * enters through as well. + * + * `currentUserWorkflow` — the unsaved canvas the user is looking at — is passed + * through as `baseGraph`, which the use case honours only for a delegated + * principal. No other surface can supply it. + */ export const editWorkflowServerTool: BaseServerTool = { name: EditWorkflow.id, async execute(params: EditWorkflowParams, context?: ServerToolContext): Promise { - const logger = createLogger('EditWorkflowServerTool') const { operations, workflowId, currentUserWorkflow } = params if (!Array.isArray(operations) || operations.length === 0) { throw new OrchestrationError('validation', 'operations are required and must be an array') } if (!workflowId) throw new OrchestrationError('validation', 'workflowId is required') - if (!context?.userId) { - throw new Error('Unauthorized workflow access') - } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: context.userId, - action: 'write', - }) - if (!authorization.allowed) { - // Classified, not a bare Error: the copilot error projection passes a - // classified message through to the model verbatim, while an - // unclassified throw collapses into "system error, please retry" — - // which invites blind retries of a call that can never succeed. - throw new OrchestrationError( - authorization.status === 404 ? 'not_found' : 'forbidden', - authorization.status === 404 - ? `Workflow not found: ${workflowId}. Pass the workflow's canonical id (copy it from workflows/**/meta.json or the tool result that created it) — a workflow name or @-mention is not an id.` - : authorization.message || 'Unauthorized workflow access' - ) - } - - await assertWorkflowMutable(workflowId) - - const workspaceId = authorization.workflow?.workspaceId ?? undefined - const workflowName = authorization.workflow?.name ?? undefined - - if ( - operationsReferenceSimSandbox(operations) && - (!workspaceId || !(await hasWorkspaceSandboxAccess(workspaceId))) - ) { - throw new OrchestrationError('forbidden', MAX_PLAN_REQUIRED) - } logger.info('Executing edit_workflow', { operationCount: operations.length, workflowId, hasCurrentUserWorkflow: !!currentUserWorkflow, - chatId: context.chatId, + chatId: context?.chatId, }) assertServerToolNotAborted(context) - let workflowState: any - if (currentUserWorkflow) { - try { - workflowState = JSON.parse(currentUserWorkflow) - } catch (error) { - logger.error('Failed to parse currentUserWorkflow', error) - throw new OrchestrationError('validation', 'Invalid currentUserWorkflow format') - } - } else { - const fromDb = await getCurrentWorkflowStateFromDb(workflowId) - workflowState = fromDb.workflowState - } - - const [permissionConfig, blockVisibility] = await Promise.all([ - workspaceId ? getUserPermissionConfig(context.userId, workspaceId) : null, - getBlockVisibilityForCopilot(context.userId, workspaceId), - ]) - - // Pre-validate credential and apiKey inputs before applying operations - // This filters out invalid credentials and apiKeys for hosted models - let operationsToApply = operations - const credentialErrors: ValidationError[] = [] - if (context?.userId) { - const { filteredOperations, errors: credErrors } = await preValidateCredentialInputs( + const result: ApplyWorkflowOperationsResult = await executeCopilotWorkflowUseCase( + context, + applyWorkflowOperations, + { + workflowId, operations, - { userId: context.userId, workspaceId }, - workflowState - ) - operationsToApply = filteredOperations - credentialErrors.push(...credErrors) - } - - // Apply operations directly to the workflow state - const { - state: modifiedWorkflowState, - validationErrors, - skippedItems, - } = await withBlockVisibility(blockVisibility, async () => - applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig) - ) - - // Add credential validation errors - validationErrors.push(...credentialErrors) - - // Resolve credential/resource references against the workspace (Tier 2). - // Includes oauth-input credentials and only the active canonical member, so a - // credential "set in basic mode but unresolved in the dropdown" is caught. - let unresolvedReferences: WorkflowLintUnresolvedReference[] = [] - if (context?.userId) { - try { - unresolvedReferences = await collectUnresolvedReferences(modifiedWorkflowState, { - userId: context.userId, - workspaceId, - }) - // Back-compat: also surface unresolved references through the input-validation channel. - validationErrors.push( - ...unresolvedReferences.map((ref) => ({ - blockId: ref.blockId, - blockType: ref.blockType ?? 'unknown', - field: ref.field, - value: ref.value, - error: ref.reason, - })) - ) - } catch (error) { - logger.warn('Selector ID validation failed', { - error: toError(error).message, - }) + ...(currentUserWorkflow + ? { baseGraph: parseCurrentUserWorkflow(currentUserWorkflow) } + : {}), + checkAborted: () => assertServerToolNotAborted(context), } - - // Resolve agent-block tool/skill references (custom tools, MCP servers, - // skills). A well-shaped entry whose id does not resolve is dropped at - // runtime, so the agent silently loses the tool/skill - surface it through - // the same lint + input-validation channels as credential/resource refs. - try { - const toolReferences = await collectUnresolvedAgentToolReferences(modifiedWorkflowState, { - userId: context.userId, - workspaceId, - }) - unresolvedReferences.push(...toolReferences) - validationErrors.push( - ...toolReferences.map((ref) => ({ - blockId: ref.blockId, - blockType: ref.blockType ?? 'agent', - field: ref.field, - value: ref.value, - error: ref.reason, - })) - ) - } catch (error) { - logger.warn('Agent tool/skill reference validation failed', { - error: toError(error).message, - }) - } - } - - // Validate the workflow state - const validation = validateWorkflowState(modifiedWorkflowState, { sanitize: true }) - - if (!validation.valid) { - logger.error('Edited workflow state is invalid', { - errors: validation.errors, - warnings: validation.warnings, - }) - throw new OrchestrationError( - 'validation', - `Invalid edited workflow: ${validation.errors.join('; ')}` - ) - } - - if (validation.warnings.length > 0) { - logger.warn('Edited workflow validation warnings', { - warnings: validation.warnings, - }) - } - - // Extract and persist custom tools to database (reuse workspaceId from selector validation) - if (context?.userId && workspaceId) { - try { - assertServerToolNotAborted(context) - const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState - const { saved, errors } = await extractAndPersistCustomTools( - finalWorkflowState, - workspaceId, - context.userId - ) - - if (saved > 0) { - logger.info(`Persisted ${saved} custom tool(s) to database`, { workflowId }) - } - - if (errors.length > 0) { - logger.warn('Some custom tools failed to persist', { errors, workflowId }) - } - } catch (error) { - logger.error('Failed to persist custom tools', { error, workflowId }) - } - } else if (context?.userId && !workspaceId) { - logger.warn('Workflow has no workspaceId, skipping custom tools persistence', { - workflowId, - }) - } else { - logger.warn('No userId in context - skipping custom tools persistence', { workflowId }) - } - - logger.info('edit_workflow successfully applied operations', { - operationCount: operations.length, - blocksCount: Object.keys(modifiedWorkflowState.blocks).length, - edgesCount: modifiedWorkflowState.edges.length, - inputValidationErrors: validationErrors.length, - skippedItemsCount: skippedItems.length, - schemaValidationErrors: validation.errors.length, - validationWarnings: validation.warnings.length, + ).catch((error: unknown) => { + throw enrichWorkflowNotFound(error, workflowId) }) - // Format validation errors for LLM feedback const inputErrors = - validationErrors.length > 0 - ? validationErrors.map((e) => `Block "${e.blockId}" (${e.blockType}): ${e.error}`) + result.inputValidationErrors.length > 0 + ? result.inputValidationErrors.map( + (error) => `Block "${error.blockId}" (${error.blockType}): ${error.error}` + ) : undefined - - // Split engine skipped items into genuine failures vs benign, self-healing - // deferrals. A deferred forward-reference edge (invalid_edge_target) is NOT - // a failure: the engine wires it automatically once its target block exists - // (this call or a later one) via pendingConnections. Surfacing it through - // the same "skipped" failure channel as real skips makes a literal model - // thrash (re-issuing a self-healing op). Keep them in separate result fields - // and preserve item.type/details so the prompt can branch on a - // machine-readable category instead of pattern-matching prose. - const mapSkippedItem = (item: (typeof skippedItems)[number]) => ({ - type: item.type, - operationType: item.operationType, - blockId: item.blockId, - reason: item.reason, - ...(item.details && { details: item.details }), - }) - - const genuineSkippedItems = skippedItems.filter((item) => !isDeferredSkippedItem(item)) - const deferredItems = skippedItems.filter((item) => isDeferredSkippedItem(item)) - const skippedDetails = - genuineSkippedItems.length > 0 ? genuineSkippedItems.map(mapSkippedItem) : undefined - const deferredDetails = deferredItems.length > 0 ? deferredItems.map(mapSkippedItem) : undefined - - // Persist the workflow state to the database - const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState - - const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({ - before: workflowState, - after: finalWorkflowState, - }) - - let layoutedBlocks = finalWorkflowState.blocks - - if (layoutBlockIds.length > 0 || resizedBlockIds.length > 0 || shiftSourceBlockIds.length > 0) { - try { - transferBlockHeights(workflowState.blocks, finalWorkflowState.blocks) - layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, { - changedBlockIds: layoutBlockIds, - resizedBlockIds, - shiftSourceBlockIds, - horizontalSpacing: DEFAULT_HORIZONTAL_SPACING, - verticalSpacing: DEFAULT_VERTICAL_SPACING, - previousBlocks: workflowState.blocks, - }) - } catch (error) { - logger.warn('Targeted autolayout failed, using default positions', { - workflowId, - error: toError(error).message, - }) - } - } - - const workflowStateForDb = { - blocks: layoutedBlocks, - edges: finalWorkflowState.edges, - loops: generateLoopBlocks(layoutedBlocks as any), - parallels: generateParallelBlocks(layoutedBlocks as any), - lastSaved: Date.now(), - isDeployed: false, - } - - // Aggregate lint report: graph (sources/sinks/orphans/ports) + Tier-1 config - // (required + canonical-mode) + Tier-2 resolution (credential/resource IDs). - const graphLint = lintEditedWorkflowState(workflowStateForDb as any) - const fieldIssues = collectWorkflowFieldIssues(workflowStateForDb.blocks as any) - const workflowLint: WorkflowLintReport = { - ...graphLint, - fieldIssues, - unresolvedReferences, - notes: unresolvedReferences.length > 0 ? [UNRESOLVABLE_AT_LINT_NOTE] : [], - } - const workflowLintMessage = hasWorkflowLintIssues(workflowLint) - ? formatWorkflowLintMessage(workflowLint) + result.skipped.length > 0 ? result.skipped.map(mapSkippedItem) : undefined + const deferredDetails = + result.deferred.length > 0 ? result.deferred.map(mapSkippedItem) : undefined + const sanitizationWarnings = result.warnings.length > 0 ? result.warnings : undefined + const workflowLintMessage = hasWorkflowLintIssues(result.lint) + ? formatWorkflowLintMessage(result.lint) : undefined - assertServerToolNotAborted(context) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForDb as any) - if (!saveResult.success) { - logger.error('Failed to persist workflow state to database', { - workflowId, - error: saveResult.error, - }) - throw new OrchestrationError('conflict', `Failed to save workflow: ${saveResult.error}`) - } - - // Update workflow's lastSynced timestamp - assertServerToolNotAborted(context) - await db - .update(workflowTable) - .set({ - lastSynced: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowTable.id, workflowId)) - - logger.info('Workflow state persisted to database', { workflowId }) - - fetch(`${getSocketServerUrl()}/api/workflow-updated`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId }), - }).catch((error) => { - logger.warn('Failed to notify socket server of workflow update', { workflowId, error }) - }) - - const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined - return { success: true, - workflowId, - workflowName: workflowName ?? 'Workflow', - workflowState: sanitizeForCopilot(workflowStateForDb), - workflowLint, + workflowId: result.workflowId, + workflowName: result.workflowName || 'Workflow', + /** + * Sanitized before it reaches the agent (#6904). The graph goes back into + * a model context, so non-serializable and oversized values have to be + * stripped; the application use case returns the graph it persisted, not + * a copilot-shaped one. + */ + workflowState: sanitizeForCopilot(result.graph), + workflowLint: result.lint, ...(workflowLintMessage && { workflowLintMessage }), ...(inputErrors && { inputValidationErrors: inputErrors, diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 02a33625af4..ee8ee7ec93f 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -25,6 +25,7 @@ import { PROVIDER_DEFINITIONS, SIM_AUTO_MODEL_ID, } from '@/providers/models' +import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' import type { ToolConfig, ToolHostingCondition } from '@/tools/types' import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' @@ -593,11 +594,16 @@ function serializeSubBlock(sb: SubBlockConfig): Record { if (sb.mode) result.mode = sb.mode if (sb.canonicalParamId) result.canonicalParamId = sb.canonicalParamId if (sb.condition && typeof sb.condition !== 'function') result.condition = sb.condition - if (sb.dependsOn) result.dependsOn = sb.dependsOn + // Copied, not aliased: these are the registry's own arrays, shared by every + // request in the process, so publishing one puts mutable registry state a + // single careless consumer away from corruption. The catalog projection this + // serializer parallels copies every array it publishes for the same reason. + if (sb.dependsOn) + result.dependsOn = Array.isArray(sb.dependsOn) ? [...sb.dependsOn] : sb.dependsOn // Include static options arrays for dropdowns if (Array.isArray(sb.options)) { - result.options = sb.options + result.options = [...sb.options] } return result @@ -1142,7 +1148,10 @@ export function serializeIntegrationSchema( // field and load it" matches the callable tool and the block's tools.access. id: tool.id, name: tool.name, - description: getCopilotToolDescription(tool, { isHosted: hosted }), + description: getCopilotToolDescription(tool, { + isHosted: hosted, + hostedApiKey: deriveHostedApiKeySupport(tool.hosting), + }), version: tool.version, auth, oauth: diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index b76478f2bf6..c9a6ee567fd 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -42,11 +42,6 @@ import { compileDoc, getE2BDocFormat } from '@/lib/copilot/tools/server/files/do import { extractDocText, isExtractableDocExt } from '@/lib/copilot/tools/server/files/doc-extract' import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' import { isRenderableDocExt, renderDocToGrid } from '@/lib/copilot/tools/server/files/doc-render' -import { - collectWorkflowFieldIssues, - lintEditedWorkflowState, -} from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' -import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { type FileReadResult, @@ -138,8 +133,8 @@ import { createIntegrationCredentialVisibility } from '@/lib/integrations/creden import { listKnowledgeConnectors } from '@/lib/knowledge/application/connectors' import { listKnowledgeDocuments } from '@/lib/knowledge/application/documents' import { - listArchivedKnowledgeBases, listKnowledgeBaseCatalog, + listKnowledgeBases, } from '@/lib/knowledge/application/knowledge-bases' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' @@ -166,6 +161,8 @@ import { isImageFileType, resolveEffectiveMimeType } from '@/lib/uploads/utils/f import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import { collectWorkflowFieldIssues, lintEditedWorkflowState } from '@/lib/workflows/editing/lint' +import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/workflows/editing/validation' import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, @@ -2949,12 +2946,12 @@ export class WorkspaceVFS { input: { workspaceId, scope: 'archived' }, }) .then(({ folders }) => folders), - listArchivedKnowledgeBases + listKnowledgeBases .execute({ principal: this.requireKnowledgePrincipal(), - input: { workspaceId }, + input: { workspaceId, scope: 'archived' }, }) - .then(({ knowledgeBases }) => knowledgeBases), + .then(({ knowledgeBases }) => knowledgeBases.map((entry) => entry.knowledgeBase)), ]) for (const wf of archivedWorkflows) { diff --git a/apps/sim/lib/core/application/audit-source.test.ts b/apps/sim/lib/core/application/audit-source.test.ts new file mode 100644 index 00000000000..d99da04f186 --- /dev/null +++ b/apps/sim/lib/core/application/audit-source.test.ts @@ -0,0 +1,33 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { principalAuditSource } from '@/lib/core/application/audit-source' + +describe('principalAuditSource', () => { + it('names the delegated service rather than the kind', () => { + expect( + principalAuditSource({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date(), + expiresAt: new Date(), + }) + ).toBe('copilot') + }) + + it.each([ + [{ kind: 'session', userId: 'user-1', sessionId: 'session-1' }, 'session'], + [{ kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, 'personal_api_key'], + [ + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + 'workspace_api_key', + ], + ] as const)('names the credential class for %#', (principal, expected) => { + expect(principalAuditSource(principal)).toBe(expected) + }) +}) diff --git a/apps/sim/lib/core/application/audit-source.ts b/apps/sim/lib/core/application/audit-source.ts new file mode 100644 index 00000000000..3835902a75d --- /dev/null +++ b/apps/sim/lib/core/application/audit-source.ts @@ -0,0 +1,17 @@ +import type { Principal } from '@sim/auth/principal' + +/** + * The surface a semantic audit row attributes a change to. + * + * Derived from the authenticated principal rather than hardcoded, because an + * operation's principal policy can widen: a `source` literal written when a use + * case had exactly one caller becomes a false audit row the moment a second one + * is admitted, and a false row is worse than no row. + * + * A delegated principal names its service (`copilot`, `executor`) — "which agent + * did this" is the distinction a reviewer reads the row for. Every other kind + * names its own credential class, which is already what `actor` records. + */ +export function principalAuditSource(principal: Principal): string { + return principal.kind === 'delegated' ? principal.serviceId : principal.kind +} diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 2083e81b594..bc8da9e36f1 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -54,6 +54,12 @@ export const FORBIDDEN_DETAIL_CODES = [ 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', + /** The workspace's plan does not include a capability the request depends on. */ + 'WORKSPACE_PLAN_CAPABILITY_REQUIRED', + /** The workspace's permission group does not allow this chat authentication mode. */ + 'CHAT_AUTH_MODE_NOT_PERMITTED', + /** The resource is owned by a knowledge base connector and cannot be edited directly. */ + 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', ] as const export type ForbiddenDetailCode = (typeof FORBIDDEN_DETAIL_CODES)[number] @@ -93,6 +99,12 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record { + it('freezes the operation and its principal list so nothing widens it at runtime', () => { + expect(Object.isFrozen(readSelf)).toBe(true) + expect(Object.isFrozen(readSelf.principalKinds)).toBe(true) + }) + + it('rejects an operation that names no principal', () => { + expect(() => defineOperation({ id: 'meta.empty', principalKinds: [] })).toThrow( + 'Operation meta.empty must allow at least one principal kind' + ) + }) + + it('rejects a duplicated principal kind', () => { + expect(() => + defineOperation({ id: 'meta.duplicate', principalKinds: ['session', 'session'] }) + ).toThrow('Operation meta.duplicate declares duplicate principal kinds') + }) +}) + +describe('assertOperationPrincipal', () => { + it('accepts a principal kind the operation names', () => { + const principal: Principal = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } + + expect(() => assertOperationPrincipal(principal, readSelf)).not.toThrow() + }) + + /** + * A plain `Error`, not a `forbidden` one: a principal-scoped operation is + * reachable from a single authenticating surface, so a kind it does not name + * is a wiring bug rather than a refusal a caller can provoke — and rendering + * it as a 403 would publish a wire status no request can reach. + */ + it('raises an invariant failure, not a forbidden, for a kind it does not name', () => { + const principal: Principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } + + let thrown: unknown + try { + assertOperationPrincipal(principal, readSelf) + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toBe( + 'Operation meta.capabilities.read reached by principal kind session, which its policy does not name' + ) + expect(thrown).not.toHaveProperty('code') + }) +}) diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index 4f2fbe81534..a49e9d5acde 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -5,6 +5,77 @@ export interface ApplicationOperation { readonly id: Id } +/** + * Every principal kind an operation can name. `credential_group_enrollment` is + * excluded: it authenticates one enrollment flow and never performs a semantic + * resource operation. + */ +export type PrincipalKind = Exclude + +/** + * A principal kind a non-workspace operation may name. `delegated` is excluded + * on purpose: a delegated principal is only meaningful alongside a + * `delegatedServices` policy and the workspace, audience, and expiry re-checks + * that {@link defineWorkspaceOperation} exists to carry. An operation that needs + * delegation is a workspace operation. + */ +export type UndelegatedPrincipalKind = Exclude + +/** + * An operation with no workspace scope and therefore no role, whose whole + * authorization story is which kinds of principal may perform it. + * + * Rare by design — `/api/v2/meta` is the only one, because its resource *is* + * the credential the caller has already proved it holds. It exists so such an + * operation still declares its policy as data rather than leaving it implicit + * in whichever surface happens to call it. + */ +export interface PrincipalScopedOperation< + Id extends string = string, + PrincipalKinds extends readonly UndelegatedPrincipalKind[] = readonly UndelegatedPrincipalKind[], +> extends ApplicationOperation { + readonly principalKinds: PrincipalKinds +} + +export function defineOperation< + const Id extends string, + const PrincipalKinds extends readonly UndelegatedPrincipalKind[], +>( + operation: PrincipalScopedOperation +): PrincipalScopedOperation { + if (operation.principalKinds.length === 0) { + throw new Error(`Operation ${operation.id} must allow at least one principal kind`) + } + if (new Set(operation.principalKinds).size !== operation.principalKinds.length) { + throw new Error(`Operation ${operation.id} declares duplicate principal kinds`) + } + Object.freeze(operation.principalKinds) + Object.freeze(operation) + return operation +} + +/** + * Narrows a principal to the kinds its operation names. + * + * A mismatch throws a plain invariant error, not a `forbidden` one, and the + * distinction is deliberate: a principal-scoped operation is reachable from a + * single authenticating surface whose adapter can only ever construct the kinds + * the operation names, so a mismatch is a wiring bug rather than a refusal any + * caller can provoke. Rendering it as a `403` would publish a wire status no + * request can reach — and a codeless one, since the closed + * `FORBIDDEN_DETAIL_CODES` vocabulary describes remedies a caller can act on. + */ +export function assertOperationPrincipal( + principal: Principal, + operation: O +): asserts principal is Extract { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new Error( + `Operation ${operation.id} reached by principal kind ${principal.kind}, which its policy does not name` + ) + } +} + export interface OperationUseCase { readonly operation: O execute(args: { diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index fea7f27b091..09a8f161d29 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -5,11 +5,11 @@ import type { Principal, } from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' -import type { ApplicationOperation } from '@/lib/core/application/operation' +import type { ApplicationOperation, PrincipalKind } from '@/lib/core/application/operation' type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' -export type PrincipalKind = Exclude +export type { PrincipalKind } type NonDelegatedPrincipalForOperation< O extends { readonly principalKinds: readonly PrincipalKind[] }, diff --git a/apps/sim/lib/core/utils/user-file.ts b/apps/sim/lib/core/utils/user-file.ts index 546c9dbc4cb..f2e8f166493 100644 --- a/apps/sim/lib/core/utils/user-file.ts +++ b/apps/sim/lib/core/utils/user-file.ts @@ -78,6 +78,53 @@ function collectUserFileKeysInto(value: unknown, keys: Set, seen: WeakSe } } +/** + * Collects the {@link UserFile} records embedded in a value, indexed by file id. + * + * The first occurrence of an id wins, matching `Array.prototype.find`, so a file + * echoed into several block outputs resolves to a single record. Callers use + * this to answer "which files did this value actually reference, and under which + * storage keys" without trusting an id-to-key mapping supplied from outside. + */ +export function collectUserFilesById(value: unknown): Map { + const files = new Map() + collectUserFilesInto(value, files, new WeakSet()) + return files +} + +function collectUserFilesInto( + value: unknown, + files: Map, + seen: WeakSet +): void { + if (!value || typeof value !== 'object') { + return + } + + if (seen.has(value)) { + return + } + seen.add(value) + + if (isUserFileWithMetadata(value)) { + if (!files.has(value.id)) { + files.set(value.id, value) + } + return + } + + if (Array.isArray(value)) { + for (const item of value) { + collectUserFilesInto(item, files, seen) + } + return + } + + for (const item of Object.values(value)) { + collectUserFilesInto(item, files, seen) + } +} + /** * Checks if a value matches the display-safe UserFile metadata shape after internal fields are stripped. */ diff --git a/apps/sim/lib/credentials/api/route-policies.test.ts b/apps/sim/lib/credentials/api/route-policies.test.ts new file mode 100644 index 00000000000..2a00ee16d52 --- /dev/null +++ b/apps/sim/lib/credentials/api/route-policies.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' + +function project(error: unknown) { + return internalCredentialErrorPolicy.project(error) +} + +describe('internalCredentialErrorPolicy', () => { + /** + * The same failure used to render three ways — 503 on v2, 502 here, 502 from + * `statusForCredentialOrchestrationError` — and only the v2 one told the + * caller when to come back. + */ + it('renders a provider outage as 503 with a Retry-After', () => { + const response = project( + new CredentialProviderOperationError('Provider unreachable', 'provider_unavailable', true) + ) + + expect(response?.status).toBe(503) + expect(response?.headers).toEqual({ 'Retry-After': '5' }) + expect(response?.body).toMatchObject({ code: 'provider_unavailable' }) + }) + + it('keeps a rejected secret a 400 with no retry advice', () => { + const response = project( + new CredentialProviderOperationError('Token rejected', 'invalid_credentials', false) + ) + + expect(response?.status).toBe(400) + expect(response?.headers).toBeUndefined() + }) + + it('defers anything that is not a provider failure to the base policy', () => { + expect(project(new Error('unrelated'))).toBeNull() + }) +}) diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts index 717766dec51..a7cbfda62c7 100644 --- a/apps/sim/lib/credentials/api/route-policies.ts +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -4,6 +4,7 @@ import { internalOrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server/validation' +import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' import { NoWorkspaceAccessError } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -15,14 +16,25 @@ export const credentialValidationParseOptions = { validationErrorResponse(error, getValidationErrorMessage(error)), } as const +/** + * A provider that could not be reached while a secret was verified is `503 + + * Retry-After`, matching `statusForCredentialOrchestrationError` and the v2 + * surface. All three used to disagree — 502 here, 502 there, 503 on v2 — which + * left the same failure looking like three different things depending on which + * surface the caller used, and only one of them said when to come back. + */ export const internalCredentialErrorPolicy = extendInternalErrorPolicy( internalOrchestrationErrorPolicy, (error) => { if (!(error instanceof CredentialProviderOperationError)) return null - return internalErrorResponse(error.providerUnavailable ? 502 : 400, { - error: error.message, - code: error.providerErrorCode, - }) + if (!error.providerUnavailable) { + return internalErrorResponse(400, { error: error.message, code: error.providerErrorCode }) + } + return internalErrorResponse( + 503, + { error: error.message, code: error.providerErrorCode }, + { 'Retry-After': ADMISSION_RETRY_AFTER_SECONDS.toString() } + ) } ) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts index b0a1939edfd..65a3b7a9133 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -6,6 +6,7 @@ import { defineWorkspaceOperation } from '@/lib/core/application' import { CredentialAccessRequiredError, defineAuthorizedCredentialUseCase, + requireManageableCredentialType, } from '@/lib/credentials/application/authorized-credential-use-case' import { defineCredentialOperation } from '@/lib/credentials/application/operations' @@ -120,3 +121,60 @@ describe('defineAuthorizedCredentialUseCase', () => { ) }) }) + +/** + * The table each credential-scoped operation applies before it mutates. + * + * The API-key row is the one with teeth: `v2CredentialTypeSchema` publishes only + * `oauth | service_account` and `toV2Credential` throws on anything else, so an + * `env_*` row reaching a public credential route would be a caller-reachable + * 500 rather than a refusal. + */ +describe('requireManageableCredentialType', () => { + const sessionPrincipal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + const apiKeyPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', + } + const delegatedPrincipal = { + kind: 'delegated' as const, + service: 'copilot' as const, + subject: { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }, + } + + const credentialOfType = (type: string) => ({ type }) as Pick + + it.each(['oauth', 'service_account', 'env_workspace', 'env_personal'])( + 'lets a session manage a %s credential', + (type) => { + expect(() => + requireManageableCredentialType(sessionPrincipal, credentialOfType(type)) + ).not.toThrow() + } + ) + + it.each(['oauth', 'service_account'])('lets an API key manage a %s credential', (type) => { + expect(() => + requireManageableCredentialType(apiKeyPrincipal, credentialOfType(type)) + ).not.toThrow() + }) + + it.each(['env_workspace', 'env_personal'])( + 'refuses an API key on a %s credential the public schema cannot express', + (type) => { + expect(() => + requireManageableCredentialType(apiKeyPrincipal, credentialOfType(type)) + ).toThrowError(/Only oauth, service_account credentials can be managed by this caller/) + } + ) + + it.each(['service_account', 'env_workspace', 'env_personal'])( + 'confines Copilot to oauth, refusing %s', + (type) => { + expect(() => + requireManageableCredentialType(delegatedPrincipal, credentialOfType(type)) + ).toThrowError(/Only oauth credentials can be managed by this caller/) + } + ) +}) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts index 57e6d109993..7902af3d970 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { type AuthorizedWorkspaceUseCaseDefinition, @@ -33,6 +34,38 @@ export function requireCredentialAccess( return context.credentialAccess } +/** + * Refuses a credential type the acting principal's surface cannot represent. + * + * A session is a human in the credentials settings UI, which renders every type + * including the two environment-secret ones. Copilot is confined to OAuth + * connections. An API key reaches only the two types the public API publishes: + * `v2CredentialTypeSchema` declares `oauth | service_account` and + * `toV2Credential` throws on anything else, so admitting an `env_workspace` row + * would turn a well-formed request into a caller-reachable 500 — the highest + * severity class of defect on the v2 surface. + * + * Lives here, beside the resource-role check, so every credential-scoped + * operation applies one table rather than each use case restating it. + */ +export function requireManageableCredentialType( + principal: Principal, + credential: Pick +): void { + const allowedTypes = + principal.kind === 'session' + ? ['oauth', 'env_workspace', 'env_personal', 'service_account'] + : principal.kind === 'delegated' + ? ['oauth'] + : ['oauth', 'service_account'] + if (!allowedTypes.includes(credential.type)) { + throw new OrchestrationError( + 'validation', + `Only ${allowedTypes.join(', ')} credentials can be managed by this caller` + ) + } +} + type AuthorizedCredentialUseCaseDefinition< O extends CredentialOperation, I, diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts new file mode 100644 index 00000000000..83e98bee4f7 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -0,0 +1,301 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getWorkspaceCredential: vi.fn(), + getCredentialById: vi.fn(), + getActor: vi.fn(), + updateRecord: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getWorkspaceCredential, + getCredentialById: mocks.getCredentialById, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, + canUseCredential: () => true, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + updateCredentialRecord: mocks.updateRecord, + createCredentialRecord: vi.fn(), + isProviderOutageCode: () => false, +})) +vi.mock('@/lib/credentials/oauth', () => ({ syncWorkspaceOAuthCredentialsForUser: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: vi.fn() })) + +import { + CredentialProviderOperationError, + updateWorkspaceCredentialUseCase, +} from '@/lib/credentials/application/credential-crud' + +const WORKSPACE_ID = 'workspace-1' +const OTHER_WORKSPACE_ID = 'workspace-2' +const workspace = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const apiKeyPrincipal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const sessionPrincipal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted', + createdBy: 'user-1', + createdAt: new Date('2026-08-12T20:00:00.000Z'), + updatedAt: new Date('2026-08-12T20:00:00.000Z'), +} + +describe('updateWorkspaceCredentialUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getWorkspaceCredential.mockResolvedValue(credential) + mocks.getCredentialById.mockResolvedValue(credential) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.updateRecord.mockResolvedValue({ + success: true, + updatedFields: ['encryptedServiceAccountKey'], + auditMetadata: { principal: 'zoom-account' }, + }) + }) + + it('rotates a service-account secret for a personal API key', async () => { + const result = await updateWorkspaceCredentialUseCase.execute({ + principal: apiKeyPrincipal, + input: { + credentialId: credential.id, + assertedWorkspaceId: WORKSPACE_ID, + clientSecret: 'rotated', + }, + }) + + expect(result.credential).toEqual(credential) + expect(result.updatedFields).toEqual(['encryptedServiceAccountKey']) + }) + + /** + * The asserted workspace is a scope comparison, not a field to write. Passing + * it through to the manager would put a caller-supplied key into the update + * builder's argument object. + */ + it('scopes the canonical load without forwarding the assertion to the manager', async () => { + await updateWorkspaceCredentialUseCase.execute({ + principal: apiKeyPrincipal, + input: { + credentialId: credential.id, + assertedWorkspaceId: WORKSPACE_ID, + displayName: 'Zoom prod', + }, + }) + + expect(mocks.getWorkspaceCredential).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + credentialId: credential.id, + }) + expect(mocks.updateRecord).toHaveBeenCalledWith({ + credentialId: credential.id, + displayName: 'Zoom prod', + credential, + }) + expect(mocks.updateRecord.mock.calls[0][0]).not.toHaveProperty('assertedWorkspaceId') + }) + + it('conceals a credential the asserted workspace does not own as a not-found', async () => { + mocks.getWorkspaceCredential.mockResolvedValue(null) + + await expect( + updateWorkspaceCredentialUseCase.execute({ + principal: apiKeyPrincipal, + input: { + credentialId: credential.id, + assertedWorkspaceId: OTHER_WORKSPACE_ID, + displayName: 'Zoom prod', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.updateRecord).not.toHaveBeenCalled() + }) + + /** The internal surface omits the assertion and keeps its previous behavior. */ + it('resolves the credential by id when no workspace is asserted', async () => { + await updateWorkspaceCredentialUseCase.execute({ + principal: sessionPrincipal, + input: { credentialId: credential.id, displayName: 'Zoom prod' }, + }) + + expect(mocks.getCredentialById).toHaveBeenCalledWith(credential.id) + expect(mocks.getWorkspaceCredential).not.toHaveBeenCalled() + }) + + /** + * Without this an API key could rename an environment secret through a surface + * whose presenter throws on that type, turning a well-formed request into a + * 500. + */ + it('refuses an environment credential for an API key before mutating', async () => { + const envCredential = { ...credential, type: 'env_workspace' as const } + mocks.getWorkspaceCredential.mockResolvedValue(envCredential) + mocks.getActor.mockResolvedValue({ + credential: envCredential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + updateWorkspaceCredentialUseCase.execute({ + principal: apiKeyPrincipal, + input: { + credentialId: credential.id, + assertedWorkspaceId: WORKSPACE_ID, + displayName: 'Renamed', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.updateRecord).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) + + it('lets a session rename the same environment credential', async () => { + const envCredential = { ...credential, type: 'env_workspace' as const } + mocks.getCredentialById.mockResolvedValue(envCredential) + mocks.getActor.mockResolvedValue({ + credential: envCredential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + updateWorkspaceCredentialUseCase.execute({ + principal: sessionPrincipal, + input: { credentialId: credential.id, description: 'Shared key' }, + }) + ).resolves.toMatchObject({ credential: envCredential }) + }) + + it('refuses a workspace API key before any canonical load', async () => { + await expect( + updateWorkspaceCredentialUseCase.execute({ + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + input: { + credentialId: credential.id, + assertedWorkspaceId: WORKSPACE_ID, + displayName: 'Zoom prod', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.updateRecord).not.toHaveBeenCalled() + }) + + it('refuses a credential member who is not a credential admin', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + updateWorkspaceCredentialUseCase.execute({ + principal: apiKeyPrincipal, + input: { + credentialId: credential.id, + assertedWorkspaceId: WORKSPACE_ID, + displayName: 'Zoom prod', + }, + }) + ).rejects.toMatchObject({ detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' }) + expect(mocks.updateRecord).not.toHaveBeenCalled() + }) + + /** + * A provider outage and a provider rejection are the same class here; the + * surface, not the use case, decides their statuses. What matters is that the + * distinguishing flag survives. + */ + it('raises a provider failure carrying its outage flag, and records no audit', async () => { + mocks.updateRecord.mockResolvedValue({ + success: false, + error: 'upstream unreachable', + providerErrorCode: 'provider_unavailable', + providerUnavailable: true, + }) + + await expect( + updateWorkspaceCredentialUseCase.execute({ + principal: apiKeyPrincipal, + input: { + credentialId: credential.id, + assertedWorkspaceId: WORKSPACE_ID, + clientSecret: 'rotated', + }, + }) + ).rejects.toMatchObject({ + name: 'CredentialProviderOperationError', + providerErrorCode: 'provider_unavailable', + providerUnavailable: true, + }) + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) + + it('projects the authoritative updated fields into the audit entry', async () => { + await updateWorkspaceCredentialUseCase.execute({ + principal: apiKeyPrincipal, + input: { + credentialId: credential.id, + assertedWorkspaceId: WORKSPACE_ID, + clientSecret: 'rotated', + }, + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: credential.id, + metadata: expect.objectContaining({ + updatedFields: ['encryptedServiceAccountKey'], + credentialType: 'service_account', + }), + }) + ) + }) + + it('exports the provider failure class the surface maps', () => { + const error = new CredentialProviderOperationError('down', 'provider_unavailable', true) + + expect(error.providerUnavailable).toBe(true) + expect(error.code).toBe('validation') + }) +}) diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index 5f8b1643375..c28bb5302a0 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -7,6 +7,7 @@ import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/a import { defineAuthorizedCredentialUseCase, requireCredentialAccess, + requireManageableCredentialType, } from '@/lib/credentials/application/authorized-credential-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' @@ -242,17 +243,23 @@ export const getWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ export type UpdateWorkspaceCredentialInput = Omit< PerformUpdateCredentialParams, 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' -> +> & { + /** + * Workspace the caller asserts owns the credential; a mismatch is concealed as + * a not-found. The internal surface omits it and resolves the credential's own + * workspace instead, which is what it did before this field existed. + */ + assertedWorkspaceId?: string +} export const updateWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ operation: credentialOperations.update, resolveContext: ({ input }: { input: UpdateWorkspaceCredentialInput }) => resolveCredentialApplicationContext(input), async execute({ principal, input, context }) { - if (principal.kind === 'delegated' && context.credential.type !== 'oauth') { - throw new OrchestrationError('validation', 'Copilot can update only oauth credentials') - } - const result = await updateCredentialRecord({ ...input, credential: context.credential }) + requireManageableCredentialType(principal, context.credential) + const { assertedWorkspaceId, ...fields } = input + const result = await updateCredentialRecord({ ...fields, credential: context.credential }) if (!result.success) throwCredentialMutationFailure(result) const access = await getCredentialActorContext( context.credential.id, diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts index 26c363580f9..77737952366 100644 --- a/apps/sim/lib/credentials/application/operations.test.ts +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -21,6 +21,23 @@ describe('credential operations', () => { expect(Object.isFrozen(credentialOperations.delete)).toBe(true) }) + /** + * The rotation surface leans on this: `PATCH /api/v2/credentials/{id}` is + * reachable by a personal key, and its refusal for a workspace key is the + * operation's principal list rather than anything the route does. + */ + it('declares the same authority for update as for delete', () => { + expect(credentialOperations.update).toMatchObject({ + id: 'credentials.update', + minimumRole: 'read', + minimumCredentialRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + expect(credentialOperations.update.principalKinds).not.toContain('workspace_api_key') + }) + it('rejects actorless workspace keys for credential admin operations', () => { const workspaceKeyOperation = defineWorkspaceOperation({ id: 'credentials.test_admin', diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 7dcdc507dbf..b399663cf4a 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -1,6 +1,5 @@ import type { Principal } from '@sim/auth/principal' import { getBlockVisibility } from '@/lib/core/config/block-visibility' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, @@ -11,6 +10,7 @@ import { type TokenServiceAccountField, } from '@/lib/credentials/token-service-accounts/descriptors' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { allowedIntegrationTypes, principalUserId } from '@/lib/integrations/principal-scope.server' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -18,8 +18,6 @@ import { SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export interface CredentialProviderAuthorizationOption { providerId: string @@ -215,27 +213,6 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`) } -function principalUserId(principal: Principal): string | undefined { - if (principal.kind === 'session' || principal.kind === 'personal_api_key') { - return principal.userId - } - if (principal.kind === 'delegated') return principal.subjectUserId - return undefined -} - -async function allowedIntegrationTypes( - principal: Principal, - workspaceId: string -): Promise | null> { - const userId = principalUserId(principal) - const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null - const integrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null -} - export async function listCredentialProviderCatalog( principal: Principal, context: CredentialProviderCatalogContext diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index fe3c058ba77..6112b39a99d 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -5,7 +5,10 @@ import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' import { getCredentialActorContext } from '@/lib/credentials/access' -import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { + defineAuthorizedCredentialUseCase, + requireManageableCredentialType, +} from '@/lib/credentials/application/authorized-credential-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' import { @@ -145,18 +148,7 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ principal, context }): Promise { - const allowedTypes = - principal.kind === 'session' - ? ['oauth', 'env_workspace', 'env_personal', 'service_account'] - : principal.kind === 'delegated' - ? ['oauth'] - : ['oauth', 'service_account'] - if (!allowedTypes.includes(context.credential.type)) { - throw new OrchestrationError( - 'validation', - `Only ${allowedTypes.join(', ')} credentials can be managed by this caller` - ) - } + requireManageableCredentialType(principal, context.credential) const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' const deleted = await deleteCredentialRecord({ credential: context.credential, reason }) return { credential: context.credential, deleted } diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index f795e1846e3..8c3af20c6bb 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -93,7 +93,7 @@ export interface PerformCreateCredentialResult { errorCode?: CredentialOrchestrationErrorCode /** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */ providerErrorCode?: string - /** A provider outage rather than a rejected secret — callers surface 502, not 400. */ + /** A provider outage rather than a rejected secret — callers surface 503, not 400. */ providerUnavailable?: boolean credential?: CredentialRow /** False when an existing credential matched the source and was returned instead. */ @@ -653,12 +653,17 @@ export function isProviderOutageCode(code: string | undefined): boolean { return code !== undefined && PROVIDER_OUTAGE_CODES.has(code) } -/** HTTP status for a credential orchestration failure, shared by every route surface. */ +/** + * HTTP status for a credential orchestration failure, shared by every route + * surface. A provider outage is `503`, as {@link PROVIDER_OUTAGE_CODES} says — + * the same status the internal and v2 credential error policies render, each + * with a `Retry-After`. + */ export function statusForCredentialOrchestrationError( code: CredentialOrchestrationErrorCode | undefined, options: { providerUnavailable?: boolean } = {} ): number { - if (options.providerUnavailable) return 502 + if (options.providerUnavailable) return 503 if (code === 'validation') return 400 if (code === 'forbidden') return 403 if (code === 'not_found') return 404 diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index e9519cc2206..efc4669100a 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -71,6 +71,7 @@ import { createServiceAccountCredential, deleteCredentialRecord, performUpdateCredential, + statusForCredentialOrchestrationError, } from '@/lib/credentials/orchestration' const OLD_EMAIL = 'old-sa@old-project.iam.gserviceaccount.com' @@ -476,6 +477,64 @@ describe('performUpdateCredential — description scope', () => { }) }) +/** + * Only a service-account credential has a secret blob to rotate into. Every + * other type used to fall straight through the rotation branch, so a secret + * sent alongside a rename was silently discarded behind a 200 — the caller + * believing it had rotated something. + */ +describe('performUpdateCredential — secret fields on a non-rotatable credential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsClientCredentialAccountProviderId.mockReturnValue(false) + mockGetClientCredentialAccountDescriptor.mockReturnValue(undefined) + }) + + it('rejects a secret sent for an oauth credential rather than dropping it', async () => { + mockCredential({ type: 'oauth', providerId: 'google' }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + displayName: 'Renamed', + apiToken: 'token-that-would-vanish', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.success ? '' : result.error).toMatch(/apiToken/) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + }) + + it('names every submitted secret field so the caller knows what was refused', async () => { + mockCredential({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', providerId: null }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + apiToken: 'token', + domain: 'example.atlassian.net', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.success ? '' : result.error).toMatch(/apiToken, domain/) + }) + + it('leaves a rename with no secret working on a non-service-account credential', async () => { + mockCredential({ type: 'oauth', providerId: 'google' }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + displayName: 'Renamed', + }) + + expect(result.success).toBe(true) + expect(updatePayload().displayName).toBe('Renamed') + }) +}) + describe('createServiceAccountCredential', () => { beforeEach(() => { vi.clearAllMocks() @@ -642,3 +701,27 @@ describe('deleteCredentialRecord', () => { expect(mockDeleteOrphanedOAuthAccount).not.toHaveBeenCalled() }) }) + +describe('statusForCredentialOrchestrationError', () => { + /** + * `PROVIDER_OUTAGE_CODES` twelve lines above it already says both outage + * families "must map to 503, not 400"; this returned 502, so the shared + * status helper disagreed with its own neighbouring contract. + */ + it('maps a provider outage to 503, matching the outage-code contract', () => { + expect(statusForCredentialOrchestrationError(undefined, { providerUnavailable: true })).toBe( + 503 + ) + expect(statusForCredentialOrchestrationError('validation', { providerUnavailable: true })).toBe( + 503 + ) + }) + + it('keeps the classified codes on their own statuses', () => { + expect(statusForCredentialOrchestrationError('validation')).toBe(400) + expect(statusForCredentialOrchestrationError('forbidden')).toBe(403) + expect(statusForCredentialOrchestrationError('not_found')).toBe(404) + expect(statusForCredentialOrchestrationError('conflict')).toBe(409) + expect(statusForCredentialOrchestrationError(undefined)).toBe(500) + }) +}) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index d31a670ffb4..36d170fe41e 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -34,6 +34,7 @@ import { deleteWorkspaceEnvCredentials, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' +import type { ServiceAccountFieldId } from '@/lib/credentials/service-account-fields' import { ServiceAccountSecretError, verifyAndBuildServiceAccountSecret, @@ -61,6 +62,27 @@ export { statusForCredentialOrchestrationError, } from './credential-create' +/** + * Every secret field a reconnect can carry. Only a `service_account` credential + * has somewhere to store them, so this doubles as the set a non-service-account + * update must refuse rather than silently drop. + */ +const ROTATABLE_SECRET_FIELDS: readonly ServiceAccountFieldId[] = [ + 'serviceAccountJson', + 'signingSecret', + 'botToken', + 'apiToken', + 'domain', + 'clientId', + 'clientSecret', + 'certificateId', + 'orgId', + 'dataCenter', + 'authMethod', + 'privateKey', + 'username', +] + /** * Google's stored blob is the raw GCP JSON key, whose own `type` discriminator * is `service_account`. @@ -222,23 +244,28 @@ export async function updateCredentialRecord( // secret is re-verified against the provider and re-encrypted through the // same builder the create path uses, so the rotation also yields the new // principal's derived display name and audit metadata. - const hasRotationSecret = - params.serviceAccountJson !== undefined || - params.signingSecret !== undefined || - params.botToken !== undefined || - params.apiToken !== undefined || - params.domain !== undefined || - params.clientId !== undefined || - params.clientSecret !== undefined || - params.certificateId !== undefined || - params.orgId !== undefined || - params.dataCenter !== undefined || - params.authMethod !== undefined || - params.privateKey !== undefined || - params.username !== undefined + const submittedSecretFields = ROTATABLE_SECRET_FIELDS.filter( + (field) => params[field] !== undefined + ) + const hasRotationSecret = submittedSecretFields.length > 0 + + // Only a service account stores a rotatable secret blob. Every other type + // reaches the rotation branch below and falls straight through it, so an + // OAuth credential sent `{ displayName, apiToken }` used to answer 200 with + // the token silently discarded — the caller believing it had rotated a + // secret. Refused here rather than at one contract: the credential's type + // is only known once the row is loaded, so no request schema can decide it. + if (hasRotationSecret && params.credential.type !== 'service_account') { + return { + success: false, + error: `A ${params.credential.type} credential has no rotatable secret; ${submittedSecretFields.join(', ')} cannot be updated. Reconnect the credential instead.`, + errorCode: 'validation', + } + } + let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined - if (hasRotationSecret && params.credential.type === 'service_account') { + if (hasRotationSecret) { const providerId = params.credential.providerId ?? '' // A reconnect rebuilds the secret blob from the submitted fields only, and diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.test.ts b/apps/sim/lib/credentials/token-service-accounts/errors.test.ts index 2292b102dad..d2b15fe2107 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.test.ts @@ -111,6 +111,31 @@ describe('token service-account error helpers', () => { } ) + /** + * `provider_unavailable` renders as `503 + Retry-After`, so classifying a + * permanently-wrong `domain`, `orgId`, or `clientId` as an outage tells a + * conforming client to retry input that can never succeed. + */ + it.each([400, 404, 409, 422])( + 'maps the non-transient %i to invalid_credentials, never an outage', + async (status) => { + const res = new Response('bad request', { status }) + + const error = await expectValidationError(throwForProviderResponse(res, 'self')) + + expect(error.code).toBe('invalid_credentials') + expect(error.status).toBe(status) + } + ) + + it.each([408, 429])('keeps the transient %i an outage', async (status) => { + const res = new Response('slow down', { status }) + + const error = await expectValidationError(throwForProviderResponse(res, 'self')) + + expect(error.code).toBe('provider_unavailable') + }) + it('returns without throwing on a 2xx response', async () => { const res = new Response('ok', { status: 200 }) diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index d7d53315812..bdc53a52a2d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -146,9 +146,21 @@ export async function readProviderErrorSnippet(res: Response): Promise { /** * Maps a failed provider verification response to the standard error split: - * 401/403 mean the pasted token was rejected (`invalid_credentials`), anything - * else non-2xx means the provider couldn't be reached or misbehaved - * (`provider_unavailable`) — never blame the token for a provider outage. + * every 4xx except the transient ones is the caller's input being wrong + * (`invalid_credentials`), and only 5xx or a transient 4xx means the provider + * couldn't be reached or misbehaved (`provider_unavailable`). + * + * The 4xx half matters beyond message accuracy. `provider_unavailable` renders + * as `503 + Retry-After`, which tells a conforming client to come back — so + * classifying a permanently-wrong `domain`, `orgId`, or `clientId` as an outage + * makes it retry forever. A 4xx that is not 408/429 is by definition something + * the caller must change, so it is answered as a caller error and never + * advertises a retry. + * + * `invalid_credentials` covers the whole non-transient 4xx range rather than + * splitting further: telling a rejected token apart from a wrong host takes + * provider-specific knowledge, and the providers that have it (Shopify, + * Snowflake, Atlassian) already raise `site_not_found` before reaching here. */ export async function throwForProviderResponse( res: Response, @@ -157,16 +169,11 @@ export async function throwForProviderResponse( ): Promise { if (res.ok) return const body = await readProviderErrorSnippet(res) - if (res.status === 401 || res.status === 403) { - throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { - step, - body, - ...context, - }) - } - throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { - step, - body, - ...context, - }) + const callerError = + res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status) + throw new TokenServiceAccountValidationError( + callerError ? 'invalid_credentials' : 'provider_unavailable', + res.status, + { step, body, ...context } + ) } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index 36eff828aac..46d42d993b4 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -100,6 +100,25 @@ describe('validateShopifyServiceAccount', () => { }) }) + /** + * The store domain is caller-supplied, so a host that does not resolve is + * wrong input rather than a Shopify outage — and `provider_unavailable` + * would answer `503 + Retry-After` for a domain that can never work. + */ + it('throws site_not_found when the store host does not resolve', async () => { + const dnsError = new TypeError('fetch failed') + ;(dnsError as { cause?: unknown }).cause = { code: 'ENOTFOUND' } + mockFetch.mockRejectedValueOnce(dnsError) + + await expect( + validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'nope.myshopify.com' }) + ).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'site_not_found', + status: 400, + }) + }) + it('throws provider_unavailable on 500', async () => { mockFetch.mockResolvedValueOnce(jsonResponse(500, { errors: 'Internal Server Error' })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index 4d18a625334..54ff330897a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -69,8 +69,10 @@ function normalizeShopifyDomain(rawDomain: string): string { * Validates a Shopify custom-app Admin API access token by running the * scope-free `shop` query against the store's GraphQL Admin API — the exact * URL and header shape every Sim Shopify tool uses. Unknown shops return 404 - * (wildcard DNS means the host always resolves), which maps to - * `site_not_found`. + * (wildcard DNS means the host normally resolves), which maps to + * `site_not_found` — and so does an outright resolution failure, since the + * domain is caller-supplied and a host that does not exist is wrong input + * rather than a Shopify outage. */ export async function validateShopifyServiceAccount( fields: TokenServiceAccountFields @@ -94,7 +96,8 @@ export async function validateShopifyServiceAccount( }, body: JSON.stringify({ query: SHOP_QUERY }), }, - 'shop_query' + 'shop_query', + { dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve' } ) if (res.status === 404) { diff --git a/apps/sim/lib/folders/application-folder-caps.test.ts b/apps/sim/lib/folders/application-folder-caps.test.ts index 6324ff75646..3de0373d0d4 100644 --- a/apps/sim/lib/folders/application-folder-caps.test.ts +++ b/apps/sim/lib/folders/application-folder-caps.test.ts @@ -31,7 +31,7 @@ vi.mock('@/lib/folders/queries', () => ({ return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } }, })) -vi.mock('@/lib/workflows/application/context', () => ({ +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkflowWorkspace, })) vi.mock('@/lib/workflows/queries', () => ({ diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 676b2c7144e..204f26a34c1 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -909,6 +909,30 @@ describe('restoreFolder', () => { expect(dbChainMockFns.set).toHaveBeenCalledWith({ name: 'Reports (1)' }) }) + /** + * `projectAudit: false` is for an application use case that records `FOLDER_RESTORED` + * itself against the acting `Principal` — the `actorId: userId` entry here cannot express + * a non-human one, and both firing would double-count the restore. + */ + it('suppresses its own audit entry when the caller projects one', async () => { + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + + const result = await restoreFolder(baseRestore, { projectAudit: false }) + + expect(result.success).toBe(true) + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + + it('records its own audit entry by default', async () => { + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + + await restoreFolder(baseRestore) + + expect(auditMock.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: auditMock.AuditAction.FOLDER_RESTORED }) + ) + }) + it('leaves the name alone when nothing took it', async () => { queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index cb4f2c3cbe1..345f262c3ea 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -886,7 +886,8 @@ async function deleteFolderWithoutTreeLock( * an archived folder, so a taken name would otherwise make it permanently unrestorable. */ async function restoreFolderWithoutTreeLock( - params: RestoreFolderParams + params: RestoreFolderParams, + options: { projectAudit: boolean } ): Promise { const { resourceType, folderId, workspaceId, userId, folderName } = params const config = folderResourceConfig(resourceType) @@ -1019,31 +1020,43 @@ async function restoreFolderWithoutTreeLock( logger.info('Restored folder and all contents', { folderId, resourceType, counts }) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FOLDER_RESTORED, - resourceType: AuditResourceType.FOLDER, - resourceId: folderId, - resourceName: folderName ?? folder.name, - description: `Restored ${config.label} folder "${folderName ?? folder.name}"`, - metadata: { - folderResourceType: resourceType, - affected: { - [config.countKey]: counts.children, - subfolders: Math.max(counts.folders - 1, 0), + if (options.projectAudit) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderId, + resourceName: folderName ?? folder.name, + description: `Restored ${config.label} folder "${folderName ?? folder.name}"`, + metadata: { + folderResourceType: resourceType, + affected: { + [config.countKey]: counts.children, + subfolders: Math.max(counts.folders - 1, 0), + }, }, - }, - }) + }) + } // Live resource list (e.g. tables): a restore brings the folder and its contents back. await notifyFolderResourceChanged(resourceType, workspaceId) return { success: true, restoredItems: toCascadeCounts(config, counts) } } -/** Restores a folder while serializing against every writer for the resource tree. */ -export async function restoreFolder(params: RestoreFolderParams): Promise { +/** + * Restores a folder while serializing against every writer for the resource tree. + * + * `projectAudit: false` for a caller that projects `FOLDER_RESTORED` itself — an application + * use case attributes the entry to the acting `Principal`, which the `actorId: userId` entry + * inside cannot express for a non-human principal. Omitted, the orchestration keeps recording + * its own entry, so every existing caller is unchanged. Mirrors {@link deleteFolder}. + */ +export async function restoreFolder( + params: RestoreFolderParams, + options?: { projectAudit?: boolean } +): Promise { return withFolderTreeLock(params.workspaceId, params.resourceType, () => - restoreFolderWithoutTreeLock(params) + restoreFolderWithoutTreeLock(params, { projectAudit: options?.projectAudit ?? true }) ) } diff --git a/apps/sim/lib/folders/queries.test.ts b/apps/sim/lib/folders/queries.test.ts index 6992e317072..8fc8ec84ac9 100644 --- a/apps/sim/lib/folders/queries.test.ts +++ b/apps/sim/lib/folders/queries.test.ts @@ -14,6 +14,7 @@ import { FolderCollectionFullError, FolderCollectionLimitExceededError } from '@ import { assertFolderCollectionHasRoom, findActiveFolder, + findArchivedFolderIdByPath, listActiveFolderRows, listFoldersForWorkspace, loadActiveFolderPathIndex, @@ -353,6 +354,88 @@ describe('folder queries', () => { }) }) + /** + * The path lookup behind `POST /api/v2/tables/folders/restore`. It deliberately does NOT go + * through `buildFolderPathIndex`: the partial unique index on folder names covers ACTIVE + * rows only, so an archived `/Reports` and a new active `/Reports` legally coexist and the + * lossless index would throw on the duplicate path. + */ + describe('findArchivedFolderIdByPath', () => { + const ARCHIVED_AT = new Date('2026-02-01T00:00:00.000Z') + + function archived(overrides: Partial & { id: string }) { + return { ...ROW, resourceType: 'table' as const, deletedAt: ARCHIVED_AT, ...overrides } + } + + it('resolves a root-level archived folder by its canonical path', async () => { + queueTableRows(schemaMock.folder, [archived({ id: 'f-1', name: 'Reports' })]) + + expect(await findArchivedFolderIdByPath('ws-1', 'table', '/Reports')).toBe('f-1') + }) + + it('resolves a nested archived folder through its archived ancestors', async () => { + queueTableRows(schemaMock.folder, [ + archived({ id: 'parent', name: 'Sales', parentId: null }), + archived({ id: 'child', name: 'Reports', parentId: 'parent' }), + ]) + + expect(await findArchivedFolderIdByPath('ws-1', 'table', '/Sales/Reports')).toBe('child') + }) + + it('resolves an archived folder still hanging off an ACTIVE parent', async () => { + queueTableRows(schemaMock.folder, [ + { ...ROW, id: 'parent', name: 'Sales', resourceType: 'table', deletedAt: null }, + archived({ id: 'child', name: 'Reports', parentId: 'parent' }), + ]) + + expect(await findArchivedFolderIdByPath('ws-1', 'table', '/Sales/Reports')).toBe('child') + }) + + /** An active folder standing on the path is not a restore target. */ + it('ignores an active folder occupying the same path', async () => { + queueTableRows(schemaMock.folder, [ + { ...ROW, id: 'active', name: 'Reports', resourceType: 'table', deletedAt: null }, + ]) + + expect(await findArchivedFolderIdByPath('ws-1', 'table', '/Reports')).toBeNull() + }) + + /** Archive, recreate, archive again: two archived rows share one path. */ + it('picks the most recently archived row when a path is ambiguous', async () => { + queueTableRows(schemaMock.folder, [ + archived({ id: 'old', name: 'Reports' }), + archived({ + id: 'new', + name: 'Reports', + deletedAt: new Date('2026-03-01T00:00:00.000Z'), + }), + ]) + + expect(await findArchivedFolderIdByPath('ws-1', 'table', '/Reports')).toBe('new') + }) + + it('returns null when no archived folder holds the path', async () => { + queueTableRows(schemaMock.folder, [archived({ id: 'f-1', name: 'Other' })]) + + expect(await findArchivedFolderIdByPath('ws-1', 'table', '/Reports')).toBeNull() + }) + + it('refuses to restore the workspace root', async () => { + await expect(findArchivedFolderIdByPath('ws-1', 'table', '/')).rejects.toThrow() + }) + + it('refuses a truncated read rather than resolving against a partial tree', async () => { + queueTableRows(schemaMock.folder, [ + archived({ id: 'a', name: 'A' }), + archived({ id: 'b', name: 'B' }), + ]) + + await expect( + findArchivedFolderIdByPath('ws-1', 'table', '/Reports', { maxRows: 1 }) + ).rejects.toBeInstanceOf(FolderCollectionLimitExceededError) + }) + }) + describe('toFolderApi', () => { it('serializes timestamps to ISO strings and preserves a null deletedAt', () => { expect(toFolderApi(ROW)).toMatchObject({ diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 6fc65721a5e..91b000867a0 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -6,7 +6,14 @@ import { type ListSortOrder, listOrderBy, searchFilter } from '@/lib/api/list-qu import type { DbOrTx } from '@/lib/db/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { FolderCollectionFullError, FolderCollectionLimitExceededError } from '@/lib/folders/errors' -import { buildFolderPathIndex, type FolderPathIndex, ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { + buildFolderPath, + buildFolderPathIndex, + encodeFolderPathSegment, + type FolderPathIndex, + ROOT_FOLDER_PATH, + requireNonRootFolderPath, +} from '@/lib/folders/paths' import { folderResourceLabel } from '@/lib/folders/resource-traits' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' @@ -338,3 +345,63 @@ export async function listFoldersForWorkspace( return rows.map(toFolderApi) } + +/** + * Resolves an archived folder's id from the canonical path it had when it was deleted. + * + * Cannot go through {@link buildFolderPathIndex}: that index is lossless and fails fast on a + * duplicate path, but the partial unique index on folder names only covers ACTIVE rows — so + * archiving `/Reports` and creating a new `/Reports` is legal and puts two rows on one path. + * The walk below therefore computes each archived row's path against the whole row set + * (an archived folder can still hang off an active parent) and matches without demanding + * global path uniqueness. + * + * Ambiguity resolves to the most recently archived row, which is the one a caller who just + * deleted a folder means. + */ +export async function findArchivedFolderIdByPath( + workspaceId: string, + resourceType: FolderResourceType, + path: string, + options?: { maxRows?: number } +): Promise { + const target = buildFolderPath(requireNonRootFolderPath(path)) + const maxRows = options?.maxRows ?? MAX_FOLDERS_PER_WORKSPACE + const rows = await db + .select() + .from(folder) + .where(and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, resourceType))) + .limit(maxRows + 1) + if (rows.length > maxRows) { + throw new FolderCollectionLimitExceededError('path index', maxRows) + } + + const rowById = new Map(rows.map((row) => [row.id, row])) + const pathById = new Map() + + const resolvePath = (folderId: string, seen: Set): string | null => { + const cached = pathById.get(folderId) + if (cached) return cached + if (seen.has(folderId)) return null + const row = rowById.get(folderId) + if (!row) return null + seen.add(folderId) + const parentPath = row.parentId ? resolvePath(row.parentId, seen) : ROOT_FOLDER_PATH + seen.delete(folderId) + if (parentPath === null) return null + const resolved = + parentPath === ROOT_FOLDER_PATH + ? `/${encodeFolderPathSegment(row.name)}` + : `${parentPath}/${encodeFolderPathSegment(row.name)}` + pathById.set(folderId, resolved) + return resolved + } + + let match: (typeof rows)[number] | null = null + for (const row of rows) { + if (!row.deletedAt) continue + if (resolvePath(row.id, new Set()) !== target) continue + if (!match || row.deletedAt > (match.deletedAt as Date)) match = row + } + return match?.id ?? null +} diff --git a/apps/sim/lib/integrations/principal-scope.server.ts b/apps/sim/lib/integrations/principal-scope.server.ts new file mode 100644 index 00000000000..f7d0a6b993d --- /dev/null +++ b/apps/sim/lib/integrations/principal-scope.server.ts @@ -0,0 +1,56 @@ +import type { Principal } from '@sim/auth/principal' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +/** + * The workspace integration gate, shared by every catalog that projects + * caller-specific integration availability. + * + * It exists as one module because it has two independent consumers — the + * credential-provider catalog and the block/tool catalog — and the interesting + * case is the one that is easy to get wrong twice: a workspace API key carries + * no user, so there is no permission group to read, and the allowlist collapses + * to the deployment's own. Two copies of that reasoning would disagree the + * first time either changed, and the two endpoints describe the same + * integrations. + * + * Server-only: `getUserPermissionConfig` reads the database. + */ + +/** + * The human whose permission groups apply, or `undefined` when the principal is + * not user-bearing. + * + * A workspace API key authorizes as the workspace itself, independently of who + * created it, so there is deliberately no fallback to a key owner: substituting + * one would apply a bystander's permission groups to every caller of that key. + */ +export function principalUserId(principal: Principal): string | undefined { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + if (principal.kind === 'delegated') return principal.subjectUserId + return undefined +} + +/** + * Lowercased block types this principal may see in this workspace, or `null` + * when nothing restricts them. + * + * The intersection of the caller's permission-group allowlist with the + * deployment's `ALLOWED_INTEGRATIONS`. A principal with no user contributes no + * permission-group half, leaving the deployment allowlist alone. + */ +export async function allowedIntegrationTypes( + principal: Principal, + workspaceId: string +): Promise | null> { + const userId = principalUserId(principal) + const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const integrations = intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null +} diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index d07c1c86c27..291585938dd 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -134,6 +134,25 @@ const v2KnowledgeDocumentUploadErrorPolicy = { }, } satisfies V2ErrorPolicy +/** + * A chunk read or write whose document has not finished processing. + * + * `409`, not the internal surface's `400`: the request is well-formed and the + * resource state is what refuses it. Per the v2 conventions a `409` carries no + * `Retry-After` — waiting is not what fixes a `failed` document — so the + * internal policy's `retryAfter` field is deliberately not carried over. The + * status the document is in rides in the message, which is what tells a caller + * whether to poll or to requeue. + */ +const v2KnowledgeChunkErrorPolicy = { + render(error) { + if (error instanceof KnowledgeDocumentNotReadyError) { + return v2Error('CONFLICT', error.message) + } + return v2OrchestrationErrorPolicy.render(error) + }, +} satisfies V2ErrorPolicy + export const v2KnowledgeErrorPolicies = { default: v2OrchestrationErrorPolicy, usage: v2KnowledgeUsageErrorPolicy, @@ -145,6 +164,10 @@ export const v2KnowledgeErrorPolicies = { notFoundMessage: 'Knowledge base not found', render: v2KnowledgeUsageErrorPolicy.render, }), + concealKnowledgeChunkAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Knowledge base not found', + render: v2KnowledgeChunkErrorPolicy.render, + }), concealKnowledgeBaseUploadAuthorization: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Knowledge base not found', render: v2KnowledgeDocumentUploadErrorPolicy.render, diff --git a/apps/sim/lib/knowledge/application/chunks.test.ts b/apps/sim/lib/knowledge/application/chunks.test.ts index 74b93781ad3..fffc0cf7d06 100644 --- a/apps/sim/lib/knowledge/application/chunks.test.ts +++ b/apps/sim/lib/knowledge/application/chunks.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ resolveDocument: vi.fn(), resolvePermission: vi.fn(), queryChunks: vi.fn(), + batchChunkOperation: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -26,7 +27,7 @@ vi.mock('@/lib/knowledge/application/contexts', () => ({ })) vi.mock('@/lib/knowledge/chunks/service', () => ({ - batchChunkOperation: vi.fn(), + batchChunkOperation: mocks.batchChunkOperation, createChunk: vi.fn(), deleteChunk: vi.fn(), queryChunks: mocks.queryChunks, @@ -43,8 +44,9 @@ vi.mock('@/lib/knowledge/model-input-provenance', () => ({ vi.mock('@/providers/utils', () => ({ calculateCost: vi.fn() })) +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' -import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks' +import { bulkUpdateKnowledgeChunks, listKnowledgeChunks } from '@/lib/knowledge/application/chunks' describe('knowledge chunk application use cases', () => { beforeEach(() => { @@ -76,4 +78,74 @@ describe('knowledge chunk application use cases', () => { }) expect(mocks.queryChunks).not.toHaveBeenCalled() }) + + it('passes a keyset position straight through to the chunk query', async () => { + mocks.resolveDocument.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + knowledgeBaseId: 'knowledge-1', + knowledgeBase: { id: 'knowledge-1' }, + documentId: 'document-1', + document: { id: 'document-1', processingStatus: 'completed' }, + }) + mocks.queryChunks.mockResolvedValue({ + chunks: [], + nextCursorKeys: null, + pagination: { total: 0, limit: 50, offset: 0, hasMore: false }, + }) + + const result = await listKnowledgeChunks.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-1', + cursorKeys: [3, 'chunk-3'], + }, + }) + + expect(mocks.queryChunks).toHaveBeenCalledWith( + 'document-1', + expect.objectContaining({ cursorKeys: [3, 'chunk-3'] }), + expect.any(String) + ) + expect(result.nextCursorKeys).toBeNull() + }) + + /** + * A connector owns its documents' chunks, so a direct edit would be silently + * reverted by the next sync. The refusal names its cause, because exposing + * chunk writes publicly makes it a 403 a client has to branch on. + */ + it('refuses a write to a connector-synced document with a machine-readable cause', async () => { + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveDocument.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + knowledgeBaseId: 'knowledge-1', + knowledgeBase: { id: 'knowledge-1' }, + documentId: 'document-1', + document: { id: 'document-1', processingStatus: 'completed', connectorId: 'connector-1' }, + }) + + const promise = bulkUpdateKnowledgeChunks.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-1', + operation: 'delete', + chunkIds: ['chunk-1'], + }, + }) + + await expect(promise).rejects.toBeInstanceOf(ForbiddenOperationError) + await expect(promise).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', + }) + expect(mocks.batchChunkOperation).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/knowledge/application/chunks.ts b/apps/sim/lib/knowledge/application/chunks.ts index 3f81a5d5683..9e22dbddf0f 100644 --- a/apps/sim/lib/knowledge/application/chunks.ts +++ b/apps/sim/lib/knowledge/application/chunks.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { @@ -69,8 +70,8 @@ function requireChunkReadable(context: ActiveKnowledgeDocumentContext): void { function requireChunkWritable(context: ActiveKnowledgeDocumentContext): void { if (context.document.connectorId) { - throw new OrchestrationError( - 'forbidden', + throw new ForbiddenOperationError( + 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', 'Chunks from connector-synced documents are read-only' ) } diff --git a/apps/sim/lib/knowledge/application/contexts.ts b/apps/sim/lib/knowledge/application/contexts.ts index 5219c3f2082..a6df12a0dc4 100644 --- a/apps/sim/lib/knowledge/application/contexts.ts +++ b/apps/sim/lib/knowledge/application/contexts.ts @@ -13,6 +13,10 @@ import { } from '@/lib/knowledge/connectors/service' import type { ActiveKnowledgeDocument } from '@/lib/knowledge/documents/service' import { getKnowledgeDocument, getKnowledgeDocumentById } from '@/lib/knowledge/documents/service' +import { + getRestorableKnowledgeBase, + type RestorableKnowledgeBase, +} from '@/lib/knowledge/orchestration/restore' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { getTagDefinitionById } from '@/lib/knowledge/tags/service' import type { DocumentTagDefinition } from '@/lib/knowledge/tags/types' @@ -61,6 +65,17 @@ export type ActiveKnowledgeChunkContext = ActiveKnowledgeDocumentContext & { chunk: ChunkData } +/** + * A knowledge base loaded regardless of `deletedAt`, for the one operation that + * targets an archived row. It carries the restorable identity rather than the + * full {@link KnowledgeBaseWithCounts}, which is all the restore needs and all + * the archived read projects. + */ +export type ArchivedKnowledgeBaseContext = KnowledgeWorkspaceContext & { + knowledgeBaseId: string + restorableKnowledgeBase: RestorableKnowledgeBase +} + export async function loadKnowledgeWorkspaceContext( workspaceId: string ): Promise { @@ -130,6 +145,42 @@ export async function resolveActiveKnowledgeBaseInWorkspace( return { ...workspaceContext, knowledgeBaseId: knowledgeBase.id, knowledgeBase } } +/** + * Loads a soft-deleted knowledge base and the workspace context that authorizes + * restoring it. + * + * The workspace is loaded with `includeArchived`, because archiving a workspace + * archives everything under it and a restore has to be able to reach both. + * + * A knowledge base with no workspace is a legacy personal one, which answers + * only to its creator and has no workspace operation that could authorize it. + * Reporting it as missing is the same concealment {@link requireKnowledgeBase} + * applies — a caller who cannot own it must not learn it exists. + */ +export async function resolveArchivedKnowledgeBaseContext(input: { + knowledgeBaseId: string + assertedWorkspaceId?: string +}): Promise { + const knowledgeBase = await getRestorableKnowledgeBase(input.knowledgeBaseId) + if ( + !knowledgeBase?.workspaceId || + (input.assertedWorkspaceId !== undefined && + knowledgeBase.workspaceId !== input.assertedWorkspaceId) + ) { + throw new OrchestrationError('not_found', 'Knowledge base not found') + } + const workspaceContext = await loadKnowledgeWorkspaceAuthorizationContext( + knowledgeBase.workspaceId, + { includeArchived: true } + ) + if (!workspaceContext) throw new OrchestrationError('not_found', 'Knowledge base not found') + return { + ...workspaceContext, + knowledgeBaseId: knowledgeBase.id, + restorableKnowledgeBase: knowledgeBase, + } +} + export async function resolveActiveKnowledgeResourceContext(input: { knowledgeBaseId: string assertedWorkspaceId?: string diff --git a/apps/sim/lib/knowledge/application/folder-paths.test.ts b/apps/sim/lib/knowledge/application/folder-paths.test.ts new file mode 100644 index 00000000000..81ea9978f03 --- /dev/null +++ b/apps/sim/lib/knowledge/application/folder-paths.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { knowledgeFolderPathForId } from '@/lib/knowledge/application/folder-paths' + +type ActiveFolderIndex = Parameters[0] + +/** Minimal active-folder index: only `pathById` is read by the projection. */ +function indexWith(paths: Record): ActiveFolderIndex { + return { + rowById: new Map(), + pathById: new Map(Object.entries(paths)), + idByPath: new Map(Object.entries(paths).map(([id, path]) => [path, id])), + } +} + +describe('knowledgeFolderPathForId', () => { + it('renders the active folder path', () => { + expect(knowledgeFolderPathForId(indexWith({ 'folder-1': '/Product' }), 'folder-1')).toBe( + '/Product' + ) + }) + + it('reports the root for a knowledge base that sits at the workspace root', () => { + expect(knowledgeFolderPathForId(indexWith({}), null)).toBe('/') + expect(knowledgeFolderPathForId(indexWith({}), undefined)).toBe('/') + }) + + /** + * The index holds active folders only, so an archived knowledge base whose + * folder was archived alongside it — or one whose folder was deleted — has no + * path to render. Falling back to the root keeps a well-formed read a 200; it + * used to throw, which reached the v2 surface as a caller-reachable 500. + */ + it('falls back to the root when the folder is archived or missing', () => { + expect(knowledgeFolderPathForId(indexWith({ 'folder-1': '/Product' }), 'archived-folder')).toBe( + '/' + ) + }) +}) diff --git a/apps/sim/lib/knowledge/application/folder-paths.ts b/apps/sim/lib/knowledge/application/folder-paths.ts index cc1c9a70bf6..7af4384cf21 100644 --- a/apps/sim/lib/knowledge/application/folder-paths.ts +++ b/apps/sim/lib/knowledge/application/folder-paths.ts @@ -22,12 +22,20 @@ export async function resolveKnowledgeFolderPath( }) } +/** + * Renders a knowledge base's containing-folder path from the active folder index. + * + * A folder id the index does not hold reports the workspace root rather than + * throwing. The index covers *active* folders only, so an archived knowledge base + * whose containing folder was archived with it — or one whose folder was deleted + * underneath it — resolves to nothing, and a well-formed read of that row must not + * become a 500. Matches the v2 files projection, which falls back to `/` for the + * same reason. + */ export function knowledgeFolderPathForId( index: FolderPathIndex, folderId: string | null | undefined ): string { if (!folderId) return ROOT_FOLDER_PATH - const path = index.pathById.get(folderId) - if (!path) throw new Error('Knowledge base references an inactive or missing folder') - return path + return index.pathById.get(folderId) ?? ROOT_FOLDER_PATH } diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts index c92716aa480..5f44cae43a1 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ resolveWorkspace: vi.fn(), loadAuthorizationWorkspace: vi.fn(), resolveKnowledgeBase: vi.fn(), + resolveArchivedKnowledgeBase: vi.fn(), resolvePermission: vi.fn(), resolveFolderPath: vi.fn(), createRecord: vi.fn(), @@ -32,6 +33,7 @@ vi.mock('@sim/audit', () => ({ KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + KNOWLEDGE_BASE_RESTORED: 'knowledge_base.restored', }, AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, recordAudit: mocks.recordAudit, @@ -65,6 +67,7 @@ vi.mock('@/lib/knowledge/application/contexts', () => ({ loadKnowledgeWorkspaceAuthorizationContext: mocks.loadAuthorizationWorkspace, resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, + resolveArchivedKnowledgeBaseContext: mocks.resolveArchivedKnowledgeBase, })) vi.mock('@/lib/knowledge/application/folder-paths', () => ({ @@ -99,16 +102,17 @@ import { bulkDeleteKnowledgeBases, createKnowledgeBase, deleteInternalKnowledgeBase, - listArchivedKnowledgeBases, listInternalKnowledgeBases, listKnowledgeBaseCatalog, listKnowledgeBases, readInternalKnowledgeBase, readKnowledgeBase, restoreInternalKnowledgeBase, + restoreKnowledgeBase, updateInternalKnowledgeBase, updateKnowledgeBaseOperation, } from '@/lib/knowledge/application/knowledge-bases' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' const context = { workspaceId: 'workspace-1', @@ -157,6 +161,17 @@ describe('knowledge base application use cases', () => { mocks.listVisibleRecords.mockResolvedValue([knowledgeBase]) mocks.getRecord.mockResolvedValue(knowledgeBase) mocks.getRestorableRecord.mockResolvedValue(knowledgeBase) + mocks.resolveArchivedKnowledgeBase.mockResolvedValue({ + ...context, + knowledgeBaseId: knowledgeBase.id, + restorableKnowledgeBase: { + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId, + userId: knowledgeBase.userId, + deletedAt: new Date('2026-02-01T00:00:00Z'), + }, + }) mocks.performUpdate.mockResolvedValue({ success: true, knowledgeBase: { ...knowledgeBase, name: 'Renamed' }, @@ -278,7 +293,7 @@ describe('knowledge base application use cases', () => { it('rejects an archived Knowledge list bound to another trusted workspace before reading', async () => { await expect( - listArchivedKnowledgeBases.execute({ + listKnowledgeBases.execute({ principal: { kind: 'delegated', serviceId: 'copilot', @@ -289,7 +304,7 @@ describe('knowledge base application use cases', () => { issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), }, - input: { workspaceId: 'workspace-1' }, + input: { workspaceId: 'workspace-1', scope: 'archived' }, }) ).rejects.toMatchObject({ code: 'forbidden' }) @@ -496,6 +511,14 @@ describe('knowledge base application use cases', () => { expect(mocks.performUpdate).not.toHaveBeenCalled() }) + /** + * The internal restore delegates its workspace branch to the shared + * `restoreKnowledgeBase` use case, so the archived context — which is what + * loads the workspace with `includeArchived` — is resolved there rather than + * in the internal wrapper. What must stay true is that the mutation runs only + * after authorization, and that the shared use case suppresses the + * orchestration's own audit so the restore is recorded once. + */ it('carries canonical scope into internal delete and restores only after authorization', async () => { const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const await deleteInternalKnowledgeBase.execute({ @@ -510,10 +533,121 @@ describe('knowledge base application use cases', () => { expect(mocks.performDelete).toHaveBeenCalledWith( expect.objectContaining({ assertedWorkspaceId: 'workspace-1' }) ) - expect(mocks.loadAuthorizationWorkspace).toHaveBeenLastCalledWith('workspace-1', { - includeArchived: true, + expect(mocks.resolveArchivedKnowledgeBase).toHaveBeenCalledWith({ + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.resolvePermission).toHaveBeenCalled() + expect(mocks.performRestore).toHaveBeenCalledWith( + expect.objectContaining({ knowledgeBaseId: 'knowledge-1', recordSemanticAudit: false }) + ) + }) + + it('restores a workspace knowledge base only after the operation authorizes', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + restoreInternalKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseId: 'knowledge-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.performRestore).not.toHaveBeenCalled() + }) + + /** + * Restore is the inverse of delete, so a principal that can archive a + * knowledge base must be able to recover it. A tighter policy here would + * strand rows a workspace API key deleted. + */ + it('reaches restore under the same policy as delete', () => { + expect(knowledgeOperations.restore.minimumRole).toBe(knowledgeOperations.delete.minimumRole) + expect(knowledgeOperations.restore.workspaceApiKey).toBe( + knowledgeOperations.delete.workspaceApiKey + ) + }) + + /** + * The orchestration call and the audit projection must agree on which surface + * asked: the internal route restores as `ui` and the public one as `api`, and + * a literal in the orchestration call would attribute both to whichever one + * it names. + */ + it.each([['ui' as const], ['api' as const], ['agent' as const]])( + 'restores with the calling surface, not a fixed one (%s)', + async (source) => { + await restoreKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseId: 'knowledge-1', assertedWorkspaceId: 'workspace-1', source }, + }) + + expect(mocks.performRestore).toHaveBeenCalledWith(expect.objectContaining({ source })) + } + ) + + it('carries the internal surface through the shared restore use case', async () => { + await restoreInternalKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseId: 'knowledge-1' }, + }) + + expect(mocks.performRestore).toHaveBeenCalledWith(expect.objectContaining({ source: 'ui' })) + }) + + it('answers an already-active knowledge base without restoring or auditing it', async () => { + mocks.resolveArchivedKnowledgeBase.mockResolvedValue({ + ...context, + knowledgeBaseId: knowledgeBase.id, + restorableKnowledgeBase: { + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId, + userId: knowledgeBase.userId, + deletedAt: null, + }, + }) + + const result = await restoreKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + source: 'api', + }, + }) + + expect(result.restored).toBe(false) + expect(result.knowledgeBase.id).toBe('knowledge-1') + expect(mocks.performRestore).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('reads the archived set through the same list, keyset, and reader as the active one', async () => { + mocks.listRecords.mockResolvedValue({ data: [knowledgeBase], nextCursorKeys: ['k', 'id'] }) + + const result = await listKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + scope: 'archived', + search: 'docs', + sortBy: 'updatedAt', + sortOrder: 'desc', + limit: 25, + cursorKeys: ['2026-01-01T00:00:00.000Z', 'knowledge-0'], + }, + }) + + expect(mocks.listRecords).toHaveBeenCalledWith('workspace-1', 'archived', { + folderId: undefined, + search: 'docs', + sortBy: 'updatedAt', + sortOrder: 'desc', + limit: 25, + cursorKeys: ['2026-01-01T00:00:00.000Z', 'knowledge-0'], }) - expect(mocks.performRestore).toHaveBeenCalledOnce() + expect(result.nextCursorKeys).toEqual(['k', 'id']) }) it('bounds bulk deletion before canonical workspace loading', async () => { diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 4b8f7fbca3f..7768e23e6d3 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -30,6 +30,7 @@ import { type KnowledgeWorkspaceContext, loadKnowledgeWorkspaceAuthorizationContext, resolveActiveKnowledgeBaseContext, + resolveArchivedKnowledgeBaseContext, resolveKnowledgeWorkspaceContext, } from '@/lib/knowledge/application/contexts' import { @@ -51,7 +52,10 @@ import { performRestoreKnowledgeBase, performUpdateKnowledgeBase, } from '@/lib/knowledge/orchestration' -import type { KnowledgeOrchestrationResult } from '@/lib/knowledge/orchestration/shared' +import type { + KnowledgeOperationSource, + KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' import { createAuthorizedKnowledgeBase, deleteKnowledgeBase, @@ -68,6 +72,11 @@ const logger = createLogger('KnowledgeBaseApplication') export interface ListKnowledgeBasesInput { workspaceId: string + /** + * Lifecycle set to list. Defaults to `active`; `archived` reads the + * soft-deleted set the restore flow discovers from. + */ + scope?: KnowledgeBaseScope folderPath?: string search?: string sortBy?: 'name' | 'createdAt' | 'updatedAt' @@ -92,8 +101,19 @@ export interface ListKnowledgeBasesResult { sortOrder: 'asc' | 'desc' } -export interface ListArchivedKnowledgeBasesResult { - knowledgeBases: KnowledgeBaseWithCounts[] +export interface RestoreKnowledgeBaseInput extends ReadKnowledgeBaseInput { + /** + * Which surface asked for the restore. Required, unlike its optional siblings + * on the sibling inputs: this one reaches the orchestration call as well as + * the audit projection, and a default there would attribute one surface's + * restore to another. + */ + source: KnowledgeOperationSource +} + +export interface RestoreKnowledgeBaseResult extends KnowledgeBaseResult { + /** `false` when the knowledge base was already active and nothing changed. */ + restored: boolean } export interface KnowledgeBaseCatalogTagDefinition { @@ -251,7 +271,7 @@ async function executeListKnowledgeBases(args: { sortOrder: args.input.sortOrder ?? 'asc', } } - const page = await getWorkspaceKnowledgeBases(args.context.workspaceId, 'active', { + const page = await getWorkspaceKnowledgeBases(args.context.workspaceId, args.input.scope, { folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, search: args.input.search, sortBy: args.input.sortBy, @@ -418,15 +438,70 @@ export const listKnowledgeBaseCatalog = defineAuthorizedKnowledgeUseCase({ }, }) -export const listArchivedKnowledgeBases = defineAuthorizedKnowledgeUseCase({ - operation: knowledgeOperations.listArchived, - resolveContext: ({ input }: { input: { workspaceId: string } }) => - resolveKnowledgeWorkspaceContext(input), - async execute({ context }): Promise { +/** + * Un-archives a knowledge base and reports it as it now stands. + * + * Idempotent: a knowledge base that is already active is returned unchanged, + * with no restore performed and no audit entry recorded. A `409` there would + * make a retry after a dropped response look like a failure, and restore has no + * state a second call could corrupt. + * + * `performRestoreKnowledgeBase` records its own audit entry for the legacy + * session path, so this one suppresses it and projects the entry from the + * authoritative result instead — the pattern every other migrated knowledge + * write follows. + */ +export const restoreKnowledgeBase = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.restore, + resolveContext: ({ input }: { input: RestoreKnowledgeBaseInput }) => + resolveArchivedKnowledgeBaseContext({ + knowledgeBaseId: input.knowledgeBaseId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ principal, input, context, request }): Promise { + const restored = context.restorableKnowledgeBase.deletedAt !== null + if (restored) { + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: context.knowledgeBaseId, + userId: resolveKnowledgeAttributedUserId(principal, context), + source: input.source, + recordSemanticAudit: false, + ...(request ? { request } : {}), + }) + if (!outcome.success) { + throwKnowledgeOrchestrationFailure(outcome, 'Failed to restore knowledge base') + } + } + const knowledgeBase = await getKnowledgeBaseById(context.knowledgeBaseId) + if (!knowledgeBase) throw new OrchestrationError('not_found', 'Knowledge base not found') + const index = await loadActiveFolderPathIndex( + context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ) return { - knowledgeBases: (await getWorkspaceKnowledgeBases(context.workspaceId, 'archived')).data, + knowledgeBase, + folderPath: knowledgeFolderPathForId(index, knowledgeBase.folderId), + restored, } }, + projectAudit: ({ input, result }) => + result.restored + ? [ + { + action: AuditAction.KNOWLEDGE_BASE_RESTORED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.knowledgeBase.id, + resourceName: result.knowledgeBase.name, + description: `Restored knowledge base "${result.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseName: result.knowledgeBase.name, + }, + }, + ] + : [], }) export const listInternalKnowledgeBases = { @@ -737,13 +812,26 @@ export const restoreInternalKnowledgeBase = { requireSessionPrincipal(principal, knowledgeSessionOperations.restore.id) const knowledgeBase = await getRestorableKnowledgeBase(input.knowledgeBaseId) if (!knowledgeBase) throw new OrchestrationError('not_found', 'Knowledge base not found') + /** + * A workspace-owned knowledge base is restored through the shared + * application use case, so the internal and public surfaces cannot drift. + * The branch below is the legacy personal case: those rows belong to no + * workspace, so no workspace operation can authorize them and only their + * creator ever could. + */ if (knowledgeBase.workspaceId) { - const context = await loadKnowledgeWorkspaceAuthorizationContext(knowledgeBase.workspaceId, { - includeArchived: true, + await restoreKnowledgeBase.execute({ + principal, + input: { + knowledgeBaseId: knowledgeBase.id, + assertedWorkspaceId: knowledgeBase.workspaceId, + source: 'ui', + }, + ...(request ? { request } : {}), }) - if (!context) throw new OrchestrationError('not_found', 'Knowledge base not found') - await authorizeWorkspaceOperation(principal, knowledgeOperations.update, context) - } else if (knowledgeBase.userId !== principal.userId) { + return { success: true } + } + if (knowledgeBase.userId !== principal.userId) { throw new OrchestrationError('unauthorized', 'Unauthorized') } diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 69bfd883860..803a4223c3b 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -11,11 +11,11 @@ describe('knowledge operation registry', () => { const ids = Object.values(knowledgeOperations).map((operation) => operation.id) expect(ids).toEqual([ 'knowledge.list', - 'knowledge.list_archived', 'knowledge.read', 'knowledge.create', 'knowledge.update', 'knowledge.delete', + 'knowledge.restore', 'knowledge.bulk_move_items', 'knowledge.bulk_delete_items', 'knowledge.bulk_delete', @@ -49,8 +49,8 @@ describe('knowledge operation registry', () => { 'knowledge.tags.read_usage', 'knowledge.tags.read_detailed_usage', 'knowledge.tags.read_next_slot', - 'knowledge.tags.save_document_definitions', - 'knowledge.tags.delete_document_definitions', + 'knowledge.tags.bulk_save', + 'knowledge.tags.cleanup', 'knowledge.connectors.list', 'knowledge.connectors.read', 'knowledge.connectors.create', @@ -120,6 +120,29 @@ describe('knowledge operation registry', () => { } }) + /** + * Archive, restore, and the list that discovers archived rows are one + * recoverable loop. A principal that may run the first two but not the third + * can restore only the ids it recorded before archiving, so the discovery read + * — `knowledge.list`, which serves `scope=archived` too — carries the policy of + * the writes it exists to serve. + */ + it('keeps the archived list reachable by every principal that may archive and restore', () => { + expect(knowledgeOperations.list.workspaceApiKey).toBe( + knowledgeOperations.restore.workspaceApiKey + ) + expect(knowledgeOperations.list.principalKinds).toEqual( + knowledgeOperations.restore.principalKinds + ) + expect(knowledgeOperations.list.principalKinds).toContain('workspace_api_key') + expect( + permissionSatisfies( + knowledgeOperations.restore.minimumRole, + knowledgeOperations.list.minimumRole + ) + ).toBe(true) + }) + it('allows delegated callers only on semantic knowledge and document operations', () => { expect(knowledgeOperations.list.principalKinds).toContain('delegated') expect(knowledgeOperations.search.principalKinds).toContain('delegated') diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index d8118853020..a4ffeb7567b 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -29,18 +29,21 @@ const HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY = { } as const export const knowledgeOperations = { + /** + * Lists the workspace's knowledge bases, active or archived. + * + * One operation covers both lifecycle scopes: the archived set is the same rows + * under a different `deleted_at` predicate, and it is the only discovery read + * that makes restore usable, so denying it to a principal that may archive and + * restore leaves that principal able to recover only the ids it happened to + * record itself. + */ list: defineWorkspaceOperation({ id: 'knowledge.list', minimumRole: 'read', workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), - listArchived: defineWorkspaceOperation({ - id: 'knowledge.list_archived', - minimumRole: 'read', - workspaceApiKey: 'deny', - ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, - }), read: defineWorkspaceOperation({ id: 'knowledge.read', minimumRole: 'read', @@ -65,6 +68,19 @@ export const knowledgeOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + /** + * Un-archives a soft-deleted knowledge base. + * + * Deliberately the same policy as {@link knowledgeOperations.delete}: an + * operation's inverse must not be harder to reach than the operation, or a + * principal can archive a knowledge base it is then unable to recover. + */ + restore: defineWorkspaceOperation({ + id: 'knowledge.restore', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), bulkMoveItems: defineWorkspaceOperation({ id: 'knowledge.bulk_move_items', minimumRole: 'write', @@ -269,14 +285,22 @@ export const knowledgeOperations = { workspaceApiKey: 'deny', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), + /** + * Bulk upsert of a knowledge base's tag vocabulary. + * + * Named for the knowledge base it writes, not the document a caller used to + * address it through: the write targets `knowledge_base_tag_definitions` and + * its audit entry has always been a `KNOWLEDGE_BASE` one. + */ saveDocumentTagDefinitions: defineWorkspaceOperation({ - id: 'knowledge.tags.save_document_definitions', + id: 'knowledge.tags.bulk_save', minimumRole: 'write', workspaceApiKey: 'deny', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), + /** Removal over that same vocabulary — unused definitions, or all of them. */ deleteDocumentTagDefinitions: defineWorkspaceOperation({ - id: 'knowledge.tags.delete_document_definitions', + id: 'knowledge.tags.cleanup', minimumRole: 'write', workspaceApiKey: 'deny', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, diff --git a/apps/sim/lib/knowledge/application/tags.test.ts b/apps/sim/lib/knowledge/application/tags.test.ts index d5ca973db3c..97f0febd3af 100644 --- a/apps/sim/lib/knowledge/application/tags.test.ts +++ b/apps/sim/lib/knowledge/application/tags.test.ts @@ -10,12 +10,15 @@ const mocks = vi.hoisted(() => ({ resolveDocument: vi.fn(), resolvePermission: vi.fn(), listTags: vi.fn(), + listAllTags: vi.fn(), nextSlot: vi.fn(), createTag: vi.fn(), updateTag: vi.fn(), deleteTag: vi.fn(), readUsage: vi.fn(), saveTags: vi.fn(), + cleanupTags: vi.fn(), + deleteAllTags: vi.fn(), recordAudit: vi.fn(), })) @@ -44,19 +47,24 @@ vi.mock('@/lib/knowledge/application/contexts', () => ({ vi.mock('@/lib/knowledge/tags/service', () => ({ getDocumentTagDefinitions: mocks.listTags, + getTagDefinitions: mocks.listAllTags, getNextAvailableSlot: mocks.nextSlot, createTagDefinition: mocks.createTag, updateTagDefinition: mocks.updateTag, deleteTagDefinition: mocks.deleteTag, getTagUsageStats: mocks.readUsage, createOrUpdateTagDefinitionsBulk: mocks.saveTags, + cleanupUnusedTagDefinitions: mocks.cleanupTags, + deleteAllTagDefinitions: mocks.deleteAllTags, })) import { createKnowledgeTag, + deleteKnowledgeDocumentTagDefinitions, deleteKnowledgeTag, listKnowledgeTags, readKnowledgeTagUsage, + readNextKnowledgeTagSlot, saveKnowledgeDocumentTagDefinitions, updateKnowledgeTag, } from '@/lib/knowledge/application/tags' @@ -118,6 +126,7 @@ describe('knowledge tag application use cases', () => { mocks.resolveTag.mockResolvedValue(tagContext) mocks.resolveDocument.mockResolvedValue(documentContext) mocks.saveTags.mockResolvedValue({ created: [], updated: [], errors: [] }) + mocks.listAllTags.mockResolvedValue([]) }) it.each([ @@ -244,6 +253,136 @@ describe('knowledge tag application use cases', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) + /** + * Neither uniqueness invariant can be checked before the write: the read that + * would check it and the insert that depends on the answer are separate + * statements. `tagSlot` is a caller parameter too, so an occupied slot reaches + * the index on the first try — a 500 for an ordinary well-formed request. + */ + it.each([ + ['kb_tag_definitions_kb_slot_idx', /slot is already in use/i], + ['kb_tag_definitions_kb_display_name_idx', /name already exists/i], + ])('reports a create that loses at %s as a conflict', async (constraint, message) => { + mocks.createTag.mockRejectedValueOnce( + Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + constraint_name: constraint, + }) + ) + + await expect( + createKnowledgeTag.execute({ + principal: sessionPrincipal, + input: { + knowledgeBaseId: 'knowledge-b', + tagSlot: 'tag1', + displayName: 'Region', + fieldType: 'text', + }, + }) + ).rejects.toMatchObject({ code: 'conflict', message: expect.stringMatching(message) }) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + /** + * A tag's slot is fixed, and each slot holds one kind of value. Without this + * guard a tag sitting in a text slot could be relabelled `number`, and every + * later read would interpret its text values as the wrong type. + */ + it.each([ + ['number', 'tag1'], + ['date', 'tag1'], + ])('rejects changing fieldType to %s, invalid for the tag slot', async (fieldType, tagSlot) => { + mocks.resolveTag.mockResolvedValueOnce({ + ...tagContext, + tagDefinition: { ...tagContext.tagDefinition, tagSlot, fieldType: 'text' }, + }) + + await expect( + updateKnowledgeTag.execute({ + principal: sessionPrincipal, + input: { + knowledgeBaseId: 'knowledge-b', + tagDefinitionId: 'tag-1', + updates: { fieldType }, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.updateTag).not.toHaveBeenCalled() + }) + + it('rejects an unknown fieldType on update, as create does', async () => { + await expect( + updateKnowledgeTag.execute({ + principal: sessionPrincipal, + input: { + knowledgeBaseId: 'knowledge-b', + tagDefinitionId: 'tag-1', + updates: { fieldType: 'nonsense' }, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.updateTag).not.toHaveBeenCalled() + }) + + it('allows a rename that leaves the field type alone', async () => { + mocks.updateTag.mockResolvedValueOnce({ ...tagContext.tagDefinition, displayName: 'Region' }) + + await updateKnowledgeTag.execute({ + principal: sessionPrincipal, + input: { + knowledgeBaseId: 'knowledge-b', + tagDefinitionId: 'tag-1', + updates: { displayName: 'Region' }, + }, + }) + + expect(mocks.updateTag).toHaveBeenCalledTimes(1) + }) + + it('reports a rename onto a taken name as a conflict', async () => { + mocks.updateTag.mockRejectedValueOnce( + Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + constraint_name: 'kb_tag_definitions_kb_display_name_idx', + }) + ) + + await expect( + updateKnowledgeTag.execute({ + principal: sessionPrincipal, + input: { + knowledgeBaseId: 'knowledge-b', + tagDefinitionId: 'tag-1', + updates: { displayName: 'Region' }, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + /** A not-null or foreign-key violation is a real fault and must stay one. */ + it('propagates a non-uniqueness database failure', async () => { + mocks.createTag.mockRejectedValueOnce( + Object.assign(new Error('null value in column violates not-null constraint'), { + code: '23502', + }) + ) + + await expect( + createKnowledgeTag.execute({ + principal: sessionPrincipal, + input: { + knowledgeBaseId: 'knowledge-b', + tagSlot: 'tag1', + displayName: 'Region', + fieldType: 'text', + }, + }) + ).rejects.toThrow('not-null constraint') + }) + it('accepts a create slot matching its field type', async () => { const tagDefinition = { ...tagContext.tagDefinition, id: 'tag-new' } mocks.createTag.mockResolvedValueOnce(tagDefinition) @@ -290,6 +429,107 @@ describe('knowledge tag application use cases', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) + /** + * `TAG_SLOT_CONFIG` gives text 7 slots but number 5, boolean 3, and date 2, so + * a fixed total of 7 reported free capacity a narrower field type does not + * have — `number` with four slots taken read as three remaining when one did. + */ + it.each([ + ['text', 7], + ['number', 5], + ['boolean', 3], + ['date', 2], + ] as const)( + 'reports %s capacity from its own slot table, not the text one', + async (fieldType, maxSlots) => { + mocks.listAllTags.mockResolvedValue([ + { tagSlot: `${fieldType}-slot-1`, fieldType }, + { tagSlot: `${fieldType}-slot-2`, fieldType }, + { tagSlot: 'tag7', fieldType: 'text-other' }, + ]) + mocks.nextSlot.mockResolvedValue(`${fieldType}-slot-3`) + + await expect( + readNextKnowledgeTagSlot.execute({ + principal: sessionPrincipal, + input: { knowledgeBaseId: 'knowledge-b', fieldType }, + }) + ).resolves.toEqual({ + nextAvailableSlot: `${fieldType}-slot-3`, + fieldType, + usedSlots: [`${fieldType}-slot-1`, `${fieldType}-slot-2`], + totalSlots: maxSlots, + availableSlots: maxSlots - 2, + }) + } + ) + + it('reports no capacity once the field type is exhausted', async () => { + mocks.listAllTags.mockResolvedValue([{ tagSlot: 'date1', fieldType: 'date' }]) + mocks.nextSlot.mockResolvedValue(null) + + await expect( + readNextKnowledgeTagSlot.execute({ + principal: sessionPrincipal, + input: { knowledgeBaseId: 'knowledge-b', fieldType: 'date' }, + }) + ).resolves.toMatchObject({ totalSlots: 2, availableSlots: 0 }) + }) + + /** + * Both vocabulary writes act on the knowledge base: the bulk save writes + * `knowledge_base_tag_definitions` keyed by base and slot, and the cleanup + * deletes definitions across every document in the base. Neither reads a + * document, so neither resolves one — the canonical context they load is the + * knowledge base, and their audit entry names it. + */ + it('resolves the knowledge base rather than a document for a bulk save', async () => { + mocks.saveTags.mockResolvedValue({ created: [], updated: [], errors: [] }) + + await saveKnowledgeDocumentTagDefinitions.execute({ + principal: sessionPrincipal, + input: { + knowledgeBaseId: 'knowledge-b', + definitions: [{ tagSlot: 'tag1', displayName: 'Region', fieldType: 'text' }], + }, + }) + + expect(mocks.resolveKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ knowledgeBaseId: 'knowledge-b' }) + ) + expect(mocks.resolveDocument).not.toHaveBeenCalled() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: 'knowledge_base', resourceId: 'knowledge-b' }) + ) + }) + + it('cleans unused definitions across the knowledge base without resolving a document', async () => { + mocks.cleanupTags.mockResolvedValue(3) + + await expect( + deleteKnowledgeDocumentTagDefinitions.execute({ + principal: sessionPrincipal, + input: { knowledgeBaseId: 'knowledge-b', action: 'cleanup' }, + }) + ).resolves.toEqual({ action: 'cleanup', count: 3 }) + + expect(mocks.cleanupTags).toHaveBeenCalledWith('knowledge-b', expect.any(String)) + expect(mocks.resolveDocument).not.toHaveBeenCalled() + }) + + it('deletes the whole vocabulary when the caller asks for all', async () => { + mocks.deleteAllTags.mockResolvedValue(7) + + await expect( + deleteKnowledgeDocumentTagDefinitions.execute({ + principal: sessionPrincipal, + input: { knowledgeBaseId: 'knowledge-b', action: 'all' }, + }) + ).resolves.toEqual({ action: 'all', count: 7 }) + + expect(mocks.cleanupTags).not.toHaveBeenCalled() + }) + it('preserves legacy bulk rename payloads whose existing slot and field type differ', async () => { const definitions = [ { diff --git a/apps/sim/lib/knowledge/application/tags.ts b/apps/sim/lib/knowledge/application/tags.ts index 3264a6b3c07..9d044a466d1 100644 --- a/apps/sim/lib/knowledge/application/tags.ts +++ b/apps/sim/lib/knowledge/application/tags.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' @@ -12,6 +13,7 @@ import { getFieldTypeForSlot, isValidSlotForFieldType, SUPPORTED_FIELD_TYPES, + TAG_SLOT_CONFIG, } from '@/lib/knowledge/constants' import { cleanupUnusedTagDefinitions, @@ -43,6 +45,12 @@ export interface CreateKnowledgeTagInput extends ListKnowledgeTagsInput { export interface UpdateKnowledgeTagInput { tagDefinitionId: string + /** + * Knowledge base the caller addressed the definition through. Supplying it + * makes {@link resolveActiveKnowledgeTagContext} 404 a definition that lives + * in a sibling base, which is what a nested route path asserts. + */ + knowledgeBaseId?: string assertedWorkspaceId?: string updates: UpdateTagDefinitionData source?: string @@ -61,16 +69,66 @@ export interface KnowledgeDocumentTagDefinitionsInput extends ListKnowledgeTagsI documentId: string } -export interface SaveKnowledgeDocumentTagDefinitionsInput - extends KnowledgeDocumentTagDefinitionsInput { +/** + * Bulk vocabulary upsert. + * + * Knowledge-base scoped, like the rows it writes. `documentId` is accepted and + * ignored: the legacy internal route still addresses this write through + * `/api/knowledge/{id}/documents/{documentId}/tag-definitions`, a path that names + * a document the write never reads. + */ +export interface SaveKnowledgeDocumentTagDefinitionsInput extends ListKnowledgeTagsInput { + documentId?: string definitions: BulkTagDefinitionsData['definitions'] } -export interface DeleteKnowledgeDocumentTagDefinitionsInput - extends KnowledgeDocumentTagDefinitionsInput { +export interface DeleteKnowledgeDocumentTagDefinitionsInput extends ListKnowledgeTagsInput { + documentId?: string + /** `cleanup` removes only definitions no document still uses; `all` removes every one. */ action?: 'cleanup' | 'all' } +/** The two unique indexes on `knowledge_base_tag_definitions`, by name. */ +const TAG_SLOT_UNIQUE_INDEX = 'kb_tag_definitions_kb_slot_idx' +const TAG_DISPLAY_NAME_UNIQUE_INDEX = 'kb_tag_definitions_kb_display_name_idx' + +/** + * Reports a tag uniqueness violation as a conflict rather than a fault. + * + * Neither slot occupancy nor display-name uniqueness is checked before the + * write, and neither can be: the read that would check them and the insert that + * depends on the answer are separate statements, so two concurrent creates both + * pass and one loses at the index. Even single-threaded, `tagSlot` is a caller + * parameter and the next-free-slot search only runs when it is omitted, so a + * caller naming an occupied slot reaches the index directly. + * + * The index name distinguishes the two so the message says which value to + * change. Anything else propagates — a foreign-key or not-null violation is a + * real fault and must not be reported as the caller's conflict. + */ +function tagUniquenessConflict(error: unknown): never { + if (getPostgresErrorCode(error) === '23505') { + const constraint = getPostgresConstraintName(error) + if (constraint === TAG_SLOT_UNIQUE_INDEX) { + throw new OrchestrationError( + 'conflict', + 'That tag slot is already in use in this knowledge base; omit tagSlot to take the next free one.' + ) + } + if (constraint === TAG_DISPLAY_NAME_UNIQUE_INDEX) { + throw new OrchestrationError( + 'conflict', + 'A tag with that name already exists in this knowledge base' + ) + } + throw new OrchestrationError( + 'conflict', + 'That tag conflicts with one that already exists in this knowledge base' + ) + } + throw error +} + export const listKnowledgeTags = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.listTags, resolveContext: ({ input }: { input: ListKnowledgeTagsInput }) => @@ -114,7 +172,7 @@ export const createKnowledgeTag = defineAuthorizedKnowledgeUseCase({ fieldType, }, generateRequestId() - ) + ).catch(tagUniquenessConflict) return { tagDefinition, knowledgeBaseId: context.knowledgeBaseId } }, projectAudit: ({ input, context, result }) => ({ @@ -144,12 +202,35 @@ export const updateKnowledgeTag = defineAuthorizedKnowledgeUseCase({ if (input.updates.displayName === undefined && input.updates.fieldType === undefined) { throw new OrchestrationError('validation', 'No tag updates specified') } + + /** + * A tag's slot is fixed for its lifetime, and each slot only holds one kind + * of value — so changing `fieldType` is only meaningful when the new type is + * valid for the slot the definition already occupies. Create checks this; + * update did not, so a tag in a text slot could be relabelled `number` and + * every read of it would then interpret text as the wrong type. The same + * two checks as create, in the same order. + */ + const { fieldType } = input.updates + if (fieldType !== undefined) { + if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(fieldType)) { + throw new OrchestrationError('validation', 'Invalid field type') + } + const tagSlot = context.tagDefinition.tagSlot + if (!isValidSlotForFieldType(tagSlot, fieldType)) { + throw new OrchestrationError( + 'validation', + `Tag slot "${tagSlot}" is not valid for field type "${fieldType}"; a tag's slot cannot change, so create a new tag of that type instead` + ) + } + } + return { tagDefinition: await updateTagDefinition( context.tagDefinitionId, input.updates, generateRequestId() - ), + ).catch(tagUniquenessConflict), knowledgeBaseId: context.knowledgeBaseId, } }, @@ -220,27 +301,35 @@ export const readNextKnowledgeTagSlot = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: ReadNextKnowledgeTagSlotInput }) => resolveActiveKnowledgeResourceContext(input), async execute({ input, context }) { - if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(input.fieldType)) { + const fieldType = SUPPORTED_FIELD_TYPES.find((supported) => supported === input.fieldType) + if (!fieldType) { throw new OrchestrationError('validation', 'Invalid field type') } const existingDefinitions = await getTagDefinitions(context.knowledgeBaseId) const usedSlots = existingDefinitions - .filter((definition) => definition.fieldType === input.fieldType) + .filter((definition) => definition.fieldType === fieldType) .map((definition) => definition.tagSlot) const existingBySlot = new Map( existingDefinitions.map((definition) => [definition.tagSlot, definition]) ) const nextAvailableSlot = await getNextAvailableSlot( context.knowledgeBaseId, - input.fieldType, + fieldType, existingBySlot ) + /** + * Capacity is per field type, not the text capacity for all four: + * `TAG_SLOT_CONFIG` gives text 7 slots but number 5, boolean 3, and date 2, + * so a fixed 7 overstated both the total and what remains on the other + * three. + */ + const { maxSlots } = TAG_SLOT_CONFIG[fieldType] return { nextAvailableSlot, - fieldType: input.fieldType, + fieldType, usedSlots, - totalSlots: 7, - availableSlots: nextAvailableSlot ? 7 - usedSlots.length : 0, + totalSlots: maxSlots, + availableSlots: nextAvailableSlot ? maxSlots - usedSlots.length : 0, } }, }) @@ -254,10 +343,18 @@ export const listKnowledgeDocumentTagDefinitions = defineAuthorizedKnowledgeUseC }, }) +/** + * Creates or updates tag definitions in bulk on the knowledge base. + * + * The knowledge base is the canonical context, not a document: every write here + * lands in `knowledge_base_tag_definitions` keyed by knowledge base and slot, and + * the audit entry it projects is a `KNOWLEDGE_BASE` one. Tag *values* on one + * document are written by the document update through its tag slots. + */ export const saveKnowledgeDocumentTagDefinitions = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.saveDocumentTagDefinitions, resolveContext: ({ input }: { input: SaveKnowledgeDocumentTagDefinitionsInput }) => - resolveCanonicalActiveKnowledgeDocumentContext(input), + resolveActiveKnowledgeResourceContext(input), async execute({ input, context }) { for (const definition of input.definitions) { if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(definition.fieldType)) { @@ -291,10 +388,15 @@ export const saveKnowledgeDocumentTagDefinitions = defineAuthorizedKnowledgeUseC }), }) +/** + * Removes tag definitions from the knowledge base — the unused ones, or all of + * them. Knowledge-base scoped for the same reason the bulk save is: cleanup + * deletes definitions across the whole vocabulary, not one document's. + */ export const deleteKnowledgeDocumentTagDefinitions = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.deleteDocumentTagDefinitions, resolveContext: ({ input }: { input: DeleteKnowledgeDocumentTagDefinitionsInput }) => - resolveCanonicalActiveKnowledgeDocumentContext(input), + resolveActiveKnowledgeResourceContext(input), async execute({ input, context }) { if (input.action === 'cleanup') { return { diff --git a/apps/sim/lib/knowledge/chunks/keyset-sql.test.ts b/apps/sim/lib/knowledge/chunks/keyset-sql.test.ts new file mode 100644 index 00000000000..d545ed7e60c --- /dev/null +++ b/apps/sim/lib/knowledge/chunks/keyset-sql.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import type { SQL } from 'drizzle-orm' +import { PgDialect } from 'drizzle-orm/pg-core' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') + +import { queryChunks } from '@/lib/knowledge/chunks/service' +import type { ChunkSortBy } from '@/lib/knowledge/chunks/types' + +/** + * The SQL the chunk list actually generates, rendered rather than mocked. + * + * `chunks.test.ts` stubs `queryChunks` outright and the application tests stub + * `db`, so nothing there would notice a fragment Postgres cannot parse — which + * is the failure mode this list is exposed to: the `enabled` sort orders and + * compares on a hand-written `case when` expression rather than a plain column, + * and it appears in both `ORDER BY` and the keyset's `>`/`<` comparison. A green + * suite is not evidence for either. + * + * This renders through drizzle's own `PgDialect`, which is what builds the + * string sent to Postgres. It proves the fragment is well-formed and that every + * caller value is bound rather than interpolated; it does not prove Postgres + * accepts it, because this repo has no Postgres in its test environment. + */ +const dialect = new PgDialect() + +function render(fragment: unknown): { sql: string; params: unknown[] } { + const query = dialect.sqlToQuery(fragment as SQL) + return { sql: query.sql, params: query.params } +} + +/** The `WHERE` predicate and the `ORDER BY` list of the page read. */ +async function readPageSql(sortBy: ChunkSortBy, sortOrder: 'asc' | 'desc', cursorKeys?: unknown[]) { + await queryChunks( + 'document-1', + { sortBy, sortOrder, limit: 10, cursorKeys: cursorKeys as never }, + 'request-1' + ) + return { + where: render(dbChainMockFns.where.mock.calls[0]?.[0]), + orderBy: (dbChainMockFns.orderBy.mock.calls[0] ?? []).map(render), + } +} + +const ENABLED_CASE = 'case when "embedding"."enabled" then 1 else 0 end' + +describe('chunk list generated SQL', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it.each([ + ['chunkIndex', 'asc', '"embedding"."chunk_index" asc'], + ['chunkIndex', 'desc', '"embedding"."chunk_index" desc'], + ['tokenCount', 'asc', '"embedding"."token_count" asc'], + ['tokenCount', 'desc', '"embedding"."token_count" desc'], + ['enabled', 'asc', `${ENABLED_CASE} asc`], + ['enabled', 'desc', `${ENABLED_CASE} desc`], + ] as const)( + 'orders %s %s on the declared expression, tie-broken by id', + async (sortBy, sortOrder, leading) => { + const { orderBy } = await readPageSql(sortBy, sortOrder) + + expect(orderBy.map((entry) => entry.sql)).toEqual([leading, `"embedding"."id" ${sortOrder}`]) + } + ) + + it.each([ + ['chunkIndex', 'asc', '>', '"embedding"."chunk_index"'], + ['chunkIndex', 'desc', '<', '"embedding"."chunk_index"'], + ['tokenCount', 'asc', '>', '"embedding"."token_count"'], + ['tokenCount', 'desc', '<', '"embedding"."token_count"'], + ['enabled', 'asc', '>', ENABLED_CASE], + ['enabled', 'desc', '<', ENABLED_CASE], + ] as const)( + 'resumes %s %s strictly after the cursor on the same expression', + async (sortBy, sortOrder, comparison, expression) => { + const { where } = await readPageSql(sortBy, sortOrder, [1, 'chunk-1']) + + expect(where.sql).toContain(`${expression} ${comparison} $`) + expect(where.sql).toContain(`${expression} = $`) + expect(where.params).toEqual(['document-1', 1, 1, 'chunk-1']) + } + ) + + it('binds a search term with its LIKE wildcards escaped', async () => { + await queryChunks('document-1', { search: '100%_raw\\' }, 'request-1') + + const where = render(dbChainMockFns.where.mock.calls[0]?.[0]) + expect(where.sql).toContain('"embedding"."content" ilike $') + expect(where.params).toContain('%100\\%\\_raw\\\\%') + }) +}) diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index 6c6f5155770..bd205d10f26 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -3,13 +3,24 @@ import { document, embedding, knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { generateId } from '@sim/utils/id' -import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { + type KeysetKey, + keysetColumns, + keysetPage, + listOrderBy, + numberKey, + resumeKeyset, + searchFilter, + textKey, +} from '@/lib/api/list-query' import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' import type { BatchOperationResult, ChunkData, ChunkFilters, ChunkQueryResult, + ChunkSortBy, CreateChunkData, } from '@/lib/knowledge/chunks/types' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' @@ -22,7 +33,45 @@ const logger = createLogger('ChunksService') const KB_CHUNK_LOCK_TIMEOUT_MS = 5_000 /** - * Query chunks for a document with filtering and pagination + * The keyset each chunk sort pages on. + * + * Every list ends in `embedding.id`, which is what separates rows the leading + * key ties on: `tokenCount` and `enabled` are both non-unique, so without the + * tiebreaker a page boundary landing inside a run of equal values either + * repeats or drops the tied rows. `chunkIndex` is unique per document, but it + * carries the tiebreaker too so the three sorts encode the same arity and a + * cursor cannot be replayed across them by accident. + */ +const CHUNK_SORTS = { + chunkIndex: [ + numberKey(embedding.chunkIndex, (row) => row.chunkIndex), + textKey(embedding.id, (row) => row.id), + ], + tokenCount: [ + numberKey(embedding.tokenCount, (row) => row.tokenCount), + textKey(embedding.id, (row) => row.id), + ], + /** + * Ordered on `false < true` as the boolean column itself sorts, projected to + * `0`/`1` so the cursor carries a value a keyset key can bind. A bare boolean + * has no {@link KeysetKey} codec, and inventing one for a single sort would + * put a shared helper behind a column only this list orders by. + */ + enabled: [ + numberKey(sql`case when ${embedding.enabled} then 1 else 0 end`, (row) => + row.enabled ? 1 : 0 + ), + textKey(embedding.id, (row) => row.id), + ], +} satisfies Record[]> + +/** + * Query chunks for a document with filtering and pagination. + * + * Two positioning schemes share one read. `cursorKeys` is the keyset the public + * surface pages on; `offset` is the ordinal the internal surface has always + * used. They are never combined — a keyset request sends no offset — so the + * ordinal simply stays zero for the paged caller. */ export async function queryChunks( documentId: string, @@ -36,7 +85,9 @@ export async function queryChunks( offset = 0, sortBy = 'chunkIndex', sortOrder = 'asc', + cursorKeys, } = filters + const keys = CHUNK_SORTS[sortBy] const conditions = [eq(embedding.documentId, documentId)] @@ -46,11 +97,19 @@ export async function queryChunks( conditions.push(eq(embedding.enabled, false)) } - if (search) { - conditions.push(ilike(embedding.content, `%${search}%`)) + const contentSearch = searchFilter(embedding.content, search) + if (contentSearch) { + conditions.push(contentSearch) } - const chunks = await db + /** + * `total` counts the filtered set, so it is read from the filters alone. The + * keyset resume narrows the *page*, and folding it into the count would turn + * a total into a remainder that shrinks with every page. + */ + const pageConditions = [...conditions, resumeKeyset(keys, cursorKeys, sortOrder)] + + const rows = await db .select({ id: embedding.id, chunkIndex: embedding.chunkIndex, @@ -71,19 +130,9 @@ export async function queryChunks( updatedAt: embedding.updatedAt, }) .from(embedding) - .where(and(...conditions)) - .orderBy( - (() => { - const col = - sortBy === 'tokenCount' - ? embedding.tokenCount - : sortBy === 'enabled' - ? embedding.enabled - : embedding.chunkIndex - return sortOrder === 'desc' ? desc(col) : asc(col) - })() - ) - .limit(limit) + .where(and(...pageConditions)) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + .limit(limit + 1) .offset(offset) const totalCount = await db @@ -91,15 +140,18 @@ export async function queryChunks( .from(embedding) .where(and(...conditions)) - logger.info(`[${requestId}] Retrieved ${chunks.length} chunks for document ${documentId}`) + const page = keysetPage(keys, rows as ChunkData[], limit) + + logger.info(`[${requestId}] Retrieved ${page.data.length} chunks for document ${documentId}`) return { - chunks: chunks as ChunkData[], + chunks: page.data, + nextCursorKeys: page.nextCursorKeys, pagination: { total: Number(totalCount[0]?.count || 0), limit, offset, - hasMore: chunks.length === limit, + hasMore: page.nextCursorKeys !== null, }, } } diff --git a/apps/sim/lib/knowledge/chunks/types.ts b/apps/sim/lib/knowledge/chunks/types.ts index 2532828b2e9..e668d913439 100644 --- a/apps/sim/lib/knowledge/chunks/types.ts +++ b/apps/sim/lib/knowledge/chunks/types.ts @@ -1,10 +1,18 @@ +import type { CursorKey } from '@/lib/api/list-query' + +export const CHUNK_SORT_FIELDS = ['chunkIndex', 'tokenCount', 'enabled'] as const + +export type ChunkSortBy = (typeof CHUNK_SORT_FIELDS)[number] + export interface ChunkFilters { search?: string enabled?: 'true' | 'false' | 'all' limit?: number offset?: number - sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled' + sortBy?: ChunkSortBy sortOrder?: 'asc' | 'desc' + /** Keyset position from a previous page. Never combined with `offset`. */ + cursorKeys?: CursorKey[] } export interface ChunkData { @@ -29,6 +37,8 @@ export interface ChunkData { export interface ChunkQueryResult { chunks: ChunkData[] + /** Keys resuming the next page, or `null` on the last one. */ + nextCursorKeys: CursorKey[] | null pagination: { total: number limit: number diff --git a/apps/sim/lib/knowledge/orchestration/restore.ts b/apps/sim/lib/knowledge/orchestration/restore.ts index c162dc160b7..be4881743de 100644 --- a/apps/sim/lib/knowledge/orchestration/restore.ts +++ b/apps/sim/lib/knowledge/orchestration/restore.ts @@ -20,10 +20,17 @@ export interface RestorableKnowledgeBase { name: string workspaceId: string | null userId: string + /** `null` when the knowledge base is active — a restore is then a no-op. */ + deletedAt: Date | null } export interface PerformRestoreKnowledgeBaseParams extends KnowledgeOperationContext { knowledgeBaseId: string + /** + * `false` when an application use case projects the audit entry from the + * authoritative result instead, so the restore is not recorded twice. + */ + recordSemanticAudit?: boolean } export type PerformRestoreKnowledgeBaseResult = KnowledgeOrchestrationResult<{ @@ -44,6 +51,7 @@ export async function getRestorableKnowledgeBase( name: knowledgeBase.name, workspaceId: knowledgeBase.workspaceId, userId: knowledgeBase.userId, + deletedAt: knowledgeBase.deletedAt, }) .from(knowledgeBase) .where(eq(knowledgeBase.id, knowledgeBaseId)) @@ -72,17 +80,19 @@ export async function performRestoreKnowledgeBase( logger.info(`[${requestId}] Restored knowledge base ${knowledgeBaseId}`) - recordAudit({ - workspaceId: kb.workspaceId, - ...auditActorFields(params), - action: AuditAction.KNOWLEDGE_BASE_RESTORED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: knowledgeBaseId, - resourceName: kb.name, - description: `Restored knowledge base "${kb.name}"`, - metadata: { source, knowledgeBaseName: kb.name }, - ...(request ? { request } : {}), - }) + if (params.recordSemanticAudit !== false) { + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_RESTORED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBaseId, + resourceName: kb.name, + description: `Restored knowledge base "${kb.name}"`, + metadata: { source, knowledgeBaseName: kb.name }, + ...(request ? { request } : {}), + }) + } return { success: true, knowledgeBase: kb } } diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index cdea996fef8..439a693bd63 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -232,6 +232,62 @@ describe('listWorkspaceAndLegacyKnowledgeBases', () => { * be able to clear `workspaceId` (which would orphan the KB to its original * `userId`, who may not be the caller). */ +describe('updateKnowledgeBase — chunking config persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + dbChainMockFns.limit.mockReset() + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValue([{ workspaceId: 'ws-current', userId: 'u-1' }]) + }) + + /** + * The strategy fields are the half a `{ maxSize, minSize, overlap }` shape + * cannot describe, so they are what a narrower write type — or a destructure + * of only those three — drops. Nothing downstream re-derives them. + */ + it('persists every declared chunking field, strategy included', async () => { + await updateKnowledgeBase( + 'kb-1', + { + chunkingConfig: { + maxSize: 512, + minSize: 50, + overlap: 100, + strategy: 'markdown', + strategyOptions: { headingDepth: 3 }, + }, + }, + 'req-1' + ) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + chunkingConfig: { + maxSize: 512, + minSize: 50, + overlap: 100, + strategy: 'markdown', + strategyOptions: { headingDepth: 3 }, + }, + }) + ) + }) + + it('omits the strategy fields a caller did not set', async () => { + await updateKnowledgeBase( + 'kb-1', + { chunkingConfig: { maxSize: 512, minSize: 50, overlap: 100 } }, + 'req-1' + ) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + chunkingConfig: { maxSize: 512, minSize: 50, overlap: 100 }, + }) + ) + }) +}) + describe('updateKnowledgeBase — workspace transfer authorization', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index d0de80faee3..f4608251a82 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -3,6 +3,7 @@ import { document, knowledgeBase, knowledgeConnector, workspaceFiles } from '@si import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { filterUndefined } from '@sim/utils/object' import type { SQL } from 'drizzle-orm' import { and, count, eq, exists, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm' import type { V2KnowledgeBaseSortBy } from '@/lib/api/contracts/v2/knowledge' @@ -497,11 +498,7 @@ export async function updateKnowledgeBase( description?: string workspaceId?: string | null folderId?: string | null - chunkingConfig?: { - maxSize: number - minSize: number - overlap: number - } + chunkingConfig?: ChunkingConfig }, requestId: string, options?: { actorUserId?: string; assertedWorkspaceId?: string } @@ -516,7 +513,21 @@ export async function updateKnowledgeBase( if (updates.workspaceId !== undefined) updateData.workspaceId = updates.workspaceId if (updates.folderId !== undefined) updateData.folderId = updates.folderId if (updates.chunkingConfig !== undefined) { - updateData.chunkingConfig = updates.chunkingConfig + /** + * Projected field by field rather than assigned whole, so every member of + * {@link ChunkingConfig} is named here: `strategy` and `strategyOptions` + * used to survive only because structural typing let them ride on an + * object typed as the three size fields, and the first destructure of + * those three would have dropped them silently. + */ + const { maxSize, minSize, overlap, strategy, strategyOptions } = updates.chunkingConfig + updateData.chunkingConfig = filterUndefined({ + maxSize, + minSize, + overlap, + strategy, + strategyOptions, + }) } if (updates.workspaceId !== undefined && !options?.actorUserId) { diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index 5fe313989a3..8968da3067b 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -701,6 +701,7 @@ export async function getTagUsageStats( requestId: string ): Promise< Array<{ + id: string tagSlot: string displayName: string fieldType: string @@ -743,6 +744,7 @@ export async function getTagUsageStats( ) stats.push({ + id: def.id, tagSlot: def.tagSlot, displayName: def.displayName, fieldType: def.fieldType, diff --git a/apps/sim/lib/logs/application/get-log-stats.ts b/apps/sim/lib/logs/application/get-log-stats.ts new file mode 100644 index 00000000000..953f26e5b01 --- /dev/null +++ b/apps/sim/lib/logs/application/get-log-stats.ts @@ -0,0 +1,56 @@ +import { and } from 'drizzle-orm' +import { MAX_STATS_WORKFLOWS } from '@/lib/api/contracts/logs' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { logOperations } from '@/lib/logs/application/operations' +import { folderScopeCondition, resolveLogFolderScope } from '@/lib/logs/folder-scope' +import { buildLogFilters, type LogFilters } from '@/lib/logs/public-filters' +import { buildDashboardStats, resolveLogStatsWindow } from '@/lib/logs/stats' +import { readLogStatsBounds, readLogStatsSegments } from '@/lib/logs/stats-queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface GetLogStatsInput { + workspaceId: string + filters: Omit + folderPaths?: string[] + segmentCount: number +} + +export type GetLogStatsResult = ReturnType + +/** + * Time-bucketed run counts, success counts, and mean latency for a workspace — + * per workflow and in aggregate. + * + * The read and the aggregation are shared with the first-party dashboard + * (`lib/logs/stats-queries.ts` and `lib/logs/stats.ts`); the authorization is + * not. That route answers a caller without workspace access with a zeroed 200, + * which this surface must not do, so the two deliberately meet below the + * authorization boundary rather than at it. + */ +export const getLogStats = defineAuthorizedWorkspaceUseCase({ + operation: logOperations.readStats, + resolveContext: async ({ input }: { input: GetLogStatsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ input, context }): Promise => { + const folderScope = input.folderPaths + ? await resolveLogFolderScope(context.workspaceId, input.folderPaths) + : undefined + + const where = and( + buildLogFilters({ ...input.filters, workspaceId: context.workspaceId }), + folderScope ? folderScopeCondition(folderScope) : undefined + ) + + const bounds = await readLogStatsBounds(where) + const window = resolveLogStatsWindow(bounds, input.segmentCount) + const rows = await readLogStatsSegments(where, window.startTime.toISOString(), window.segmentMs) + return buildDashboardStats(rows, window, input.segmentCount, { + maxWorkflows: MAX_STATS_WORKFLOWS, + }) + }, +}) diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts index cf40838c5c2..5f68e0753be 100644 --- a/apps/sim/lib/logs/application/get-public-log.ts +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -1,8 +1,11 @@ +import type { CostLedger } from '@/lib/api/contracts/logs' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { logOperations } from '@/lib/logs/application/operations' +import { buildCostLedger } from '@/lib/logs/cost-ledger' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import { getPublicWorkflowLog, getPublicWorkflowLogScope } from '@/lib/logs/public-queries' import { sanitizeExecutionSnapshotState } from '@/lib/logs/snapshot-sanitizer' @@ -38,6 +41,14 @@ export interface GetPublicLogResult { */ workflowFolderPath: string | null executionData: Record + /** + * The run's itemized billing lines, or `null` when no ledger exists for it. + * + * `null` is a distinct answer from an empty item list and is reachable: the + * ledger is keyed on `usage_log` rows recorded with `source = 'workflow'`, so a + * run that predates the ledger has none at all. + */ + costLedger: CostLedger | null } /** @@ -84,7 +95,14 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ if (!log || log.workflowId !== context.workflowId) { throw new OrchestrationError('not_found', 'Log not found') } - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + } + ) const executionData = await materializeExecutionDataForDisplay( log.executionData as Record | null, { @@ -97,8 +115,10 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ if (log.workflowUserId && !log.workflowOwnerEmail) { throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) } + const costLedger = await buildCostLedger(log.executionId) return { log: { ...log, workflowState: sanitizeExecutionSnapshotState(log.workflowState) }, + costLedger, workflowFolderPath: publicLogFolderPath( folderIndex.pathById, log.workflowFolderId, diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts index 6b5ce4e9ea4..fd92332c6af 100644 --- a/apps/sim/lib/logs/application/list-public-logs.ts +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -1,33 +1,40 @@ +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' -import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { logOperations } from '@/lib/logs/application/operations' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { resolveLogFolderScope } from '@/lib/logs/folder-scope' import type { LogFilters } from '@/lib/logs/public-filters' -import { listPublicWorkflowLogs } from '@/lib/logs/public-queries' +import { + type PublicLogListRow, + type PublicLogSortField, + readPublicLogPage, +} from '@/lib/logs/public-queries' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' -type PublicLogRow = Awaited>['data'][number] - export interface ListPublicLogsInput { workspaceId: string - filters: Omit + filters: Omit folderPaths?: string[] + sortBy: PublicLogSortField + sortOrder: ListSortOrder + cursorKeys: CursorKey[] | undefined limit: number includeFullDetails: boolean includeFinalOutput: boolean includeTraceSpans: boolean + includeJobRuns: boolean } export interface PublicLogApplicationItem { - log: PublicLogRow + log: PublicLogListRow executionData?: Record } export interface ListPublicLogsResult { items: PublicLogApplicationItem[] - nextCursor: string | null + nextCursorKeys: CursorKey[] | null includeFullDetails: boolean includeFinalOutput: boolean includeTraceSpans: boolean @@ -42,37 +49,33 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ }, authorizationOptions: {}, execute: async ({ principal, input, context }): Promise => { - const folderIndex = input.folderPaths - ? await loadActiveFolderPathIndex(context.workspaceId, 'workflow') - : null - /** - * A path naming no active folder contributes nothing to the scope instead of - * failing the read, so `folderPaths=/live,/deleted` still returns the `/live` - * runs and `folderPaths=/deleted` alone returns an empty page. See - * {@link resolveFolderPathFilter} for why a filter's miss is an empty set. - */ - const resolvedFolderIds = input.folderPaths?.flatMap((path) => { - if (!folderIndex) return [] - const filter = resolveFolderPathFilter(folderIndex, path) - return filter.kind === 'folder' ? [filter.folderId] : [] - }) + const folderScope = input.folderPaths + ? await resolveLogFolderScope(context.workspaceId, input.folderPaths) + : undefined - const folderIds = resolvedFolderIds?.filter( - (folderId): folderId is string => typeof folderId === 'string' - ) - const includesRoot = resolvedFolderIds?.includes(null) ?? false const needsMaterialization = input.includeFinalOutput || input.includeTraceSpans - const { data, nextCursor } = await listPublicWorkflowLogs({ - filters: { ...input.filters, workspaceId: context.workspaceId, folderIds }, + const { data, nextCursorKeys } = await readPublicLogPage({ + filters: { ...input.filters, workspaceId: context.workspaceId }, limit: input.limit, includeExecutionData: needsMaterialization, - folderScope: input.folderPaths ? { includesRoot, folderIds: folderIds ?? [] } : undefined, + folderScope, + includeJobRuns: input.includeJobRuns, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + cursorKeys: input.cursorKeys, }) const userId = principal.kind === 'personal_api_key' ? principal.userId : undefined + /** + * Job runs carry no materializable execution data on this surface: their + * `execution_data` is a job envelope rather than a workflow trace, and + * `materializeExecutionDataForDisplay` is keyed on a workflow. They pass + * through unmaterialized rather than being handed a shape that does not + * describe them. + */ const items = needsMaterialization ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { - if (!log.executionData) return { log } + if (log.kind !== 'workflow' || !log.executionData) return { log } return { log, executionData: await materializeExecutionDataForDisplay( @@ -90,7 +93,7 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ return { items, - nextCursor, + nextCursorKeys, includeFullDetails: input.includeFullDetails, includeFinalOutput: input.includeFinalOutput, includeTraceSpans: input.includeTraceSpans, diff --git a/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts b/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts new file mode 100644 index 00000000000..d632f95666f --- /dev/null +++ b/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + readBounds: vi.fn(), + readSegments: vi.fn(), + resolveFolderScope: vi.fn(), + folderCondition: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/logs/stats-queries', () => ({ + readLogStatsBounds: mocks.readBounds, + readLogStatsSegments: mocks.readSegments, +})) + +vi.mock('@/lib/logs/folder-scope', () => ({ + resolveLogFolderScope: mocks.resolveFolderScope, + folderScopeCondition: mocks.folderCondition, + LOG_FOLDER_SCOPE_VERSION: 2, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getLogStats } from '@/lib/logs/application/get-log-stats' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', +} +const sessionPrincipal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', +} + +function segmentRow(workflowId: string) { + return { + workflowId, + workflowName: workflowId, + segmentIndex: 0, + totalExecutions: 2, + successfulExecutions: 1, + avgDurationMs: 100, + } +} + +describe('getLogStats', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.readBounds.mockResolvedValue({ + minTime: '2026-08-06T00:00:00.000Z', + maxTime: '2026-08-06T01:00:00.000Z', + }) + mocks.readSegments.mockResolvedValue([segmentRow('workflow-1')]) + mocks.resolveFolderScope.mockResolvedValue({ includesRoot: false, folderIds: ['folder-1'] }) + }) + + it('rejects a principal kind the operation does not accept before reading anything', async () => { + await expect( + getLogStats.execute({ + principal: sessionPrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 24 }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.readBounds).not.toHaveBeenCalled() + expect(mocks.readSegments).not.toHaveBeenCalled() + }) + + it('conceals a workspace that does not resolve', async () => { + mocks.loadWorkspace.mockResolvedValueOnce(null) + + await expect( + getLogStats.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 24 }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.readBounds).not.toHaveBeenCalled() + }) + + it('rejects a workspace key pointed at another workspace', async () => { + await expect( + getLogStats.execute({ + principal: { ...workspacePrincipal, workspaceId: 'workspace-2' }, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 24 }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.readBounds).not.toHaveBeenCalled() + }) + + it('derives the bucket width from the window before reading the segments', async () => { + await getLogStats.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 2 }, + }) + + expect(mocks.readSegments).toHaveBeenCalledWith( + expect.anything(), + '2026-08-06T00:00:00.000Z', + expect.any(Number) + ) + }) + + it('resolves the folder scope only after authorization, and only when asked', async () => { + await getLogStats.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 2 }, + }) + expect(mocks.resolveFolderScope).not.toHaveBeenCalled() + + await getLogStats.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/prod'], + segmentCount: 2, + }, + }) + expect(mocks.resolveFolderScope).toHaveBeenCalledWith('workspace-1', ['/prod']) + expect(mocks.folderCondition).toHaveBeenCalledWith({ + includesRoot: false, + folderIds: ['folder-1'], + }) + }) + + it('caps the per-workflow series while keeping the workspace totals exact', async () => { + mocks.readSegments.mockResolvedValueOnce( + Array.from({ length: 250 }, (_unused, index) => segmentRow(`workflow-${index}`)) + ) + + const { stats, workflowsTruncated } = await getLogStats.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 1 }, + }) + + expect(workflowsTruncated).toBe(true) + expect(stats.workflows).toHaveLength(200) + expect(stats.totalRuns).toBe(500) + }) + + it('records no audit for a read', async () => { + await getLogStats.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 2 }, + }) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('propagates infrastructure failures instead of turning them into a not-found', async () => { + const failure = new Error('replica unavailable') + mocks.readBounds.mockRejectedValueOnce(failure) + + await expect( + getLogStats.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 2 }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/logs/application/operations.ts b/apps/sim/lib/logs/application/operations.ts index 9026c369451..22b7f2debc5 100644 --- a/apps/sim/lib/logs/application/operations.ts +++ b/apps/sim/lib/logs/application/operations.ts @@ -9,6 +9,12 @@ export const logOperations = { workspaceApiKey: 'allow', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), + readStats: defineWorkspaceOperation({ + id: 'logs.read_stats', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), readDetail: defineWorkspaceOperation({ id: 'logs.read_detail', minimumRole: 'read', diff --git a/apps/sim/lib/logs/application/public-log-use-cases.test.ts b/apps/sim/lib/logs/application/public-log-use-cases.test.ts index de575537efc..4b5ba8013c1 100644 --- a/apps/sim/lib/logs/application/public-log-use-cases.test.ts +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -28,7 +28,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ vi.mock('@/lib/logs/public-queries', () => ({ getPublicWorkflowLogScope: mocks.getLogScope, getPublicWorkflowLog: mocks.getLog, - listPublicWorkflowLogs: mocks.listLogs, + readPublicLogPage: mocks.listLogs, })) vi.mock('@/lib/folders/queries', () => ({ @@ -98,6 +98,7 @@ const workspaceContext = { billedAccountUserId: 'billing-owner-1', } const log = { + kind: 'workflow' as const, executionId: 'run-1', workspaceId: 'workspace-1', workflowId: 'workflow-1', @@ -125,7 +126,7 @@ describe('public log application use cases', () => { workflowId: 'workflow-1', }) mocks.getLog.mockResolvedValue(log) - mocks.listLogs.mockResolvedValue({ data: [log], nextCursor: null }) + mocks.listLogs.mockResolvedValue({ data: [log], nextCursorKeys: null }) mocks.loadFolders.mockResolvedValue({ idByPath: new Map([['/agents', 'folder-1']]), pathById: new Map([['folder-1', '/agents']]), @@ -306,10 +307,14 @@ describe('public log application use cases', () => { input: { workspaceId: 'workspace-1', filters: {}, + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, limit: 50, includeFullDetails: false, includeFinalOutput: false, includeTraceSpans: true, + includeJobRuns: false, }, }) @@ -338,16 +343,20 @@ describe('public log application use cases', () => { workspaceId: 'workspace-1', filters: {}, folderPaths: ['/agents'], + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, limit: 50, includeFullDetails: false, includeFinalOutput: false, includeTraceSpans: false, + includeJobRuns: false, }, }) expect(mocks.listLogs).toHaveBeenCalledWith( expect.objectContaining({ - filters: expect.objectContaining({ workspaceId: 'workspace-1', folderIds: ['folder-1'] }), + filters: expect.objectContaining({ workspaceId: 'workspace-1' }), folderScope: { includesRoot: false, folderIds: ['folder-1'] }, }) ) @@ -367,17 +376,21 @@ describe('public log application use cases', () => { workspaceId: 'workspace-1', filters: {}, folderPaths: ['/missing'], + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, limit: 50, includeFullDetails: false, includeFinalOutput: false, includeTraceSpans: false, + includeJobRuns: false, }, }) expect(mocks.listLogs).toHaveBeenCalledWith( expect.objectContaining({ folderScope: { includesRoot: false, folderIds: [] } }) ) - expect(result.nextCursor).toBeNull() + expect(result.nextCursorKeys).toBeNull() }) it('keeps the folders that do resolve when one path in the set does not', async () => { @@ -387,10 +400,14 @@ describe('public log application use cases', () => { workspaceId: 'workspace-1', filters: {}, folderPaths: ['/agents', '/missing'], + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, limit: 50, includeFullDetails: false, includeFinalOutput: false, includeTraceSpans: false, + includeJobRuns: false, }, }) @@ -399,6 +416,122 @@ describe('public log application use cases', () => { ) }) + it('forwards the job-run union flag to the query', async () => { + await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + includeJobRuns: true, + }, + }) + + expect(mocks.listLogs).toHaveBeenCalledWith(expect.objectContaining({ includeJobRuns: true })) + }) + + /** + * A job run's `execution_data` is a job envelope rather than a workflow trace, + * and the display projection is keyed on a workflow, so it passes through + * unmaterialized instead of being handed a shape that does not describe it. + */ + it('does not materialize a job run', async () => { + mocks.listLogs.mockResolvedValueOnce({ + data: [{ kind: 'job', executionId: 'job-1', executionData: { pointer: true } }], + nextCursorKeys: null, + }) + + const result = await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, + limit: 50, + includeFullDetails: false, + includeFinalOutput: true, + includeTraceSpans: false, + includeJobRuns: true, + }, + }) + + expect(mocks.materialize).not.toHaveBeenCalled() + expect(result.items[0].executionData).toBeUndefined() + }) + + it("covers a selected folder's whole subtree", async () => { + mocks.loadFolders.mockResolvedValueOnce({ + idByPath: new Map([ + ['/agents', 'folder-1'], + ['/agents/nested', 'folder-2'], + ]), + pathById: new Map([ + ['folder-1', '/agents'], + ['folder-2', '/agents/nested'], + ]), + }) + + await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/agents'], + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + includeJobRuns: false, + }, + }) + + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ + folderScope: { includesRoot: false, folderIds: ['folder-1', 'folder-2'] }, + }) + ) + }) + + /** + * The sortable read folded into this list when `POST /logs/query` was retired, + * so the sort has to reach the query and the keyset has to come back out. + */ + it('forwards the requested sort and returns the keyset the next page resumes from', async () => { + mocks.listLogs.mockResolvedValueOnce({ data: [], nextCursorKeys: ['0.41', 'log-1'] }) + + const result = await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + sortBy: 'cost' as const, + sortOrder: 'asc' as const, + cursorKeys: undefined, + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + includeJobRuns: false, + }, + }) + + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ sortBy: 'cost', sortOrder: 'asc' }) + ) + expect(result.nextCursorKeys).toEqual(['0.41', 'log-1']) + }) + it('propagates run-store failures', async () => { const failure = new Error('database unavailable') mocks.getLogScope.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/logs/cost-ledger.test.ts b/apps/sim/lib/logs/cost-ledger.test.ts new file mode 100644 index 00000000000..36eb70375d0 --- /dev/null +++ b/apps/sim/lib/logs/cost-ledger.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { buildCostLedger } from '@/lib/logs/cost-ledger' + +function usageRow(overrides: Record = {}) { + return { + category: 'model', + description: 'gpt-5', + cost: '0.25', + metadata: null, + ...overrides, + } +} + +describe('buildCostLedger', () => { + beforeEach(() => { + resetDbChainMock() + }) + + /** + * `null` and `[]` are different answers and both are reachable, so neither may + * stand in for the other: `null` means no ledger exists for the run — it + * predates the ledger, or it is a job run, whose costs are not recorded under + * the workflow source this reads. + */ + it('reports no ledger rather than an empty one when nothing was recorded', async () => { + queueTableRows(schemaMock.usageLog, []) + + expect(await buildCostLedger('run-1')).toBeNull() + }) + + it('folds repeated lines for one item and sums their cost', async () => { + queueTableRows(schemaMock.usageLog, [usageRow(), usageRow({ cost: '0.75' })]) + + expect(await buildCostLedger('run-1')).toEqual({ + total: 1, + items: [{ category: 'model', description: 'gpt-5', cost: 1 }], + }) + }) + + it('keeps lines that differ in category or description apart', async () => { + queueTableRows(schemaMock.usageLog, [ + usageRow(), + usageRow({ category: 'tool', description: 'gpt-5' }), + usageRow({ description: 'claude-opus-5' }), + ]) + + const ledger = await buildCostLedger('run-1') + + expect(ledger?.items).toHaveLength(3) + expect(ledger?.total).toBeCloseTo(0.75) + }) + + it('reports token counts per call rather than accumulating them', async () => { + queueTableRows(schemaMock.usageLog, [ + usageRow({ metadata: { inputTokens: 100, outputTokens: 20 } }), + usageRow({ metadata: { inputTokens: 400, outputTokens: 5 } }), + ]) + + expect((await buildCostLedger('run-1'))?.items[0]).toMatchObject({ + inputTokens: 400, + outputTokens: 20, + }) + }) + + it('omits token fields entirely for a line that bills none', async () => { + queueTableRows(schemaMock.usageLog, [usageRow({ category: 'fixed', description: 'Base fee' })]) + + expect((await buildCostLedger('run-1'))?.items[0]).toEqual({ + category: 'fixed', + description: 'Base fee', + cost: 0.25, + }) + }) +}) diff --git a/apps/sim/lib/logs/cost-ledger.ts b/apps/sim/lib/logs/cost-ledger.ts new file mode 100644 index 00000000000..ad1012af706 --- /dev/null +++ b/apps/sim/lib/logs/cost-ledger.ts @@ -0,0 +1,64 @@ +import { db } from '@sim/db' +import { usageLog } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import type { CostLedger } from '@/lib/api/contracts/logs' + +/** + * The itemized billing lines for one run, or `null` when the run has no ledger. + * + * `null` and `[]` are different answers and both are reachable, so neither may + * stand in for the other. `null` means `usage_log` recorded nothing for the + * execution — a run that predates the ledger, or a job run, which the + * `source = 'workflow'` predicate excludes outright. An empty array would claim + * a ledger exists and itemizes to nothing. + * + * Lines are folded on `(category, description)` because the ledger records one + * row per billed event and a run can bill the same model many times; token + * counts take the maximum rather than the sum, matching how they are reported + * per call rather than accumulated. + */ +export async function buildCostLedger(executionId: string): Promise { + const rows = await db + .select({ + category: usageLog.category, + description: usageLog.description, + cost: usageLog.cost, + metadata: usageLog.metadata, + }) + .from(usageLog) + .where(and(eq(usageLog.executionId, executionId), eq(usageLog.source, 'workflow'))) + + if (rows.length === 0) return null + + type LedgerItem = CostLedger['items'][number] + const byKey = new Map() + for (const row of rows) { + const metadata = (row.metadata ?? {}) as { inputTokens?: number; outputTokens?: number } + const category = row.category as LedgerItem['category'] + const key = `${category}::${row.description}` + const existing = byKey.get(key) + if (existing) { + existing.cost += Number(row.cost) + if (typeof metadata.inputTokens === 'number') { + existing.inputTokens = Math.max(existing.inputTokens ?? 0, metadata.inputTokens) + } + if (typeof metadata.outputTokens === 'number') { + existing.outputTokens = Math.max(existing.outputTokens ?? 0, metadata.outputTokens) + } + } else { + byKey.set(key, { + category, + description: row.description, + cost: Number(row.cost), + ...(typeof metadata.inputTokens === 'number' ? { inputTokens: metadata.inputTokens } : {}), + ...(typeof metadata.outputTokens === 'number' + ? { outputTokens: metadata.outputTokens } + : {}), + }) + } + } + + const items = [...byKey.values()] + const total = items.reduce((sum, item) => sum + item.cost, 0) + return { total, items } +} diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index ecf8c820b1e..40846ec8ecf 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -2,13 +2,12 @@ import { db } from '@sim/db' import { jobExecutionLogs, pausedExecutions, - usageLog, workflow, workflowDeploymentVersion, workflowExecutionLogs, } from '@sim/db/schema' import { and, eq, type SQL } from 'drizzle-orm' -import type { CostLedger } from '@/lib/api/contracts/logs' +import { buildCostLedger } from '@/lib/logs/cost-ledger' import { hydrateChildTraces } from '@/lib/logs/execution/hydrate-child-traces' import { type ExecutionProgressMarkers, @@ -23,52 +22,6 @@ import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' type LookupColumn = 'id' | 'executionId' -async function buildCostLedger(executionId: string): Promise { - const rows = await db - .select({ - category: usageLog.category, - description: usageLog.description, - cost: usageLog.cost, - metadata: usageLog.metadata, - }) - .from(usageLog) - .where(and(eq(usageLog.executionId, executionId), eq(usageLog.source, 'workflow'))) - - if (rows.length === 0) return null - - type LedgerItem = CostLedger['items'][number] - const byKey = new Map() - for (const row of rows) { - const metadata = (row.metadata ?? {}) as { inputTokens?: number; outputTokens?: number } - const category = row.category as LedgerItem['category'] - const key = `${category}::${row.description}` - const existing = byKey.get(key) - if (existing) { - existing.cost += Number(row.cost) - if (typeof metadata.inputTokens === 'number') { - existing.inputTokens = Math.max(existing.inputTokens ?? 0, metadata.inputTokens) - } - if (typeof metadata.outputTokens === 'number') { - existing.outputTokens = Math.max(existing.outputTokens ?? 0, metadata.outputTokens) - } - } else { - byKey.set(key, { - category, - description: row.description, - cost: Number(row.cost), - ...(typeof metadata.inputTokens === 'number' ? { inputTokens: metadata.inputTokens } : {}), - ...(typeof metadata.outputTokens === 'number' - ? { outputTokens: metadata.outputTokens } - : {}), - }) - } - } - - const items = [...byKey.values()] - const total = items.reduce((sum, item) => sum + item.cost, 0) - return { total, items } -} - export function jobCostTotal(raw: unknown): { total: number } | null { const total = (raw as { total?: unknown } | null | undefined)?.total const n = total == null ? Number.NaN : Number(total) diff --git a/apps/sim/lib/logs/filters.test.ts b/apps/sim/lib/logs/filters.test.ts new file mode 100644 index 00000000000..13429da2697 --- /dev/null +++ b/apps/sim/lib/logs/filters.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { flattenMockConditions } from '@sim/testing' +import { describe, expect, it } from 'vitest' +import { buildFilterConditions } from '@/lib/logs/filters' + +/** + * The bound operands of every `ILIKE` clause the filter set produces. + * + * The mocked `sql` tag renders interpolations as `?`, so the columns a clause + * touches are visible in its params rather than in its text. The mock spells + * them table-qualified (`workflow.name`, not `name`), which is what makes + * `folder.name` and `workflow.name` distinguishable here at all. + */ +function likeClauseParams(params: Parameters[0]): unknown[][] { + const condition = buildFilterConditions(params, { useSimpleLevelFilter: true }) + return flattenMockConditions(condition) + .map((node) => (node as { toSQL?: () => { sql: string; params: unknown[] } }).toSQL?.()) + .filter((fragment): fragment is { sql: string; params: unknown[] } => + Boolean(fragment?.sql.includes('ILIKE')) + ) + .map((fragment) => fragment.params) +} + +describe('folderName filter', () => { + /** + * The regression this pins: `folderName` used to ILIKE `workflow.name`, a + * verbatim copy of the clause for `workflowName` directly above it, so a + * folder search quietly matched workflow names and reported the wrong runs + * with no error at all. + */ + it('matches against the folder table rather than the workflow name', () => { + const [clause] = likeClauseParams({ workspaceId: 'workspace-1', folderName: 'support' }) + + expect(clause).toContain('workflow.folderId') + expect(clause).toContain('folder.name') + expect(clause).toContain('%support%') + expect(clause).not.toContain('workflow.name') + expect(JSON.stringify(clause)).toContain('folder.deletedAt') + }) + + it('is a different predicate from the workflow-name filter', () => { + const byFolder = likeClauseParams({ workspaceId: 'workspace-1', folderName: 'support' }) + const byWorkflow = likeClauseParams({ workspaceId: 'workspace-1', workflowName: 'support' }) + + expect(byFolder).not.toEqual(byWorkflow) + }) + + it('leaves the workflow-name filter matching the workflow name alone', () => { + const [clause] = likeClauseParams({ workspaceId: 'workspace-1', workflowName: 'support' }) + + expect(clause).toEqual(['workflow.name', '%support%']) + }) + + it('adds no predicate when neither name filter is set', () => { + expect(likeClauseParams({ workspaceId: 'workspace-1' })).toEqual([]) + }) +}) diff --git a/apps/sim/lib/logs/filters.ts b/apps/sim/lib/logs/filters.ts index 0569e535a5c..844d68cabbe 100644 --- a/apps/sim/lib/logs/filters.ts +++ b/apps/sim/lib/logs/filters.ts @@ -1,4 +1,4 @@ -import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { folder, workflow, workflowExecutionLogs } from '@sim/db/schema' import { and, eq, gt, gte, inArray, lt, lte, ne, type SQL, sql } from 'drizzle-orm' import { z } from 'zod' import type { TimeRange } from '@/stores/logs/filters/types' @@ -165,6 +165,7 @@ function buildDateConditions( } function buildSearchConditions(params: { + workspaceId: string search?: string workflowName?: string folderName?: string @@ -182,9 +183,24 @@ function buildSearchConditions(params: { conditions.push(sql`${workflow.name} ILIKE ${nameTerm}`) } + /** + * Matches the folder the run's workflow lives in, by name. + * + * The subquery is what makes it a folder filter at all: this condition used to + * ILIKE `workflow.name`, a copy of the clause directly above it, so + * `folder:support` quietly searched workflow names and reported the wrong runs + * with no error. The log queries join `workflow` but not `folder`, so the + * lookup is expressed as a membership test rather than by adding a join every + * caller of this builder would have to make. + */ if (params.folderName) { const folderTerm = `%${params.folderName}%` - conditions.push(sql`${workflow.name} ILIKE ${folderTerm}`) + conditions.push( + // Scoped to the workspace and to workflow folders: `folder` holds every + // resource kind for every tenant, so an unscoped subquery is a full-table + // ILIKE on each search and matches ids it can never legitimately return. + sql`${workflow.folderId} IN (SELECT ${folder.id} FROM ${folder} WHERE ${folder.name} ILIKE ${folderTerm} AND ${folder.workspaceId} = ${params.workspaceId} AND ${folder.resourceType} = 'workflow' AND ${folder.deletedAt} IS NULL)` + ) } if (params.executionId) { @@ -297,6 +313,7 @@ export function buildFilterConditions( if (endCondition) conditions.push(endCondition) const searchConditions = buildSearchConditions({ + workspaceId: params.workspaceId, search: params.search, workflowName: params.workflowName, folderName: params.folderName, diff --git a/apps/sim/lib/logs/folder-scope.test.ts b/apps/sim/lib/logs/folder-scope.test.ts new file mode 100644 index 00000000000..a31a7fda036 --- /dev/null +++ b/apps/sim/lib/logs/folder-scope.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { folderScopeCondition, resolveLogFolderScope } from '@/lib/logs/folder-scope' + +interface FolderRow { + id: string + name: string + parentId: string | null +} + +const FOLDERS: FolderRow[] = [ + { id: 'a', name: 'a', parentId: null }, + { id: 'a-b', name: 'b', parentId: 'a' }, + { id: 'a-b-c', name: 'c', parentId: 'a-b' }, + { id: 'ab', name: 'ab', parentId: null }, + { id: 'other', name: 'other', parentId: null }, +] + +describe('resolveLogFolderScope', () => { + beforeEach(() => { + resetDbChainMock() + queueTableRows(schemaMock.folder, FOLDERS) + }) + + it('covers the whole subtree of a selected folder', async () => { + const scope = await resolveLogFolderScope('workspace-1', ['/a']) + + expect(scope.includesRoot).toBe(false) + expect([...scope.folderIds].sort()).toEqual(['a', 'a-b', 'a-b-c']) + }) + + it('covers a nested selection without reaching back up the tree', async () => { + const scope = await resolveLogFolderScope('workspace-1', ['/a/b']) + + expect([...scope.folderIds].sort()).toEqual(['a-b', 'a-b-c']) + }) + + /** + * `/` prefixes every path in the workspace, so expanding it would turn "runs + * at the workspace root" into "every run" — inverting the filter rather than + * widening it. + */ + it('does not expand the workspace root', async () => { + const scope = await resolveLogFolderScope('workspace-1', ['/']) + + expect(scope).toEqual({ includesRoot: true, folderIds: [] }) + }) + + /** `/a` must not swallow `/ab`; only a full segment boundary is a descendant. */ + it('does not treat a name-prefixed sibling as a descendant', async () => { + const scope = await resolveLogFolderScope('workspace-1', ['/a']) + + expect(scope.folderIds).not.toContain('ab') + }) + + it('drops a path that names no active folder rather than failing the read', async () => { + const scope = await resolveLogFolderScope('workspace-1', ['/a', '/gone']) + + expect([...scope.folderIds].sort()).toEqual(['a', 'a-b', 'a-b-c']) + }) + + it('de-duplicates overlapping selections', async () => { + const scope = await resolveLogFolderScope('workspace-1', ['/a', '/a/b']) + + expect([...scope.folderIds].sort()).toEqual(['a', 'a-b', 'a-b-c']) + }) +}) + +describe('folderScopeCondition', () => { + const isUnsatisfiable = (condition: { strings?: readonly string[] }) => + condition.strings?.[0] === 'false' + + it('matches nothing when the scope resolved to nothing', () => { + expect(isUnsatisfiable(folderScopeCondition({ includesRoot: false, folderIds: [] }))).toBe(true) + }) + + it('does not fall back to an unfiltered read when only the root is selected', () => { + expect(isUnsatisfiable(folderScopeCondition({ includesRoot: true, folderIds: [] }))).toBe(false) + }) +}) diff --git a/apps/sim/lib/logs/folder-scope.ts b/apps/sim/lib/logs/folder-scope.ts new file mode 100644 index 00000000000..3b91e820311 --- /dev/null +++ b/apps/sim/lib/logs/folder-scope.ts @@ -0,0 +1,107 @@ +import { workflow } from '@sim/db/schema' +import { inArray, isNull, or, type SQL, sql } from 'drizzle-orm' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' + +/** The folders a `folderPaths` filter selects, with the workspace root carried separately. */ +export interface LogFolderScope { + includesRoot: boolean + folderIds: string[] +} + +/** + * The folders a set of canonical paths selects: each named folder AND its whole + * subtree. + * + * Subtree coverage is what "filter by folder" means everywhere else in the + * product — the first-party list, export, and stats reads all expand to + * descendants — so resolving each path to exactly one id silently omitted every + * run in a nested folder and answered with a plausible-looking short page. + * + * Expansion is by canonical path prefix over the index that is already loaded to + * resolve the paths in the first place: a descendant of `/a` is exactly a folder + * whose canonical path starts with `/a/`. That is exact by construction, because + * canonical paths cannot alias, and it costs one pass over a map already in + * memory rather than a second recursive query. + * + * The root is the one path that must never expand. `/` is a prefix of every path + * in the workspace, so expanding it would turn "runs at the workspace root" into + * "every run" — inverting the filter rather than widening it. It is carried as + * `includesRoot` instead, because a root-level workflow has a null `folder_id` + * and no id to match on. + * + * A path naming no active folder contributes nothing rather than failing the + * read, matching {@link resolveFolderPathFilter}'s miss semantics: a filter's + * miss is an empty set, not a 404. + * + * Capped at {@link MAX_FOLDERS_PER_WORKSPACE} like every other reader that + * materializes the tree. An uncapped read is unbounded in a workspace that is + * already over the ceiling, and a truncated index is worse than a refusal here: + * a missing descendant silently narrows the filter, which on a log search reads + * as "those runs do not exist". + */ +export async function resolveLogFolderScope( + workspaceId: string, + paths: string[] +): Promise { + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const folderIds = new Set() + let includesRoot = false + + for (const path of paths) { + const filter = resolveFolderPathFilter(folderIndex, path) + if (filter.kind !== 'folder') continue + if (filter.folderId === null || path === ROOT_FOLDER_PATH) { + includesRoot = true + continue + } + folderIds.add(filter.folderId) + const prefix = `${path}/` + for (const [folderId, folderPath] of folderIndex.pathById) { + if (folderPath.startsWith(prefix)) folderIds.add(folderId) + } + } + + return { includesRoot, folderIds: [...folderIds] } +} + +/** + * The root/non-root predicate for a resolved folder scope. + * + * A scope carrying neither the root nor any folder id is a `folderPaths` filter + * that matched no active folder, and it must match no rows — hence the explicit + * unsatisfiable predicate. Building it by `or`-ing two optional halves instead + * would hand the empty case to `or(undefined, undefined)`, which is `undefined` + * in Drizzle: the filter drops out of the surrounding `and(...)` and the query + * returns the workspace's entire log set, the exact opposite of what was asked. + */ +export function folderScopeCondition(scope: LogFolderScope): SQL { + const parts = [ + scope.includesRoot ? isNull(workflow.folderId) : undefined, + scope.folderIds.length > 0 ? inArray(workflow.folderId, scope.folderIds) : undefined, + ].filter((part): part is SQL => part !== undefined) + + if (parts.length === 0) return sql`false` + if (parts.length === 1) return parts[0] + return or(...parts) ?? sql`false` +} + +/** + * The version stamped into a log cursor whenever a `folderPaths` filter is + * active. + * + * The path strings a caller sends did not change when folder filters gained + * subtree coverage, but the set of runs they select did. Without a version in + * the fingerprint, a cursor minted before the change decodes cleanly and resumes + * inside a now-larger sequence, silently skipping every run that sorts before + * its position — the failure mode cursor binding exists to prevent. Bumping this + * turns those in-flight tokens into the canonical "restart paging" 400. + * + * Stamped only when `folderPaths` is present, so unfiltered walks resume across + * the deploy untouched. Bump it again for any future change to what a folder + * path selects. + */ +export const LOG_FOLDER_SCOPE_VERSION = 2 diff --git a/apps/sim/lib/logs/list-logs.test.ts b/apps/sim/lib/logs/list-logs.test.ts index 33dd2cdf7cc..978794b0988 100644 --- a/apps/sim/lib/logs/list-logs.test.ts +++ b/apps/sim/lib/logs/list-logs.test.ts @@ -56,7 +56,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ })) import type { ListLogsParams } from './list-logs' -import { decodeCursor, listLogs } from './list-logs' +import { listLogs } from './list-logs' +import { decodeLogSortCursor } from './sort-cursor' afterAll(resetDbChainMock) @@ -178,7 +179,7 @@ describe('listLogs', () => { expect(result.data).toHaveLength(1) expect(result.nextCursor).not.toBeNull() - const decoded = decodeCursor(result.nextCursor!) + const decoded = decodeLogSortCursor(result.nextCursor!) expect(decoded?.id).toBe('log-a') }) diff --git a/apps/sim/lib/logs/list-logs.ts b/apps/sim/lib/logs/list-logs.ts index 4a682260cee..60d4f6309d7 100644 --- a/apps/sim/lib/logs/list-logs.ts +++ b/apps/sim/lib/logs/list-logs.ts @@ -33,6 +33,11 @@ import { workflowExecutionOriginSql } from '@/lib/logs/execution-origin' import { jobCostTotal } from '@/lib/logs/fetch-log-detail' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { + buildLogSortCursorCondition, + decodeLogSortCursor, + encodeLogSortCursor, +} from '@/lib/logs/sort-cursor' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export type ListLogsParams = z.output @@ -40,25 +45,6 @@ export type ListLogsParams = z.output type SortBy = 'date' | 'duration' | 'cost' | 'status' type SortOrder = 'asc' | 'desc' -interface CursorData { - v: string | number | null - id: string -} - -function encodeCursor(data: CursorData): string { - return Buffer.from(JSON.stringify(data)).toString('base64') -} - -export function decodeCursor(cursor: string): CursorData | null { - try { - const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) - if (typeof parsed?.id !== 'string') return null - return parsed as CursorData - } catch { - return null - } -} - /** * Shared logs list query used by the `/api/logs` route and the copilot `query_logs` * tool. Builds the workflow + job execution-log query (cursor pagination, sort, @@ -74,7 +60,7 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< const sortBy = params.sortBy as SortBy const sortOrder = params.sortOrder as SortOrder - const cursor = params.cursor ? decodeCursor(params.cursor) : null + const cursor = params.cursor ? decodeLogSortCursor(params.cursor) : null // Expand selected folders to include descendants (matches the route behavior), // without mutating the caller's params object. @@ -114,16 +100,8 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< const nullsLast = sql`NULLS LAST` const orderByClause = (expr: SQL): SQL => sql`${dir(expr)} ${nullsLast}` - const buildCursorCondition = (sortExpr: unknown, idCol: unknown): SQL | undefined => { - if (!cursor) return undefined - const v = cursor.v - const id = cursor.id - const cmp = sortOrder === 'asc' ? sql`>` : sql`<` - if (v === null) { - return sql`(${sortExpr} IS NULL AND ${idCol} ${cmp} ${id})` - } - return sql`((${sortExpr} IS NOT NULL AND ${sortExpr} ${cmp} ${v}) OR (${sortExpr} = ${v} AND ${idCol} ${cmp} ${id}) OR ${sortExpr} IS NULL)` - } + const buildCursorCondition = (sortExpr: unknown, idCol: unknown): SQL | undefined => + buildLogSortCursorCondition(cursor, sortExpr, idCol, sortOrder) const fetchSize = p.limit + 1 @@ -455,7 +433,7 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< : v == null ? null : String(v) - nextCursor = encodeCursor({ v: cursorV, id: last.id }) + nextCursor = encodeLogSortCursor({ v: cursorV, id: last.id }) } let total: number | undefined diff --git a/apps/sim/lib/logs/log-files.ts b/apps/sim/lib/logs/log-files.ts new file mode 100644 index 00000000000..79877d728fc --- /dev/null +++ b/apps/sim/lib/logs/log-files.ts @@ -0,0 +1,104 @@ +import { workflowRunFileDownloadPath } from '@/lib/workflows/executor/execution-run-files' +import { isRunOutputFileKey } from '@/lib/workflows/executor/run-file-scope' + +/** + * One file a log row publishes: the run resource's descriptor without the bytes. + * + * The storage `key` and the recorded `url` are deliberately absent. The key is + * the addressing secret the download path exists to avoid handing out — the run + * contract states the rule — and the recorded `url` points at + * `/api/files/serve/…`, which authenticates by session or internal token and + * refuses an API key outright, so publishing it offered a v2 caller a link it + * could never follow. + */ +export interface PublicLogFile { + id: string + name: string + size: number + type: string + downloadPath: string +} + +/** The scope columns a log row carries, plus its recorded `files` blob. */ +export interface RecordedLogFileRow { + workspaceId: string | null + workflowId: string | null + executionId: string + files: unknown +} + +interface RecordedFile { + id: string + name: string + size: number + type: string + key: string +} + +/** + * Whether one recorded entry carries every field the published descriptor needs. + * + * An entry that does not is dropped rather than back-filled: the executor writes + * the full `UserFile` shape for every file a run produces, so a partial entry + * came from somewhere else, and inventing a size or a MIME type for it would + * publish a fact the recording does not contain. This runs on a response that is + * `.parse`d on the way out, so a value that cannot satisfy the schema has to be + * removed here rather than reaching it. + */ +function isRecordedFile(value: unknown): value is RecordedFile { + if (!value || typeof value !== 'object') return false + const file = value as Record + return ( + typeof file.id === 'string' && + file.id.length > 0 && + typeof file.name === 'string' && + typeof file.key === 'string' && + typeof file.type === 'string' && + typeof file.size === 'number' && + Number.isFinite(file.size) && + file.size >= 0 + ) +} + +/** + * Projects a log row's recorded `files` blob onto the files the run itself + * produced. + * + * `workflow_execution_logs.files` is a recording, not a manifest. It is + * extracted from trace spans, final output, AND the workflow input, and the + * start block copies every caller-supplied input field verbatim into its output + * — so the blob carries input attachments a caller sent and can carry a + * `UserFile` naming any storage key at all. Publishing it as stored leaked the + * key and offered a URL the v2 surface cannot serve. + * + * Every entry is therefore filtered through `isRunOutputFileKey`, which admits + * only keys under this run's own `execution/// + * /…` prefix, and re-addressed through the run resource's download + * path. A run whose workflow row is gone has no such path to offer, so its files + * are dropped rather than pointed at a route that cannot resolve them. + * + * `null` in, `null` out — a run that recorded nothing is different from one + * whose recorded entries were all out of scope, which yields `[]`. + */ +export function projectLogFiles(row: RecordedLogFileRow): PublicLogFile[] | null { + if (row.files == null) return null + if (!Array.isArray(row.files) || row.workflowId === null) return [] + + const scope = { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + } + const files: PublicLogFile[] = [] + for (const entry of row.files) { + if (!isRecordedFile(entry) || !isRunOutputFileKey(entry.key, scope)) continue + files.push({ + id: entry.id, + name: entry.name, + size: Math.trunc(entry.size), + type: entry.type, + downloadPath: workflowRunFileDownloadPath(row.workflowId, row.executionId, entry.id), + }) + } + return files +} diff --git a/apps/sim/lib/logs/public-filters.ts b/apps/sim/lib/logs/public-filters.ts index 38a77ed15a9..69f22c91386 100644 --- a/apps/sim/lib/logs/public-filters.ts +++ b/apps/sim/lib/logs/public-filters.ts @@ -1,5 +1,7 @@ -import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { jobExecutionLogs, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { escapeLikePattern } from '@/lib/api/list-query' +import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types' /** Query filters shared by the v1 and v2 public log adapters. */ export interface LogFilters { @@ -19,6 +21,16 @@ export interface LogFilters { */ triggers?: string[] level?: 'info' | 'error' + /** + * Persisted execution statuses to include, matched against the same column the + * responses report. Deliberately not derived from `level` + `ended_at` the way + * the first-party list's `running`/`pending` pseudo-levels are: a filter that + * selected on a different rule than the field it names would answer with rows + * whose reported status is not the one asked for. + */ + statuses?: PersistedWorkflowExecutionStatus[] + /** Case-insensitive substring of the run's workflow name. */ + workflowName?: string startDate?: Date endDate?: Date executionId?: string @@ -27,6 +39,12 @@ export interface LogFilters { minCost?: number maxCost?: number model?: string + /** + * The v1 adapter's opaque `(startedAt, id)` position. Consumed by + * `listPublicWorkflowLogs`, which translates it into the shared keyset — it is + * deliberately NOT read here, so a filter set can never carry two different + * spellings of the same page boundary into one query. + */ cursor?: { startedAt: string id: string @@ -39,20 +57,6 @@ export function buildLogFilters(filters: LogFilters): SQL { conditions.push(eq(workflowExecutionLogs.workspaceId, filters.workspaceId)) - // Cursor-based pagination - if (filters.cursor) { - const cursorDate = new Date(filters.cursor.startedAt) - if (filters.order === 'desc') { - conditions.push( - sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${sql.param(cursorDate, workflowExecutionLogs.startedAt)}, ${filters.cursor.id})` - ) - } else { - conditions.push( - sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${sql.param(cursorDate, workflowExecutionLogs.startedAt)}, ${filters.cursor.id})` - ) - } - } - // Workflow IDs filter if (filters.workflowIds && filters.workflowIds.length > 0) { conditions.push(inArray(workflow.id, filters.workflowIds)) @@ -73,6 +77,16 @@ export function buildLogFilters(filters: LogFilters): SQL { conditions.push(eq(workflowExecutionLogs.level, filters.level)) } + if (filters.statuses && filters.statuses.length > 0) { + conditions.push(inArray(workflowExecutionLogs.status, filters.statuses)) + } + + // Workflow name filter — unindexed ILIKE, so the term is length-bounded at the + // contract boundary. Wildcards in the term are escaped so `%` matches itself. + if (filters.workflowName) { + conditions.push(sql`${workflow.name} ILIKE ${`%${escapeLikePattern(filters.workflowName)}%`}`) + } + // Date range filters if (filters.startDate) { conditions.push(gte(workflowExecutionLogs.startedAt, filters.startDate)) @@ -116,13 +130,72 @@ export function buildLogFilters(filters: LogFilters): SQL { } /** - * Order rows by `(startedAt, id)` so the sort matches the keyset cursor's tuple - * comparison in {@link buildLogFilters}. Without the `id` tie-break, rows that - * share a `startedAt` have an arbitrary order and can be skipped or duplicated - * across pages. + * Whether a filter set can select job runs at all. + * + * `job_execution_logs` has no workflow, no folder, no model projection, and no + * comparable persisted status, so a filter naming any of those cannot be + * satisfied by a job row. The honest answer is to drop the whole branch rather + * than to silently ignore the filter for half the sequence — the first-party + * list makes the same call in `list-logs.ts`, and letting one filter mean two + * different things per branch is a wrong answer, not a partial one. */ -export function getOrderBy(order: 'desc' | 'asc' = 'desc') { - return order === 'desc' - ? [desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id)] - : [asc(workflowExecutionLogs.startedAt), asc(workflowExecutionLogs.id)] +export function jobLogsSelectable(filters: LogFilters): boolean { + return ( + !filters.workflowIds && + !filters.folderIds && + !filters.workflowName && + !filters.model && + !filters.statuses + ) +} + +/** + * The job-run half of a unioned public log page. + * + * Only the filters `job_execution_logs` can actually answer are applied; callers + * gate the branch on {@link jobLogsSelectable} first, so anything this builder + * does not translate is a filter no job row could have matched. + */ +export function buildJobLogFilters(filters: LogFilters): SQL { + const conditions: SQL[] = [eq(jobExecutionLogs.workspaceId, filters.workspaceId)] + + if (filters.triggers && filters.triggers.length > 0 && !filters.triggers.includes('all')) { + conditions.push(inArray(jobExecutionLogs.trigger, filters.triggers)) + } + + if (filters.level) { + conditions.push(eq(jobExecutionLogs.level, filters.level)) + } + + if (filters.startDate) { + conditions.push(gte(jobExecutionLogs.startedAt, filters.startDate)) + } + + if (filters.endDate) { + conditions.push(lte(jobExecutionLogs.startedAt, filters.endDate)) + } + + if (filters.executionId) { + conditions.push(eq(jobExecutionLogs.executionId, filters.executionId)) + } + + if (filters.minDurationMs !== undefined) { + conditions.push(gte(jobExecutionLogs.totalDurationMs, filters.minDurationMs)) + } + + if (filters.maxDurationMs !== undefined) { + conditions.push(lte(jobExecutionLogs.totalDurationMs, filters.maxDurationMs)) + } + + // Job cost is a jsonb document rather than the indexed numeric projection the + // workflow logs carry, so the bound is compared against the extracted total. + if (filters.minCost !== undefined) { + conditions.push(sql`(${jobExecutionLogs.cost}->>'total')::numeric >= ${filters.minCost}`) + } + + if (filters.maxCost !== undefined) { + conditions.push(sql`(${jobExecutionLogs.cost}->>'total')::numeric <= ${filters.maxCost}`) + } + + return and(...conditions)! } diff --git a/apps/sim/lib/logs/public-queries.test.ts b/apps/sim/lib/logs/public-queries.test.ts index 014ae6f97c1..a83e5c00e12 100644 --- a/apps/sim/lib/logs/public-queries.test.ts +++ b/apps/sim/lib/logs/public-queries.test.ts @@ -9,10 +9,12 @@ import { schemaMock, } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' +import { jobLogsSelectable } from '@/lib/logs/public-filters' import { decodePublicLogCursor, encodePublicLogCursor, listPublicWorkflowLogs, + readPublicLogPage, } from '@/lib/logs/public-queries' describe('public log cursor', () => { @@ -84,3 +86,269 @@ describe('public workflow log folder scope', () => { expect(lastWhere().some(isUnsatisfiable)).toBe(false) }) }) + +/** + * A job run has no workflow, no folder, no model projection, and no comparable + * persisted status, so a filter naming any of those cannot be satisfied by a job + * row. Dropping the branch is the honest answer; applying the filter to half the + * sequence and ignoring it for the other half would make one param mean two + * different things. + */ +describe('job-run union', () => { + const base = { workspaceId: 'workspace-1' } + + it('selects job runs when every active filter can apply to them', () => { + expect(jobLogsSelectable({ ...base, level: 'error', triggers: ['mothership'] })).toBe(true) + }) + + it.each([ + ['workflowIds', { workflowIds: ['workflow-1'] }], + ['folderIds', { folderIds: ['folder-1'] }], + ['workflowName', { workflowName: 'support' }], + ['model', { model: 'gpt-5' }], + ['statuses', { statuses: ['completed' as const] }], + ])('drops the job branch when %s is set', (_field, filter) => { + expect(jobLogsSelectable({ ...base, ...filter })).toBe(false) + }) +}) + +describe('unioned public log page', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('reads only workflow logs when job runs are not requested', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, []) + + const { data } = await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1' }, + limit: 50, + includeExecutionData: false, + }) + + expect(data).toEqual([]) + expect(dbChainMockFns.from).toHaveBeenCalledTimes(1) + }) + + it('reads both tables when job runs are requested', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, []) + queueTableRows(schemaMock.jobExecutionLogs, []) + + await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1' }, + limit: 50, + includeExecutionData: false, + includeJobRuns: true, + }) + + expect(dbChainMockFns.from).toHaveBeenCalledTimes(2) + }) + + it('skips the job read when a filter no job row could satisfy is set', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, []) + + await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1', model: 'gpt-5' }, + limit: 50, + includeExecutionData: false, + includeJobRuns: true, + }) + + expect(dbChainMockFns.from).toHaveBeenCalledTimes(1) + }) + + // The public surface carries its folder filter in `folderScope`, never in + // `filters.folderIds`, so asserting on `jobLogsSelectable` alone cannot see + // this case: a folder-scoped page would union in every job run in the + // workspace while reporting itself as scoped. + it('skips the job read when the page is scoped to a folder', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, []) + + await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1' }, + folderScope: { folderIds: ['folder-1'], includesRoot: false }, + limit: 50, + includeExecutionData: false, + includeJobRuns: true, + }) + + expect(dbChainMockFns.from).toHaveBeenCalledTimes(1) + }) + + it('tags every row with the table it came from', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { id: 'w-1', startedAt: new Date('2026-08-06T00:00:02Z') }, + ]) + queueTableRows(schemaMock.jobExecutionLogs, [ + { id: 'j-1', startedAt: new Date('2026-08-06T00:00:01Z') }, + ]) + + const { data } = await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1' }, + limit: 50, + includeExecutionData: false, + includeJobRuns: true, + }) + + expect(data.map((row) => [row.kind, row.id])).toEqual([ + ['workflow', 'w-1'], + ['job', 'j-1'], + ]) + }) + + it('merges the two branches into the requested order', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { id: 'w-1', startedAt: new Date('2026-08-06T00:00:02Z') }, + ]) + queueTableRows(schemaMock.jobExecutionLogs, [ + { id: 'j-1', startedAt: new Date('2026-08-06T00:00:01Z') }, + ]) + + const { data } = await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1', order: 'asc' }, + limit: 50, + includeExecutionData: false, + includeJobRuns: true, + }) + + expect(data.map((row) => row.id)).toEqual(['j-1', 'w-1']) + }) + + /** + * Both tables order by `(startedAt, id)` and both ids are globally unique, so + * the cursor the merged page mints names one position in the merged sequence. + */ + it('mints its cursor from the last row of the merged page, whichever table it came from', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { id: 'w-1', startedAt: new Date('2026-08-06T00:00:03Z') }, + ]) + queueTableRows(schemaMock.jobExecutionLogs, [ + { id: 'j-1', startedAt: new Date('2026-08-06T00:00:02Z') }, + ]) + + const { nextCursor } = await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1' }, + limit: 1, + includeExecutionData: false, + includeJobRuns: true, + }) + + expect(decodePublicLogCursor(nextCursor as string, 'desc')).toEqual({ + startedAt: '2026-08-06T00:00:03.000Z', + id: 'w-1', + order: 'desc', + }) + }) +}) + +/** + * A keyset cannot compare against null — `value < NULL` is unknown, so a null + * row is neither before nor after the cursor and the page boundary either + * duplicates or drops it. Reading the two nullable sort columns through a + * sentinel is what makes the ordering total. + */ +describe('sortable public log query', () => { + beforeEach(() => { + resetDbChainMock() + queueTableRows(schemaMock.workflowExecutionLogs, []) + }) + + const orderedSql = () => + (dbChainMockFns.orderBy.mock.calls.at(-1) ?? []) + .map((clause) => { + const column = (clause as { column?: unknown }).column + return (column as { toSQL?: () => { sql: string } })?.toSQL?.().sql ?? String(column) + }) + .join(' | ') + + async function query(sortBy: 'startedAt' | 'durationMs' | 'cost' | 'status') { + await readPublicLogPage({ + filters: { workspaceId: 'workspace-1' }, + includeExecutionData: false, + sortBy, + sortOrder: 'desc', + cursorKeys: undefined, + limit: 50, + }) + } + + it.each([['durationMs'], ['cost']] as const)( + 'reads the nullable %s column through a sentinel so the ordering is total', + async (sortBy) => { + await query(sortBy) + + expect(orderedSql()).toContain('COALESCE') + } + ) + + it.each([['startedAt'], ['status']] as const)( + 'leaves the non-null %s column alone', + async (sortBy) => { + await query(sortBy) + + expect(orderedSql()).not.toContain('COALESCE') + } + ) + + /** Without the unique trailing key, rows tied on the sort column repeat or vanish across pages. */ + it('always ends the keyset in a unique column', async () => { + await query('status') + + expect(dbChainMockFns.orderBy.mock.calls.at(-1)).toHaveLength(2) + }) + + /** + * `cost_total` is an unconstrained `numeric`, which node-postgres returns as a + * string precisely because float64 cannot hold every value it can store. + * Minting the anchor through `Number()` narrows it, and the narrowed value is + * then compared back against full-precision `numeric` — so rows that differ + * only beyond float64 precision collapse onto one anchor and the page + * boundary skips or repeats them. + */ + it('carries the cost anchor at full numeric precision', async () => { + const costTotal = '0.12345678901234567890123' + expect(String(Number(costTotal))).not.toBe(costTotal) + resetDbChainMock() + queueTableRows(schemaMock.workflowExecutionLogs, [ + { id: 'w-1', costTotal, startedAt: new Date('2026-08-06T00:00:01.000Z') }, + { id: 'w-2', costTotal, startedAt: new Date('2026-08-06T00:00:00.000Z') }, + ]) + + const page = await readPublicLogPage({ + filters: { workspaceId: 'workspace-1' }, + includeExecutionData: false, + sortBy: 'cost', + sortOrder: 'desc', + cursorKeys: undefined, + limit: 1, + }) + + expect(page.nextCursorKeys).toEqual([costTotal, 'w-1']) + }) + + /** An unsettled run has no cost, and its sentinel has to bind back as `numeric` too. */ + it('anchors an unsettled run on the cost sentinel', async () => { + resetDbChainMock() + queueTableRows(schemaMock.workflowExecutionLogs, [ + { id: 'w-1', costTotal: null, startedAt: new Date('2026-08-06T00:00:01.000Z') }, + { id: 'w-2', costTotal: null, startedAt: new Date('2026-08-06T00:00:00.000Z') }, + ]) + + const page = await readPublicLogPage({ + filters: { workspaceId: 'workspace-1' }, + includeExecutionData: false, + sortBy: 'cost', + sortOrder: 'desc', + cursorKeys: undefined, + limit: 1, + }) + + expect(page.nextCursorKeys).toEqual(['-1', 'w-1']) + }) + + it('over-fetches one row so the next page can be answered without a count', async () => { + await query('startedAt') + + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(51) + }) +}) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index 039a8afe3d2..98d70fc6af4 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -1,5 +1,6 @@ import { db } from '@sim/db' import { + jobExecutionLogs, pausedExecutions, user, workflow, @@ -7,9 +8,29 @@ import { workflowExecutionLogs, workflowExecutionSnapshots, } from '@sim/db/schema' -import { and, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' +import { and, type Column, eq, sql } from 'drizzle-orm' +import { + type CursorKey, + decimalKey, + type KeysetKey, + type KeysetPage, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + numberKey, + resumeKeyset, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { workflowExecutionOriginSql } from '@/lib/logs/execution-origin' -import { buildLogFilters, getOrderBy, type LogFilters } from '@/lib/logs/public-filters' +import { folderScopeCondition, type LogFolderScope } from '@/lib/logs/folder-scope' +import { + buildJobLogFilters, + buildLogFilters, + jobLogsSelectable, + type LogFilters, +} from '@/lib/logs/public-filters' export interface PublicLogCursor { startedAt: string @@ -59,44 +80,23 @@ export interface ListPublicWorkflowLogsInput { filters: LogFilters limit: number includeExecutionData: boolean - folderScope?: { - includesRoot: boolean - folderIds: string[] - } -} - -/** - * The root/non-root predicate for a resolved folder scope. - * - * A scope carrying neither the root nor any folder id is a `folderPaths` filter - * that matched no active folder, and it must match no rows — hence the explicit - * unsatisfiable predicate. Building it by `or`-ing two optional halves instead - * would hand the empty case to `or(undefined, undefined)`, which is `undefined` - * in Drizzle: the filter drops out of the surrounding `and(...)` and the query - * returns the workspace's entire log set, the exact opposite of what was asked. - */ -function folderScopeCondition(scope: { includesRoot: boolean; folderIds: string[] }): SQL { - const parts = [ - scope.includesRoot ? isNull(workflow.folderId) : undefined, - scope.folderIds.length > 0 ? inArray(workflow.folderId, scope.folderIds) : undefined, - ].filter((part): part is SQL => part !== undefined) - - if (parts.length === 0) return sql`false` - if (parts.length === 1) return parts[0] - return or(...parts) ?? sql`false` + folderScope?: LogFolderScope + /** + * Whether Chat and Sim-agent job runs join the sequence. + * + * The union is keyset-safe because both tables order by `(startedAt, id)` and + * both ids are globally unique text primary keys, so the tuple the cursor + * compares stays unique across the merged sequence. + */ + includeJobRuns?: boolean } /** - * Reads the workflow-execution log page shared by the v1 and v2 public - * adapters. Folder path resolution remains an adapter concern; this query takes - * the resulting ids and applies one coherent root/non-root predicate. + * The workflow-log projection, as one query builder so its row type can be + * derived without the reader that pages it referring to itself. */ -export async function listPublicWorkflowLogs(input: ListPublicWorkflowLogsInput) { - const filters = input.folderScope ? { ...input.filters, folderIds: undefined } : input.filters - const conditions = buildLogFilters(filters) - const folderCondition = input.folderScope ? folderScopeCondition(input.folderScope) : undefined - - const rows = await db +function workflowLogQuery(includeExecutionData: boolean) { + return db .select({ id: workflowExecutionLogs.id, workflowId: workflowExecutionLogs.workflowId, @@ -111,7 +111,7 @@ export async function listPublicWorkflowLogs(input: ListPublicWorkflowLogsInput) totalDurationMs: workflowExecutionLogs.totalDurationMs, costTotal: workflowExecutionLogs.costTotal, files: workflowExecutionLogs.files, - executionData: input.includeExecutionData ? workflowExecutionLogs.executionData : sql`null`, + executionData: includeExecutionData ? workflowExecutionLogs.executionData : sql`null`, workflowName: workflow.name, workflowDescription: workflow.description, workflowFolderId: workflow.folderId, @@ -123,20 +123,299 @@ export async function listPublicWorkflowLogs(input: ListPublicWorkflowLogsInput) }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(and(conditions, folderCondition)) - .orderBy(...getOrderBy(input.filters.order)) +} + +/** + * The job-run projection. + * + * Deliberately narrower than the workflow one: a job run has no workflow, no + * deployment version, and no attachment list, so those fields are absent from + * the row rather than reported as null-valued versions of a thing that does not + * exist. + */ +function jobLogQuery() { + return db + .select({ + id: jobExecutionLogs.id, + workspaceId: jobExecutionLogs.workspaceId, + executionId: jobExecutionLogs.executionId, + level: jobExecutionLogs.level, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + cost: jobExecutionLogs.cost, + }) + .from(jobExecutionLogs) +} + +export type PublicWorkflowLogListRow = Awaited>[number] +export type PublicJobLogListRow = Awaited>[number] + +/** + * One row of the public log sequence, tagged by which table it came from. + * + * The tag is not cosmetic: a job run and a workflow run whose workflow has been + * deleted both report `workflowId: null` on the wire, so without a discriminator + * a caller cannot tell "this run never had a workflow" from "its workflow is + * gone" — two different answers. + */ +export type PublicLogListRow = + | ({ kind: 'workflow' } & PublicWorkflowLogListRow) + | ({ kind: 'job' } & PublicJobLogListRow) + +/** + * Merges the two branches into the single `(startedAt, id)` ordering both were + * read under, so the page boundary and its cursor mean the same thing whether or + * not job runs were included. + * + * The comparison is at millisecond precision because that is the precision the + * keyset orders and compares at — see {@link timestampKey}. Anything finer would + * put the merged order out of step with the boundary the cursor names. + */ +function mergeByKeyset(rows: PublicLogListRow[], order: ListSortOrder): PublicLogListRow[] { + const direction = order === 'asc' ? 1 : -1 + return rows.sort((a, b) => { + const byTime = a.startedAt.getTime() - b.startedAt.getTime() + if (byTime !== 0) return direction * byTime + return direction * (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) + }) +} + +/** The columns `GET /api/v2/logs` can order by. */ +export const PUBLIC_LOG_SORT_FIELDS = ['startedAt', 'durationMs', 'cost', 'status'] as const + +export type PublicLogSortField = (typeof PUBLIC_LOG_SORT_FIELDS)[number] + +/** + * Sentinel the two nullable sort columns are read through. + * + * `total_duration_ms` and `cost_total` are null for a run that has not settled, + * and a keyset cannot compare against null — `value < NULL` is unknown, so a null + * row is neither before nor after the cursor and pages either duplicate or drop + * it. Coalescing makes the ordering total, at the cost of one documented + * decision: an unsettled run sorts as though its duration and cost were below + * every real value. Both columns are non-negative, so the sentinel cannot + * collide with a genuine measurement. + */ +const UNSETTLED_SORT_VALUE = -1 + +/** + * {@link UNSETTLED_SORT_VALUE} as the `numeric` cursor key spells it. + * + * `cost_total` is an unconstrained `numeric`, so its keyset travels as the digit + * string Postgres returned rather than as a JS number — see {@link decimalKey}. + * The sentinel has to be written the same way or an unsettled anchor could not + * be bound back. + */ +const UNSETTLED_COST_VALUE = String(UNSETTLED_SORT_VALUE) + +/** + * The columns the sortable keyset compares on — every row shape it pages carries + * these, whether or not it also selects the run's execution data. + */ +type PublicLogKeysetRow = Pick< + PublicWorkflowLogListRow, + 'id' | 'status' | 'startedAt' | 'totalDurationMs' | 'costTotal' +> + +/** + * The `(startedAt, id)` keyset, over whichever table's columns are given. + * + * Both log tables are ordered by it, and both spell it identically, so the + * merged sequence a unioned page returns resumes from one set of cursor keys. + */ +function startedAtKeyset( + startedAtColumn: Column, + idColumn: Column +): KeysetKey[] { + return [ + timestampKey(startedAtColumn, (row) => row.startedAt), + textKey(idColumn, (row) => row.id), + ] +} + +/** + * The keyset for one sort field, always ending in `id`. + * + * The trailing unique key is what separates rows that tie on the leading column + * — every one of these columns can tie, `status` on most of a page — so without + * it the page boundary repeats or drops the tied rows. + */ +function publicLogKeyset( + sortBy: PublicLogSortField +): KeysetKey[] { + const idKey = textKey(workflowExecutionLogs.id, (row) => row.id) + switch (sortBy) { + case 'durationMs': + return [ + numberKey( + sql`COALESCE(${workflowExecutionLogs.totalDurationMs}, ${UNSETTLED_SORT_VALUE})`, + (row) => row.totalDurationMs ?? UNSETTLED_SORT_VALUE + ), + idKey, + ] + case 'cost': + return [ + decimalKey( + sql`COALESCE(${workflowExecutionLogs.costTotal}, ${UNSETTLED_SORT_VALUE})`, + (row) => row.costTotal ?? UNSETTLED_COST_VALUE + ), + idKey, + ] + case 'status': + return [textKey(workflowExecutionLogs.status, (row) => row.status), idKey] + default: + return startedAtKeyset(workflowExecutionLogs.startedAt, workflowExecutionLogs.id) + } +} + +export interface ReadPublicLogPageInput { + filters: LogFilters + limit: number + includeExecutionData: boolean + folderScope?: LogFolderScope + includeJobRuns?: boolean + sortBy: PublicLogSortField + sortOrder: ListSortOrder + cursorKeys: CursorKey[] | undefined +} + +function readWorkflowLogRows( + input: ReadPublicLogPageInput, + keys: readonly KeysetKey[] +) { + const filters = input.folderScope ? { ...input.filters, folderIds: undefined } : input.filters + const folderCondition = input.folderScope ? folderScopeCondition(input.folderScope) : undefined + + return workflowLogQuery(input.includeExecutionData) + .where( + and( + buildLogFilters(filters), + folderCondition, + resumeKeyset(keys, input.cursorKeys, input.sortOrder) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), input.sortOrder)) .limit(input.limit + 1) +} + +function readJobLogRows( + input: ReadPublicLogPageInput, + keys: readonly KeysetKey[] +) { + return jobLogQuery() + .where( + and(buildJobLogFilters(input.filters), resumeKeyset(keys, input.cursorKeys, input.sortOrder)) + ) + .orderBy(...listOrderBy(keysetColumns(keys), input.sortOrder)) + .limit(input.limit + 1) +} + +/** + * Reads one page of the public log sequence, ordered by any of + * {@link PUBLIC_LOG_SORT_FIELDS} and resumed from a shared keyset cursor. + * + * Job runs join the sequence only under `startedAt`. `job_execution_logs` stores + * cost as a jsonb document and records no comparable persisted status, so + * ordering the two tables together on those columns would compare values that do + * not mean the same thing; the contract refuses the combination at the boundary, + * and this guard is the second half of that rule rather than a silent narrowing. + * + * Each branch over-fetches one row so the merged set can answer "is there + * another page" without a count. + */ +export async function readPublicLogPage( + input: ReadPublicLogPageInput +): Promise> { + const keys = publicLogKeyset(input.sortBy) + + // `folderScope` is checked separately from `jobLogsSelectable`, which reads + // `filters.folderIds`. The public surface never sets that field — its input + // type omits it and carries the folder filter in `folderScope` instead — so + // gating on the filters alone let a folder-scoped page union in every job run + // in the workspace, which is the "one filter means two different things + // across the union" answer the guard exists to refuse. + const includeJobRuns = + input.sortBy === 'startedAt' && + Boolean(input.includeJobRuns) && + !input.folderScope && + jobLogsSelectable(input.filters) + + const [workflowRows, jobRows] = await Promise.all([ + readWorkflowLogRows(input, keys), + includeJobRuns + ? readJobLogRows( + input, + startedAtKeyset(jobExecutionLogs.startedAt, jobExecutionLogs.id) + ) + : Promise.resolve([] as PublicJobLogListRow[]), + ]) + + if (!includeJobRuns) { + const page = keysetPage(keys, workflowRows, input.limit) + return { + data: page.data.map((row): PublicLogListRow => ({ kind: 'workflow', ...row })), + nextCursorKeys: page.nextCursorKeys, + } + } + + const merged = mergeByKeyset( + [ + ...workflowRows.map((row): PublicLogListRow => ({ kind: 'workflow', ...row })), + ...jobRows.map((row): PublicLogListRow => ({ kind: 'job', ...row })), + ], + input.sortOrder + ) + return keysetPage( + startedAtKeyset(workflowExecutionLogs.startedAt, workflowExecutionLogs.id), + merged, + input.limit + ) +} + +/** + * The v1 adapter's log page: {@link readPublicLogPage} ordered by start time, + * with the keyset carried by v1's own opaque `(startedAt, id)` token. + * + * v2 reads {@link readPublicLogPage} directly and carries the keyset in the + * shared v2 cursor codec. This wrapper exists so v1's published token keeps its + * shape while both surfaces page over one query. + * + * The overloads keep the narrower row type for callers that never opt in — a + * caller that cannot receive a job run should not have to narrow a union it can + * never observe. + */ +export async function listPublicWorkflowLogs( + input: ListPublicWorkflowLogsInput & { includeJobRuns?: false } +): Promise<{ + data: Array<{ kind: 'workflow' } & PublicWorkflowLogListRow> + nextCursor: string | null +}> +export async function listPublicWorkflowLogs( + input: ListPublicWorkflowLogsInput +): Promise<{ data: PublicLogListRow[]; nextCursor: string | null }> +export async function listPublicWorkflowLogs( + input: ListPublicWorkflowLogsInput +): Promise<{ data: PublicLogListRow[]; nextCursor: string | null }> { + const order = input.filters.order ?? 'desc' + const { data, nextCursorKeys } = await readPublicLogPage({ + filters: { ...input.filters, cursor: undefined }, + limit: input.limit, + includeExecutionData: input.includeExecutionData, + folderScope: input.folderScope, + includeJobRuns: input.includeJobRuns, + sortBy: 'startedAt', + sortOrder: order, + cursorKeys: input.filters.cursor + ? [input.filters.cursor.startedAt, input.filters.cursor.id] + : undefined, + }) - const hasMore = rows.length > input.limit - const data = rows.slice(0, input.limit) - const last = data.at(-1) + const [startedAt, id] = nextCursorKeys ?? [] const nextCursor = - hasMore && last - ? encodePublicLogCursor({ - startedAt: last.startedAt.toISOString(), - id: last.id, - order: input.filters.order ?? 'desc', - }) + typeof startedAt === 'string' && typeof id === 'string' + ? encodePublicLogCursor({ startedAt, id, order }) : null return { data, nextCursor } diff --git a/apps/sim/lib/logs/sort-cursor.test.ts b/apps/sim/lib/logs/sort-cursor.test.ts new file mode 100644 index 00000000000..0867f71fd19 --- /dev/null +++ b/apps/sim/lib/logs/sort-cursor.test.ts @@ -0,0 +1,176 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildLogSortCursorCondition, + decodeLogSortCursor, + encodeLogSortCursor, + type LogSortCursor, +} from '@/lib/logs/sort-cursor' + +function sqlText(condition: unknown): string { + return (condition as { toSQL: () => { sql: string } }).toSQL().sql +} + +/** The comparison operators a condition binds, which the mocked `sql` tag renders as `?`. */ +function comparators(condition: unknown): string[] { + const { params } = (condition as { toSQL: () => { params: unknown[] } }).toSQL() + return params + .map((param) => (param as { toSQL?: () => { sql: string } })?.toSQL?.().sql) + .filter((operator): operator is string => operator === '>' || operator === '<') +} + +describe('log sort cursor codec', () => { + it('round-trips a value anchor and a null anchor', () => { + expect(decodeLogSortCursor(encodeLogSortCursor({ v: 120, id: 'log-1' }))).toEqual({ + v: 120, + id: 'log-1', + }) + expect(decodeLogSortCursor(encodeLogSortCursor({ v: null, id: 'log-1' }))).toEqual({ + v: null, + id: 'log-1', + }) + }) + + it('rejects a token carrying no usable position', () => { + expect(decodeLogSortCursor('not-base64-json')).toBeNull() + expect(decodeLogSortCursor(Buffer.from('{"v":1}').toString('base64'))).toBeNull() + }) +}) + +describe('buildLogSortCursorCondition', () => { + it('adds no predicate for the first page', () => { + expect(buildLogSortCursorCondition(null, 'expr', 'id', 'desc')).toBeUndefined() + }) + + /** + * The regression guard. Under `NULLS LAST` the null-valued rows form a block + * strictly AFTER every non-null row, so while the anchor is still non-null + * they are genuinely after the cursor and must stay in the candidate set — + * `ORDER BY` plus `LIMIT` is what keeps them off the page until the non-null + * rows run out. + * + * Dropping the disjunct as a "duplicate rows" fix does the opposite of fixing + * anything: the only way to reach the null branch below is to be handed a + * null-valued row to anchor on, which can only happen if the null block was + * reachable in the first place. Remove it and every run with no recorded + * duration or cost becomes permanently unreachable through pagination. + */ + it('keeps null-valued rows reachable while the anchor is still non-null', () => { + const condition = buildLogSortCursorCondition({ v: 120, id: 'log-1' }, 'expr', 'id', 'desc') + + expect(sqlText(condition)).toContain('IS NULL') + expect(sqlText(condition)).toContain('IS NOT NULL') + }) + + /** + * Once the anchor is itself null the walk is inside the null block, where the + * only ordering left is the id tiebreak — so the value comparison must drop + * out entirely. `expr = NULL` is never true, so leaving it in would stall the + * walk on the first null row. + */ + it('pages the null block by id alone once the anchor is null', () => { + const condition = buildLogSortCursorCondition({ v: null, id: 'log-1' }, 'expr', 'id', 'desc') + + expect(sqlText(condition)).toContain('IS NULL') + expect(sqlText(condition)).not.toContain('IS NOT NULL') + }) + + /** + * Behavior, not SQL text. The disjunct assertions above are satisfied by a + * semantically dead rewrite — `OR (${sortExpr} IS NULL AND false)` still + * contains both `IS NULL` and `IS NOT NULL` — so the guard that actually + * matters is walking a fixture with a null block and finding every row + * exactly once. + * + * The condition is evaluated by translating the rendered predicate into the + * equivalent JS expression rather than by matching its shape, so any rewrite + * that changes which rows it selects fails here. + */ + describe('paging a fixture with a null block', () => { + const SORT = '@sort' + const ID = '@id' + + interface Fragment { + strings?: readonly string[] + values?: readonly unknown[] + rawSql?: string + } + + /** The predicate as a JS expression over `sort` and `id`, with every value inlined. */ + function toJsExpression(fragment: unknown): string { + const node = fragment as Fragment + if (typeof node?.rawSql === 'string') return node.rawSql + if (!node?.strings) { + if (node === (SORT as unknown)) return 'sort' + if (node === (ID as unknown)) return 'id' + return JSON.stringify(node) + } + return node.strings + .map((text, index) => + index < (node.values?.length ?? 0) ? text + toJsExpression(node.values![index]) : text + ) + .join('') + .replace(/\bIS NOT NULL\b/g, '!== null') + .replace(/\bIS NULL\b/g, '=== null') + .replace(/\bAND\b/g, '&&') + .replace(/\bOR\b/g, '||') + .replace(/(?=!])=(?!=)/g, '===') + } + + function selects(condition: unknown, row: Row): boolean { + const expression = toJsExpression(condition) + return Boolean(new Function('sort', 'id', `return (${expression})`)(row.v, row.id)) + } + + interface Row { + v: number | null + id: string + } + + /** ` DESC NULLS LAST, DESC` — the ordering the condition resumes. */ + function ordered(rows: readonly Row[]): Row[] { + return [...rows].sort((a, b) => { + if (a.v === null && b.v !== null) return 1 + if (b.v === null && a.v !== null) return -1 + if (a.v !== null && b.v !== null && a.v !== b.v) return b.v - a.v + return a.id < b.id ? 1 : a.id > b.id ? -1 : 0 + }) + } + + const rows: Row[] = [ + { v: 300, id: 'a' }, + { v: 200, id: 'b' }, + { v: 200, id: 'c' }, + { v: null, id: 'd' }, + { v: null, id: 'e' }, + { v: null, id: 'f' }, + ] + + it('walks every row exactly once, in order, through the null block', () => { + const expected = ordered(rows).map((r) => r.id) + const visited: string[] = [] + let cursor: LogSortCursor | null = null + + for (let page = 0; page < rows.length; page++) { + const condition = buildLogSortCursorCondition(cursor, SORT, ID, 'desc') + const candidates = condition ? rows.filter((r) => selects(condition, r)) : [...rows] + const [next] = ordered(candidates) + if (!next) break + visited.push(next.id) + cursor = { v: next.v, id: next.id } + } + + expect(visited).toEqual(expected) + }) + }) + + it('compares in the direction the page was ordered', () => { + const ascending = buildLogSortCursorCondition({ v: 120, id: 'log-1' }, 'expr', 'id', 'asc') + const descending = buildLogSortCursorCondition({ v: 120, id: 'log-1' }, 'expr', 'id', 'desc') + + expect(comparators(ascending)).toEqual(['>', '>']) + expect(comparators(descending)).toEqual(['<', '<']) + }) +}) diff --git a/apps/sim/lib/logs/sort-cursor.ts b/apps/sim/lib/logs/sort-cursor.ts new file mode 100644 index 00000000000..00d30ad8ac1 --- /dev/null +++ b/apps/sim/lib/logs/sort-cursor.ts @@ -0,0 +1,58 @@ +import { type SQL, sql } from 'drizzle-orm' + +/** + * The keyset a sorted log page resumes from: the sort column's value for the + * last row of the page, plus that row's id as the tiebreaker. + * + * `v` is nullable because the sortable columns are — `total_duration_ms` and + * `cost_total` are null for a run that has not finished — and the ordering puts + * those rows in a block of their own. See + * {@link buildLogSortCursorCondition} for how that block is paged. + */ +export interface LogSortCursor { + v: string | number | null + id: string +} + +export function encodeLogSortCursor(data: LogSortCursor): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +export function decodeLogSortCursor(cursor: string): LogSortCursor | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) + if (typeof parsed?.id !== 'string') return null + return parsed as LogSortCursor + } catch { + return null + } +} + +/** + * The `WHERE` fragment that resumes a page ordered by ` NULLS + * LAST, `, or `undefined` for page one. + * + * The `OR ${sortExpr} IS NULL` disjunct in the non-null branch is load-bearing + * and must not be removed as a simplification. Under `NULLS LAST` the + * null-valued rows form a block strictly after every non-null row, so while the + * anchor is still non-null they are genuinely "after the cursor" and have to + * stay in the candidate set. `ORDER BY` plus `LIMIT` is what keeps them off the + * page until the non-null rows run out, so they are not re-emitted — dropping + * the disjunct instead makes the null block unreachable forever, because the + * only way to reach the `v === null` branch below is to have already been handed + * a null-valued row to anchor on. + */ +export function buildLogSortCursorCondition( + cursor: LogSortCursor | null, + sortExpr: unknown, + idCol: unknown, + sortOrder: 'asc' | 'desc' +): SQL | undefined { + if (!cursor) return undefined + const { v, id } = cursor + const cmp = sortOrder === 'asc' ? sql`>` : sql`<` + if (v === null) { + return sql`(${sortExpr} IS NULL AND ${idCol} ${cmp} ${id})` + } + return sql`((${sortExpr} IS NOT NULL AND ${sortExpr} ${cmp} ${v}) OR (${sortExpr} = ${v} AND ${idCol} ${cmp} ${id}) OR ${sortExpr} IS NULL)` +} diff --git a/apps/sim/lib/logs/stats-queries.ts b/apps/sim/lib/logs/stats-queries.ts new file mode 100644 index 00000000000..1c251c1d8ef --- /dev/null +++ b/apps/sim/lib/logs/stats-queries.ts @@ -0,0 +1,85 @@ +import { dbReplica } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { eq, type SQL, sql } from 'drizzle-orm' + +/** Oldest and newest run start in the filtered set, or nulls when it is empty. */ +export interface LogStatsBounds { + minTime: string | null + maxTime: string | null +} + +/** One `(workflow, time bucket)` group of the filtered run set. */ +export interface LogStatsSegmentRow { + workflowId: string + workflowName: string + segmentIndex: number + totalExecutions: number + successfulExecutions: number + avgDurationMs: number +} + +/** + * The time span the filtered run set covers. + * + * Read separately from the segment counts because the segment width is derived + * from the span, so the bucketing expression cannot be built until this has + * answered. + */ +export async function readLogStatsBounds(where: SQL | undefined): Promise { + const rows = await dbReplica + .select({ + minTime: sql`MIN(${workflowExecutionLogs.startedAt})`, + maxTime: sql`MAX(${workflowExecutionLogs.startedAt})`, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(where) + + const bounds = rows[0] + return { minTime: bounds?.minTime ?? null, maxTime: bounds?.maxTime ?? null } +} + +/** + * Run counts, success counts, and mean duration per `(workflow, bucket)`. + * + * `startTimeIso` carries its `::timestamp` cast rather than arriving as a bare + * placeholder: it is an operand of a subtraction feeding `EXTRACT`, not one side + * of a comparison against a typed column, so Postgres has nothing to infer the + * type from and resolves no overload without it. + * + * A run whose workflow has been deleted still counts — it collapses into a + * single `'deleted'` series rather than disappearing, so the workspace totals + * stay reconcilable against the log list. + */ +export async function readLogStatsSegments( + where: SQL | undefined, + startTimeIso: string, + segmentMs: number +): Promise { + return dbReplica + .select({ + workflowId: sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, + workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + segmentIndex: + sql`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as( + 'segment_index' + ), + totalExecutions: sql`COUNT(*)`.as('total_executions'), + successfulExecutions: + sql`COUNT(*) FILTER (WHERE ${workflowExecutionLogs.level} != 'error')`.as( + 'successful_executions' + ), + avgDurationMs: + sql`COALESCE(AVG(${workflowExecutionLogs.totalDurationMs}) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0), 0)`.as( + 'avg_duration_ms' + ), + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(where) + .groupBy( + sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, + sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + sql`segment_index` + ) +} diff --git a/apps/sim/lib/logs/stats.test.ts b/apps/sim/lib/logs/stats.test.ts new file mode 100644 index 00000000000..e645997fef8 --- /dev/null +++ b/apps/sim/lib/logs/stats.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { buildDashboardStats, type LogStatsWindow, resolveLogStatsWindow } from '@/lib/logs/stats' +import type { LogStatsSegmentRow } from '@/lib/logs/stats-queries' + +const WINDOW_START = new Date('2026-01-15T00:00:00.000Z') + +const window: LogStatsWindow = { + startTime: WINDOW_START, + endTime: new Date('2026-01-15T02:00:00.000Z'), + segmentMs: 60 * 60 * 1000, +} + +function row(overrides: Partial = {}): LogStatsSegmentRow { + return { + workflowId: 'wf-1', + workflowName: 'Alpha', + segmentIndex: 0, + totalExecutions: 1, + successfulExecutions: 1, + avgDurationMs: 100, + ...overrides, + } +} + +describe('resolveLogStatsWindow', () => { + const now = new Date('2026-01-15T12:00:00.000Z') + + it('falls back to the trailing 24 hours when nothing ran', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, now) + + expect(resolved.endTime).toEqual(now) + expect(resolved.startTime).toEqual(new Date('2026-01-14T12:00:00.000Z')) + }) + + it('extends the window to now when the newest run is older', () => { + const resolved = resolveLogStatsWindow( + { minTime: '2026-01-15T00:00:00.000Z', maxTime: '2026-01-15T06:00:00.000Z' }, + 12, + now + ) + + expect(resolved.endTime).toEqual(now) + expect(resolved.segmentMs).toBe(60 * 60 * 1000) + }) + + it('never buckets narrower than a minute', () => { + const resolved = resolveLogStatsWindow( + { minTime: '2026-01-15T12:00:00.000Z', maxTime: '2026-01-15T12:00:01.000Z' }, + 500, + now + ) + + expect(resolved.segmentMs).toBe(60_000) + }) + + it('divides by segmentCount without producing a zero-width bucket', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 1, now) + + expect(resolved.segmentMs).toBe(24 * 60 * 60 * 1000) + }) +}) + +describe('buildDashboardStats', () => { + it('materializes every bucket, including the empty ones', () => { + const { stats } = buildDashboardStats([row({ segmentIndex: 1 })], window, 2) + + expect(stats.workflows).toHaveLength(1) + expect(stats.workflows[0].segments).toHaveLength(2) + expect(stats.workflows[0].segments[0]).toEqual({ + timestamp: '2026-01-15T00:00:00.000Z', + totalExecutions: 0, + successfulExecutions: 0, + avgDurationMs: 0, + }) + expect(stats.workflows[0].segments[1].totalExecutions).toBe(1) + }) + + it('clamps an out-of-range bucket index into the window', () => { + const { stats } = buildDashboardStats( + [row({ segmentIndex: 99 }), row({ segmentIndex: -5 })], + window, + 2 + ) + + expect(stats.workflows[0].segments[0].totalExecutions).toBe(1) + expect(stats.workflows[0].segments[1].totalExecutions).toBe(1) + }) + + it('weights the mean duration by run count when folding rows into one bucket', () => { + const { stats } = buildDashboardStats( + [ + row({ workflowName: 'Alpha', totalExecutions: 1, avgDurationMs: 100 }), + row({ workflowName: 'Alpha', totalExecutions: 3, avgDurationMs: 300 }), + ], + window, + 1 + ) + + expect(stats.workflows[0].segments[0].avgDurationMs).toBe(250) + expect(stats.avgLatency).toBe(250) + }) + + it('reports a workflow with no runs as fully successful rather than as zero percent', () => { + const { stats } = buildDashboardStats( + [row({ totalExecutions: 0, successfulExecutions: 0, avgDurationMs: 0 })], + window, + 1 + ) + + expect(stats.workflows[0].overallSuccessRate).toBe(100) + }) + + it('orders workflows by error rate, then by name', () => { + const { stats } = buildDashboardStats( + [ + row({ + workflowId: 'wf-clean-b', + workflowName: 'Bravo', + totalExecutions: 4, + successfulExecutions: 4, + }), + row({ + workflowId: 'wf-clean-a', + workflowName: 'Alpha', + totalExecutions: 4, + successfulExecutions: 4, + }), + row({ + workflowId: 'wf-broken', + workflowName: 'Zulu', + totalExecutions: 4, + successfulExecutions: 1, + }), + ], + window, + 1 + ) + + expect(stats.workflows.map((wf) => wf.workflowName)).toEqual(['Zulu', 'Alpha', 'Bravo']) + }) + + it('counts every workflow into the aggregate before truncating the series list', () => { + const rows = Array.from({ length: 5 }, (_unused, index) => + row({ + workflowId: `wf-${index}`, + workflowName: `Workflow ${index}`, + totalExecutions: 2, + successfulExecutions: 1, + }) + ) + + const { stats, workflowsTruncated } = buildDashboardStats(rows, window, 1, { maxWorkflows: 2 }) + + expect(workflowsTruncated).toBe(true) + expect(stats.workflows).toHaveLength(2) + expect(stats.totalRuns).toBe(10) + expect(stats.totalErrors).toBe(5) + expect(stats.aggregateSegments[0].totalExecutions).toBe(10) + }) + + /** + * The dense `segmentCount`-length array is the expensive part, so it must be + * built only for the series that survive `maxWorkflows`. Densifying first and + * slicing after is invisible in the response — every assertion above still + * passes — but at the published ceilings it allocates `workflows × + * segmentCount` segment objects to return `maxWorkflows` of them. + * + * Counted through `Date#toISOString`, which `segmentTimestamp` calls once per + * segment it fills in, so the count is an exact proxy for the densification + * work and not a wall-clock budget. + */ + it('does not densify a segment series for a workflow the cap drops', () => { + const workflowCount = 500 + const segmentCount = 500 + const rows = Array.from({ length: workflowCount }, (_unused, index) => + row({ workflowId: `wf-${index}`, workflowName: `Workflow ${index}` }) + ) + const toISOString = vi.spyOn(Date.prototype, 'toISOString') + + try { + const { stats } = buildDashboardStats(rows, window, segmentCount, { maxWorkflows: 2 }) + + expect(stats.workflows).toHaveLength(2) + expect(stats.workflows[0].segments).toHaveLength(segmentCount) + expect(stats.totalRuns).toBe(workflowCount) + expect(toISOString.mock.calls.length).toBeLessThan(workflowCount * segmentCount * 0.1) + } finally { + toISOString.mockRestore() + } + }) + + it('reports no truncation when the cap is not reached', () => { + const { workflowsTruncated, stats } = buildDashboardStats([row()], window, 1, { + maxWorkflows: 200, + }) + + expect(workflowsTruncated).toBe(false) + expect(stats.workflows).toHaveLength(1) + }) + + it('returns an empty-but-shaped response for a workspace with no runs', () => { + const { stats } = buildDashboardStats([], window, 2) + + expect(stats.workflows).toEqual([]) + expect(stats.aggregateSegments).toHaveLength(2) + expect(stats.totalRuns).toBe(0) + expect(stats.avgLatency).toBe(0) + expect(stats.timeBounds).toEqual({ + start: '2026-01-15T00:00:00.000Z', + end: '2026-01-15T02:00:00.000Z', + }) + }) +}) diff --git a/apps/sim/lib/logs/stats.ts b/apps/sim/lib/logs/stats.ts new file mode 100644 index 00000000000..a7dd2ba6dd2 --- /dev/null +++ b/apps/sim/lib/logs/stats.ts @@ -0,0 +1,244 @@ +import type { DashboardStatsResponse, SegmentStats, WorkflowStats } from '@/lib/api/contracts/logs' +import type { LogStatsBounds, LogStatsSegmentRow } from '@/lib/logs/stats-queries' + +/** Narrowest segment the dashboard will bucket into, so a short window is not sliced into sub-minute noise. */ +const MIN_SEGMENT_MS = 60_000 + +/** The time window the segments cover, and how wide each one is. */ +export interface LogStatsWindow { + startTime: Date + endTime: Date + segmentMs: number +} + +/** + * The window the segments span, derived from the rows that exist rather than + * from a caller-supplied range. + * + * A workspace with no runs still has to answer with a window, because + * `segmentMs` and every segment timestamp are computed from one — hence the + * trailing-24-hour fallback. The end is pushed to `now` whenever the newest run + * is older than that, so a live dashboard's right edge is the present rather + * than the last thing that happened. + */ +export function resolveLogStatsWindow( + bounds: LogStatsBounds, + segmentCount: number, + now: Date = new Date() +): LogStatsWindow { + let startTime: Date + let endTime: Date + + if (!bounds.minTime || !bounds.maxTime) { + endTime = now + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + } else { + startTime = new Date(bounds.minTime) + endTime = new Date(Math.max(new Date(bounds.maxTime).getTime(), now.getTime())) + } + + const totalMs = Math.max(1, endTime.getTime() - startTime.getTime()) + return { + startTime, + endTime, + segmentMs: Math.max(MIN_SEGMENT_MS, Math.floor(totalMs / segmentCount)), + } +} + +export interface BuildDashboardStatsOptions { + /** + * Largest number of per-workflow series to return. Omitted means every + * workflow, which is what the first-party dashboard reads. + */ + maxWorkflows?: number +} + +export interface DashboardStatsResult { + stats: DashboardStatsResponse + /** Whether `stats.workflows` was cut down to `maxWorkflows`. */ + workflowsTruncated: boolean +} + +/** + * Folds grouped `(workflow, segment)` counts into the dashboard's per-workflow + * series and the workspace aggregate. + * + * Pure by construction — no database, no authorization — so the bucketing, + * weighting, and truncation rules below are directly testable, which they were + * not while they lived inside the route handler. + * + * The aggregate is summed over every workflow *before* `maxWorkflows` is + * applied. Truncating first would silently under-report `totalRuns`, + * `totalErrors`, and `avgLatency` for the workspace — a wrong answer, where a + * shortened `workflows` list paired with `workflowsTruncated: true` is merely an + * incomplete one. It sums from the sparse per-workflow maps rather than from + * densified series, so summing over every workflow does not mean materializing + * one `segmentCount`-length array per workflow: only the series that survive + * `maxWorkflows` are ever densified. + */ +export function buildDashboardStats( + rows: readonly LogStatsSegmentRow[], + window: LogStatsWindow, + segmentCount: number, + options: BuildDashboardStatsOptions = {} +): DashboardStatsResult { + const { startTime, endTime, segmentMs } = window + const segmentTimestamp = (index: number) => + new Date(startTime.getTime() + index * segmentMs).toISOString() + + const workflowMap = new Map< + string, + { + workflowId: string + workflowName: string + segments: Map + totalExecutions: number + totalSuccessful: number + } + >() + + for (const row of rows) { + const segmentIndex = Math.min( + segmentCount - 1, + Math.max(0, Math.floor(Number(row.segmentIndex))) + ) + + let wf = workflowMap.get(row.workflowId) + if (!wf) { + wf = { + workflowId: row.workflowId, + workflowName: row.workflowName, + segments: new Map(), + totalExecutions: 0, + totalSuccessful: 0, + } + workflowMap.set(row.workflowId, wf) + } + + wf.totalExecutions += Number(row.totalExecutions) + wf.totalSuccessful += Number(row.successfulExecutions) + + const existing = wf.segments.get(segmentIndex) + if (existing) { + const oldTotal = existing.totalExecutions + const newTotal = oldTotal + Number(row.totalExecutions) + existing.totalExecutions = newTotal + existing.successfulExecutions += Number(row.successfulExecutions) + existing.avgDurationMs = + newTotal > 0 + ? (existing.avgDurationMs * oldTotal + + Number(row.avgDurationMs || 0) * Number(row.totalExecutions)) / + newTotal + : 0 + } else { + wf.segments.set(segmentIndex, { + timestamp: segmentTimestamp(segmentIndex), + totalExecutions: Number(row.totalExecutions), + successfulExecutions: Number(row.successfulExecutions), + avgDurationMs: Number(row.avgDurationMs || 0), + }) + } + } + + /** + * Ordered before the segment arrays are densified, so the sort key comes from + * the accumulated totals rather than from a materialized series. + */ + const accumulated = [...workflowMap.values()] + accumulated.sort((a, b) => { + const rateA = a.totalExecutions > 0 ? (a.totalSuccessful / a.totalExecutions) * 100 : 100 + const rateB = b.totalExecutions > 0 ? (b.totalSuccessful / b.totalExecutions) * 100 : 100 + const errA = rateA < 100 ? 1 - rateA / 100 : 0 + const errB = rateB < 100 ? 1 - rateB / 100 : 0 + if (errA !== errB) return errB - errA + return a.workflowName.localeCompare(b.workflowName) + }) + + const aggregateSegments: SegmentStats[] = [] + let totalRuns = 0 + let totalErrors = 0 + let weightedLatencySum = 0 + let latencyCount = 0 + + for (let i = 0; i < segmentCount; i++) { + let segTotal = 0 + let segSuccess = 0 + let segWeightedLatency = 0 + let segLatencyCount = 0 + + /** + * Summed from the sparse per-workflow maps, over every workflow in the + * window rather than only the retained ones. A segment a workflow has no + * rows for contributes nothing, which is exactly what its densified + * all-zero entry contributed. + */ + for (const wf of accumulated) { + const seg = wf.segments.get(i) + if (!seg) continue + segTotal += seg.totalExecutions + segSuccess += seg.successfulExecutions + if (seg.avgDurationMs > 0 && seg.totalExecutions > 0) { + segWeightedLatency += seg.avgDurationMs * seg.totalExecutions + segLatencyCount += seg.totalExecutions + } + } + + totalRuns += segTotal + totalErrors += segTotal - segSuccess + weightedLatencySum += segWeightedLatency + latencyCount += segLatencyCount + + aggregateSegments.push({ + timestamp: segmentTimestamp(i), + totalExecutions: segTotal, + successfulExecutions: segSuccess, + avgDurationMs: segLatencyCount > 0 ? segWeightedLatency / segLatencyCount : 0, + }) + } + + const workflowsTruncated = + options.maxWorkflows !== undefined && accumulated.length > options.maxWorkflows + const retained = workflowsTruncated ? accumulated.slice(0, options.maxWorkflows) : accumulated + + /** + * Densified last, and only for the series that survive `maxWorkflows`. Doing + * it before the cut allocates `segmentCount` entries for every workflow in + * the window — at the published ceilings, millions of objects to return two + * hundred series. + */ + const workflows: WorkflowStats[] = retained.map((wf) => { + const segments: SegmentStats[] = [] + for (let i = 0; i < segmentCount; i++) { + segments.push( + wf.segments.get(i) ?? { + timestamp: segmentTimestamp(i), + totalExecutions: 0, + successfulExecutions: 0, + avgDurationMs: 0, + } + ) + } + return { + workflowId: wf.workflowId, + workflowName: wf.workflowName, + segments, + totalExecutions: wf.totalExecutions, + totalSuccessful: wf.totalSuccessful, + overallSuccessRate: + wf.totalExecutions > 0 ? (wf.totalSuccessful / wf.totalExecutions) * 100 : 100, + } + }) + + return { + stats: { + workflows, + aggregateSegments, + totalRuns, + totalErrors, + avgLatency: latencyCount > 0 ? weightedLatencySum / latencyCount : 0, + timeBounds: { start: startTime.toISOString(), end: endTime.toISOString() }, + segmentMs, + }, + workflowsTruncated, + } +} diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index eb25b8823ec..c351a14268d 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -230,6 +230,12 @@ export const PERSISTED_WORKFLOW_EXECUTION_STATUSES = [ export type PersistedWorkflowExecutionStatus = (typeof PERSISTED_WORKFLOW_EXECUTION_STATUSES)[number] +/** Narrows an already-validated status string onto the persisted vocabulary. */ +export function isPersistedWorkflowExecutionStatus( + value: string +): value is PersistedWorkflowExecutionStatus { + return (PERSISTED_WORKFLOW_EXECUTION_STATUSES as readonly string[]).includes(value) +} /** * In-flight statuses a crashed worker can strand, which the stale-execution * cron terminalizes. `pending` and `paused` are excluded: both are written as diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index 0a0ea749ab4..cde261b8daf 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -13,6 +13,81 @@ describe('MCP server operation registry', () => { }) }) + /** + * The six workflow-deployment operations were widened from `['delegated']` to + * human principals when `/api/v2/workflow-mcp-servers` shipped. Their roles + * and workspace-key policy are the only thing standing between a member and a + * server published for unauthenticated execution, so each is pinned here. + */ + const WORKFLOW_DEPLOYMENT_OPERATIONS = { + listWorkflowDeployments: { + id: 'mcp_servers.workflow_deployments.list', + minimumRole: 'read', + }, + createWorkflowDeploymentServer: { + id: 'mcp_servers.workflow_deployments.create_server', + minimumRole: 'admin', + }, + updateWorkflowDeploymentServer: { + id: 'mcp_servers.workflow_deployments.update_server', + minimumRole: 'admin', + }, + deleteWorkflowDeploymentServer: { + id: 'mcp_servers.workflow_deployments.delete_server', + minimumRole: 'admin', + }, + deployWorkflowTool: { + id: 'mcp_servers.workflow_deployments.deploy_tool', + minimumRole: 'admin', + }, + undeployWorkflowTool: { + id: 'mcp_servers.workflow_deployments.undeploy_tool', + minimumRole: 'admin', + }, + } as const + + it('pins the role of every workflow-deployment operation', () => { + for (const [key, expected] of Object.entries(WORKFLOW_DEPLOYMENT_OPERATIONS)) { + const operation = mcpServerOperations[key as keyof typeof mcpServerOperations] + expect(operation, key).toMatchObject(expected) + } + }) + + /** + * `update_server` carries `isPublic`, and a public server answers + * `/api/mcp/serve/{serverId}` with no Sim credential. `write` here would let a + * member remove authentication from every workflow the server publishes, + * which is the authority `create_server` and `workflows.public_api.update` + * both reserve for admins. + */ + it('requires admin to change a published server, matching create and delete', () => { + expect(mcpServerOperations.updateWorkflowDeploymentServer.minimumRole).toBe('admin') + expect(mcpServerOperations.updateWorkflowDeploymentServer.minimumRole).toBe( + mcpServerOperations.createWorkflowDeploymentServer.minimumRole + ) + }) + + it('denies workspace API keys across the whole workflow-deployment family', () => { + for (const key of Object.keys(WORKFLOW_DEPLOYMENT_OPERATIONS)) { + const operation = mcpServerOperations[key as keyof typeof mcpServerOperations] + expect(operation.workspaceApiKey, operation.id).toBe('deny') + expect(operation.principalKinds, operation.id).not.toContain('workspace_api_key') + } + }) + + it('admits only human principals and copilot delegation for workflow deployments', () => { + for (const key of Object.keys(WORKFLOW_DEPLOYMENT_OPERATIONS)) { + const operation = mcpServerOperations[key as keyof typeof mcpServerOperations] + expect(operation.principalKinds, operation.id).toEqual([ + 'session', + 'personal_api_key', + 'delegated', + ]) + expect(operation.delegatedServices, operation.id).toEqual(['copilot']) + expect(Object.isFrozen(operation), operation.id).toBe(true) + } + }) + it('uses unique stable operation IDs', () => { const ids = Object.values(mcpServerOperations).map((operation) => operation.id) expect(new Set(ids).size).toBe(ids.length) diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index 396ff1cf5be..c26cd09b2f9 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -22,47 +22,90 @@ export const mcpServerOperations = { workspaceApiKey: 'deny', ...HUMAN_PRINCIPAL_POLICY, }), + /** + * Publishing a workflow as an MCP server was reachable only through Copilot, + * so all six operations below declared `principalKinds: ['delegated']` — not + * because a human may not perform them, but because no human-facing surface + * existed. `/api/v2/workflow-mcp-servers` is that surface, so each now admits + * the two human principal kinds alongside the Copilot delegation that already + * held them. + * + * Roles are unchanged, and every one keeps `workspaceApiKey: 'deny'`: an MCP + * server publishes a workflow for execution by an outside agent, which is an + * authority grant that needs an accountable human rather than a machine + * credential. The three `admin` operations could not accept a workspace key + * anyway — it has a write ceiling — so `deny` is the honest declaration for + * the `read` and `write` ones too rather than a split policy across one + * resource family. + */ listWorkflowDeployments: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.list', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], + ...HUMAN_PRINCIPAL_POLICY, + }), + /** + * Reads one published server, and the tools it publishes. + * + * Both carry the `listWorkflowDeployments` policy rather than the + * `mcp_servers.read` one beside them: the workflow-deployment family denies + * workspace API keys throughout, and a detail read that admitted one would be + * a wider door into the same data the list deliberately closes. + */ + readWorkflowDeploymentServer: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.read_server', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_PRINCIPAL_POLICY, + }), + listWorkflowDeploymentTools: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.list_tools', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_PRINCIPAL_POLICY, }), createWorkflowDeploymentServer: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.create_server', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], + ...HUMAN_PRINCIPAL_POLICY, }), + /** + * `admin`, not `write`, because the body carries `isPublic`. A public server + * answers `/api/mcp/serve/{serverId}` with no Sim credential, so flipping it + * removes the authentication requirement from every workflow the server + * publishes — the same authority `workflows.public_api.update` reserves for + * admins. `create_server` already accepts `isPublic` at `admin`, so a lower + * role here would only mean the cheaper path to the same grant. + * + * Copilot stays a principal, unlike `workflows.public_api.update`: this + * family already admits it at `admin` for `create_server`, which grants the + * identical visibility, so denying it only for the update would leave the + * grant reachable while breaking the shipped rename tool. + */ updateWorkflowDeploymentServer: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.update_server', - minimumRole: 'write', + minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], + ...HUMAN_PRINCIPAL_POLICY, }), deleteWorkflowDeploymentServer: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.delete_server', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], + ...HUMAN_PRINCIPAL_POLICY, }), deployWorkflowTool: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.deploy_tool', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], + ...HUMAN_PRINCIPAL_POLICY, }), undeployWorkflowTool: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.undeploy_tool', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], + ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'mcp_servers.read', diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index dc7d3433516..c0d4a05113e 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -291,7 +291,7 @@ export const createMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ if (idState && !idState.deleted) { throw new OrchestrationError( 'conflict', - 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.' + 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{mcpServerId}.' ) } let result: PerformMcpServerResult & { server: McpServerRow } diff --git a/apps/sim/lib/mcp/application/workflow-deployments.test.ts b/apps/sim/lib/mcp/application/workflow-deployments.test.ts index 4957911976c..e0225e8d636 100644 --- a/apps/sim/lib/mcp/application/workflow-deployments.test.ts +++ b/apps/sim/lib/mcp/application/workflow-deployments.test.ts @@ -95,7 +95,7 @@ describe('workflow MCP deployment application commands', () => { allowPersonalApiKeys: true, billedAccountUserId: 'billing-owner-1', })) - mocks.permission.mockResolvedValue('write') + mocks.permission.mockResolvedValue('admin') mocks.updateServer.mockResolvedValue({ success: true, server: { ...server, name: 'Renamed MCP' }, @@ -132,6 +132,26 @@ describe('workflow MCP deployment application commands', () => { expect(mocks.updateServer).not.toHaveBeenCalled() }) + /** + * The body carries `isPublic`, and a public server answers + * `/api/mcp/serve/{serverId}` with no Sim credential — so a `write` member + * could otherwise remove authentication from every workflow it publishes. + */ + it('refuses a write-role member, because the update can publish the server', async () => { + queueTableRows(schemaMock.workflowMcpServer, [server]) + mocks.permission.mockResolvedValueOnce('write') + + await expect( + updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, isPublic: true }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.updateServer).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + it('owns mutation attribution and semantic audit', async () => { queueTableRows(schemaMock.workflowMcpServer, [server]) diff --git a/apps/sim/lib/mcp/application/workflow-deployments.ts b/apps/sim/lib/mcp/application/workflow-deployments.ts index 04c52e29a74..3bf0b853808 100644 --- a/apps/sim/lib/mcp/application/workflow-deployments.ts +++ b/apps/sim/lib/mcp/application/workflow-deployments.ts @@ -1,7 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' -import { db, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db' -import { and, asc, eq, inArray, isNull } from 'drizzle-orm' +import type { CursorKey } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' @@ -15,6 +14,15 @@ import { performUpdateWorkflowMcpTool, } from '@/lib/mcp/orchestration' import { mcpPubSub } from '@/lib/mcp/pubsub' +import { + getLiveWorkflowMcpTool, + getWorkflowMcpPublishableWorkflow, + getWorkflowMcpServerById, + listLiveWorkflowMcpTools, + listWorkflowMcpToolNames, + listWorkspaceWorkflowMcpServers, + type WorkflowMcpServerSortBy, +} from '@/lib/mcp/queries' import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync' import { applyDescriptionOverrides, @@ -35,11 +43,7 @@ async function resolveWorkspaceContext(workspaceId: string) { } async function resolveServerContext(serverId: string) { - const [server] = await db - .select() - .from(workflowMcpServer) - .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) - .limit(1) + const server = await getWorkflowMcpServerById(serverId) if (!server) throw new OrchestrationError('not_found', 'MCP server not found') const workspace = await resolveWorkspaceContext(server.workspaceId) return { ...workspace, server } @@ -47,17 +51,7 @@ async function resolveServerContext(serverId: string) { async function resolveWorkflowToolContext(serverId: string, workflowId: string) { const context = await resolveServerContext(serverId) - const [workflowRecord] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.workspaceId, context.workspaceId), - isNull(workflow.archivedAt) - ) - ) - .limit(1) + const workflowRecord = await getWorkflowMcpPublishableWorkflow(context.workspaceId, workflowId) if (!workflowRecord) throw new OrchestrationError('not_found', 'Workflow not found') return { ...context, workflow: workflowRecord } } @@ -84,61 +78,104 @@ function attribution( export interface ListWorkflowMcpDeploymentsInput { workspaceId: string + sortBy?: WorkflowMcpServerSortBy + sortOrder?: 'asc' | 'desc' + limit?: number + cursorKeys?: CursorKey[] } +/** + * Workflow-MCP servers in a workspace, each with the tool names it publishes. + * + * Keyset-paged, because nothing caps how many servers a workspace publishes — + * the same reasoning that made the external server list paged. An absent + * `limit` still applies {@link MAX_LISTED_WORKFLOW_MCP_SERVERS}, so the copilot + * adapter reads exactly the page it always read; it now learns the set was cut + * from `nextCursorKeys` rather than from a row count it did the arithmetic on + * itself. + * + * The tool-name aggregation stays a second bounded read rather than a join: a + * join would multiply server rows by their tools and break the keyset page + * boundary, and the names are decoration on the server summary, not the page's + * unit. + */ export const listWorkflowMcpDeployments = defineAuthorizedWorkspaceUseCase({ operation: mcpServerOperations.listWorkflowDeployments, resolveContext: ({ input }: { input: ListWorkflowMcpDeploymentsInput }) => resolveWorkspaceContext(input.workspaceId), authorizationOptions, - async execute({ context }) { - const rows = await db - .select({ - id: workflowMcpServer.id, - name: workflowMcpServer.name, - description: workflowMcpServer.description, - }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.workspaceId, context.workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .orderBy(asc(workflowMcpServer.id)) - .limit(MAX_LISTED_WORKFLOW_MCP_SERVERS + 1) - const truncated = rows.length > MAX_LISTED_WORKFLOW_MCP_SERVERS - const servers = rows.slice(0, MAX_LISTED_WORKFLOW_MCP_SERVERS) - const serverIds = servers.map((server) => server.id) - const tools = - serverIds.length === 0 - ? [] - : await db - .select({ serverId: workflowMcpTool.serverId, toolName: workflowMcpTool.toolName }) - .from(workflowMcpTool) - .where( - and(inArray(workflowMcpTool.serverId, serverIds), isNull(workflowMcpTool.archivedAt)) - ) - .orderBy(asc(workflowMcpTool.serverId), asc(workflowMcpTool.toolName)) - .limit(MAX_LISTED_WORKFLOW_MCP_TOOLS + 1) - const toolsTruncated = tools.length > MAX_LISTED_WORKFLOW_MCP_TOOLS - const names = new Map() - for (const tool of tools.slice(0, MAX_LISTED_WORKFLOW_MCP_TOOLS)) { - const existing = names.get(tool.serverId) ?? [] - existing.push(tool.toolName) - names.set(tool.serverId, existing) - } + async execute({ input, context }) { + const page = await listWorkspaceWorkflowMcpServers({ + workspaceId: context.workspaceId, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit ?? MAX_LISTED_WORKFLOW_MCP_SERVERS, + cursorKeys: input.cursorKeys, + }) + const servers = page.data + const { namesByServerId, truncated } = await listWorkflowMcpToolNames( + servers.map((server) => server.id), + MAX_LISTED_WORKFLOW_MCP_TOOLS + ) return { servers: servers.map((server) => ({ ...server, - toolCount: names.get(server.id)?.length ?? 0, - toolNames: names.get(server.id) ?? [], + toolCount: namesByServerId.get(server.id)?.length ?? 0, + toolNames: namesByServerId.get(server.id) ?? [], })), - truncated: truncated || toolsTruncated, + nextCursorKeys: page.nextCursorKeys, + truncated: page.nextCursorKeys !== null || truncated, } }, }) +export interface ReadWorkflowMcpDeploymentServerInput { + serverId: string +} + +/** + * One published server. + * + * The list carries `toolCount`/`toolNames` as decoration on a page; this read + * answers for a single server, so the inventory is left to + * {@link listWorkflowMcpDeploymentTools} rather than duplicated here in a + * second shape. + */ +export const readWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.readWorkflowDeploymentServer, + resolveContext: ({ input }: { input: ReadWorkflowMcpDeploymentServerInput }) => + resolveServerContext(input.serverId), + authorizationOptions, + async execute({ context }) { + return { server: context.server } + }, +}) + +export interface ListWorkflowMcpDeploymentToolsInput { + serverId: string +} + +/** + * Every tool a server publishes. + * + * Without this a caller could publish and unpublish tools but never enumerate + * them: the server list reports tool *names* only, so nothing returned the + * `workflowId` that addresses a tool for deletion. + */ +export const listWorkflowMcpDeploymentTools = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.listWorkflowDeploymentTools, + resolveContext: ({ input }: { input: ListWorkflowMcpDeploymentToolsInput }) => + resolveServerContext(input.serverId), + authorizationOptions, + async execute({ context }) { + const { tools, truncated } = await listLiveWorkflowMcpTools( + context.server.id, + MAX_LISTED_WORKFLOW_MCP_TOOLS + ) + return { tools, truncated } + }, +}) + export interface CreateWorkflowMcpDeploymentServerInput { workspaceId: string name: string @@ -293,17 +330,7 @@ export const deployWorkflowMcpTool = defineAuthorizedWorkspaceUseCase({ `MCP tools cannot override more than ${MAX_MCP_PARAMETER_DESCRIPTION_OVERRIDES} parameter descriptions` ) } - const [existing] = await db - .select() - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, context.server.id), - eq(workflowMcpTool.workflowId, context.workflow.id), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) + const existing = await getLiveWorkflowMcpTool(context.server.id, context.workflow.id) const toolName = sanitizeToolName( input.toolName || context.workflow.name || `workflow_${context.workflow.id}` ) @@ -384,17 +411,7 @@ export const undeployWorkflowMcpTool = defineAuthorizedWorkspaceUseCase({ resolveWorkflowToolContext(input.serverId, input.workflowId), authorizationOptions, async execute({ principal, context }) { - const [tool] = await db - .select() - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, context.server.id), - eq(workflowMcpTool.workflowId, context.workflow.id), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) + const tool = await getLiveWorkflowMcpTool(context.server.id, context.workflow.id) if (!tool) { throw new OrchestrationError('not_found', 'Workflow is not deployed to this MCP server') } diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts index 637ac9540f0..75906d80890 100644 --- a/apps/sim/lib/mcp/queries.ts +++ b/apps/sim/lib/mcp/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' -import { mcpServers } from '@sim/db/schema' -import { and, eq, isNull } from 'drizzle-orm' +import { mcpServers, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { and, asc, eq, inArray, isNull } from 'drizzle-orm' import { type CursorKey, type KeysetKey, @@ -125,3 +125,186 @@ export async function getMcpServerIdState(params: { .limit(1) return row ? { deleted: row.deletedAt !== null } : null } + +export type WorkflowMcpServerRow = typeof workflowMcpServer.$inferSelect +export type WorkflowMcpToolRow = typeof workflowMcpTool.$inferSelect +export type WorkflowMcpServerSortBy = 'name' | 'createdAt' | 'updatedAt' + +const workflowMcpServerId = textKey(workflowMcpServer.id, (row) => row.id) + +/** + * Keyset orderings for the workflow-MCP list, mirroring {@link MCP_SERVER_SORTS} + * so the two server families page identically. Each ends in `id` for the same + * reason: a non-unique final key repeats or drops a row at a page boundary. + */ +const WORKFLOW_MCP_SERVER_SORTS = { + name: [ + textKey(workflowMcpServer.name, (row) => row.name), + workflowMcpServerId, + ], + createdAt: [ + timestampKey(workflowMcpServer.createdAt, (row) => row.createdAt), + workflowMcpServerId, + ], + updatedAt: [ + timestampKey(workflowMcpServer.updatedAt, (row) => row.updatedAt), + workflowMcpServerId, + ], +} satisfies Record[]> + +/** + * One keyset page of live workflow-MCP servers in a workspace. + * + * Nothing caps how many a workspace publishes, so this pages exactly like the + * external server list. `limit` is required here rather than optional: the only + * callers are the public list and the copilot adapter, and both bound the page. + */ +export async function listWorkspaceWorkflowMcpServers(params: { + workspaceId: string + sortBy?: WorkflowMcpServerSortBy + sortOrder?: ListSortOrder + limit: number + cursorKeys?: CursorKey[] +}): Promise> { + const { sortBy = 'createdAt', sortOrder = 'desc', limit } = params + const keys = WORKFLOW_MCP_SERVER_SORTS[sortBy] + const resumeAfter = resumeKeyset(keys, params.cursorKeys, sortOrder) + + const rows = await db + .select() + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.workspaceId, params.workspaceId), + isNull(workflowMcpServer.deletedAt), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + .limit(limit + 1) + + return keysetPage(keys, rows, limit) +} + +/** + * Live tool names for a set of workflow-MCP servers, alphabetically ordered + * within each server. + * + * A bounded second read rather than a join on the server page: joining would + * multiply server rows by their tools and break the keyset page boundary. The + * cap is on the aggregate rather than per server, so `truncated` means "some + * server's inventory is incomplete", which is the only claim one read can + * honestly make. + */ +export async function listWorkflowMcpToolNames( + serverIds: string[], + limit: number +): Promise<{ namesByServerId: Map; truncated: boolean }> { + if (serverIds.length === 0) return { namesByServerId: new Map(), truncated: false } + + const rows = await db + .select({ serverId: workflowMcpTool.serverId, toolName: workflowMcpTool.toolName }) + .from(workflowMcpTool) + .where(and(inArray(workflowMcpTool.serverId, serverIds), isNull(workflowMcpTool.archivedAt))) + .orderBy(asc(workflowMcpTool.serverId), asc(workflowMcpTool.toolName)) + .limit(limit + 1) + + const namesByServerId = new Map() + for (const row of rows.slice(0, limit)) { + const existing = namesByServerId.get(row.serverId) + if (existing) existing.push(row.toolName) + else namesByServerId.set(row.serverId, [row.toolName]) + } + return { namesByServerId, truncated: rows.length > limit } +} + +/** A live (non-soft-deleted) workflow-MCP server by id, or null. */ +export async function getWorkflowMcpServerById( + serverId: string +): Promise { + const [row] = await db + .select() + .from(workflowMcpServer) + .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) + .limit(1) + return row ?? null +} + +/** + * Every live tool a server publishes, tool-name ordered. + * + * Bounded by `limit` rather than paged, matching `GET /api/v2/mcp-servers/{mcpServerId}/tools`: + * a server's inventory is capped by the workflows a workspace has deployed, and + * the caller wants the whole inventory to reconcile against, not a page of it. + * The `+ 1` read is how the caller learns the cap was hit — the same signal + * {@link listWorkflowMcpToolNames} reports as `truncated`. + */ +export async function listLiveWorkflowMcpTools( + serverId: string, + limit: number +): Promise<{ tools: WorkflowMcpToolRow[]; truncated: boolean }> { + const rows = await db + .select() + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + .orderBy(asc(workflowMcpTool.toolName)) + .limit(limit + 1) + return { tools: rows.slice(0, limit), truncated: rows.length > limit } +} + +/** + * The live tool publishing a workflow on a server, or null. + * + * A server carries at most one unarchived tool per workflow — the partial unique + * index on `(server_id, workflow_id)` — so the pair is an identity, which is why + * the public surface addresses a tool by workflow rather than by tool id. + */ +export async function getLiveWorkflowMcpTool( + serverId: string, + workflowId: string +): Promise { + const [row] = await db + .select() + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, serverId), + eq(workflowMcpTool.workflowId, workflowId), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) + return row ?? null +} + +export type WorkflowMcpPublishableWorkflow = { + id: string + name: string + isDeployed: boolean +} + +/** + * The workflow a workflow-MCP tool would publish, scoped to the server's own + * workspace. + * + * Predicating on the workspace here is what makes a workflow id from another + * tenant a not-found rather than a cross-tenant publish, so this read is the + * authorization-sensitive half of resolving a tool target. + */ +export async function getWorkflowMcpPublishableWorkflow( + workspaceId: string, + workflowId: string +): Promise { + const [row] = await db + .select({ id: workflow.id, name: workflow.name, isDeployed: workflow.isDeployed }) + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + return row ?? null +} diff --git a/apps/sim/lib/mcp/urls.ts b/apps/sim/lib/mcp/urls.ts new file mode 100644 index 00000000000..6dd27b6491b --- /dev/null +++ b/apps/sim/lib/mcp/urls.ts @@ -0,0 +1,23 @@ +import { getBaseUrl } from '@/lib/core/utils/urls' + +/** + * The endpoint an MCP client connects to for a workspace-published server. + * + * Shared by the Copilot deploy handler and the v2 surface so the two cannot + * publish different URLs for the same server. + */ +export function buildWorkflowMcpServerUrl(serverId: string): string { + return `${getBaseUrl()}/api/mcp/serve/${serverId}` +} + +/** + * The Sim execution endpoint a deployed workflow is called through, and the one + * a published MCP tool routes to. + * + * Lives here rather than beside {@link getBaseUrl} in `lib/core/utils/urls` + * because that module is replaced wholesale by the shared test mock, so an + * export added there is `undefined` in every suite until the fixture mirrors it. + */ +export function buildWorkflowMcpApiEndpoint(workflowId: string): string { + return `${getBaseUrl()}/api/v2/workflows/${workflowId}/execute` +} diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index 4a87854b7fb..52ae732e805 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -36,6 +36,21 @@ vi.mock('@/lib/table/workflow-columns', () => ({ stripGroupDeps: vi.fn(), })) +const { mockLoadExecutionsByRow } = vi.hoisted(() => ({ + mockLoadExecutionsByRow: vi.fn(async () => new Map()), +})) + +vi.mock('@/lib/table/rows/executions', () => ({ + applyExecutionsPatch: vi.fn((existing: unknown) => existing), + deriveExecClearsForDataPatch: vi.fn(() => ({ + executionsPatch: undefined, + inFlightDownstreamGroups: [], + })), + loadExecutionsByRow: mockLoadExecutionsByRow, + loadExecutionsForRow: vi.fn(async () => ({})), + writeExecutionsPatch: vi.fn(async () => 'wrote'), +})) + vi.mock('@/lib/table/validation', () => ({ validateRowSize: vi.fn(() => ({ valid: true, errors: [] })), validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })), @@ -261,6 +276,49 @@ describe('queryRows byte budget', () => { expect(result.nextCursor).toBeNull() }) + /** + * The sidecar is a second, unbounded read: its `blockErrors` are jsonb with no + * ceiling of its own, so the drain has to carry a byte budget rather than have + * one measured over an already-materialized result. + */ + it('hands the run-state drain the budget its caller asked for', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([row(1, 8), row(2, 8)]) + + await queryRows( + TABLE, + { + limit: 5, + includeTotal: false, + withExecutions: true, + runStateBudgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES, + }, + 'req-1' + ) + + expect(mockLoadExecutionsByRow).toHaveBeenCalledWith(expect.anything(), ['row_1', 'row_2'], { + budgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES, + }) + }) + + /** + * Only the public reads publish a `413` for the sidecar. The first-party grid + * reads run state at five times the row limit with no such contract, so a + * budget there turns a large page into a hard failure where it used to render. + */ + it('leaves the drain unbounded for a caller that asked for no budget', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([row(1, 8), row(2, 8)]) + + await queryRows(TABLE, { limit: 5, includeTotal: false, withExecutions: true }, 'req-1') + + expect(mockLoadExecutionsByRow).toHaveBeenCalledWith( + expect.anything(), + ['row_1', 'row_2'], + undefined + ) + }) + it('returns an entire under-budget result past the former batch safety limit', async () => { const state = mockRowsPastFormerBatchSafetyLimit() diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index 679c0deb676..bd8402a0809 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -56,6 +56,16 @@ export const v2TableErrorPolicies = { notFoundMessage: 'Table export not found', render: renderTableError, }), + /** + * Workspace-scoped bulk routes. Deliberately NOT a concealment policy: these + * routes name a workspace, not one table, so there is no table whose + * existence a 403 could betray, and per-item authorization failures are + * already folded into the response's `notFound` list by the use case. The + * same reasoning {@link internalTableErrorPolicies.bulk} is built on. + */ + bulk: { + render: renderTableError, + } satisfies V2ErrorPolicy, } as const const internalTableGroupErrorPolicy = extendInternalErrorPolicy( diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts index ddfa56d7ad0..0bafc8bfce9 100644 --- a/apps/sim/lib/table/application/bulk.test.ts +++ b/apps/sim/lib/table/application/bulk.test.ts @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ resolveWorkspaceContext: vi.fn(), signal: vi.fn(), notifyTables: vi.fn(), + resolveFolderPathFromIndex: vi.fn(), + resolveTableFolderPath: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -58,7 +60,13 @@ vi.mock('@/lib/folders/bulk', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceTablesChanged: mocks.notifyTables, })) -vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/folders/queries', () => ({ + findActiveFolder: mocks.findActiveFolder, + resolveFolderPathFromIndex: mocks.resolveFolderPathFromIndex, +})) +vi.mock('@/lib/table/application/folder-paths', () => ({ + resolveTableFolderPath: mocks.resolveTableFolderPath, +})) vi.mock('@/lib/table', () => ({ deleteTable: mocks.deleteTable, moveTableToFolder: mocks.moveTableToFolder, @@ -90,6 +98,18 @@ function tableContext(id: string, folderId: string | null = null) { } } +/** + * The active folder tree a path-keyed batch resolves against. `undefined` for + * anything absent, mirroring `resolveFolderPathFromIndex`; `/` is the workspace + * root, which is not a folder row. + */ +const FOLDER_ID_BY_PATH: Record = { + '/': null, + '/Sales': 'folder-1', + '/Sales/': 'folder-1', + '/Sales/Enterprise': 'folder-2', +} + const emptyPlan = { selected: [], notFound: [], contained: [], covered: new Set() } describe('table bulk application use cases', () => { @@ -111,13 +131,22 @@ describe('table bulk application use cases', () => { folderCount: 0, resourceCount: 0, }) + mocks.resolveTableFolderPath.mockResolvedValue({ folderId: null, index: { kind: 'index' } }) + mocks.resolveFolderPathFromIndex.mockImplementation( + (_index: unknown, path: string) => FOLDER_ID_BY_PATH[path] + ) }) it('rejects an empty selection before the canonical workspace load', async () => { await expect( bulkDeleteTables.execute({ principal, - input: { assertedWorkspaceId: 'workspace-1', tableIds: [], folderIds: [] }, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'ids' as const, + tableIds: [], + folders: [], + }, }) ).rejects.toMatchObject({ code: 'validation' }) @@ -132,7 +161,8 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: Array.from({ length: 60 }, (_, index) => `table-${index}`), - folderIds: Array.from({ length: 60 }, (_, index) => `folder-${index}`), + folderKeying: 'ids' as const, + folders: Array.from({ length: 60 }, (_, index) => `folder-${index}`), }, }) ).rejects.toMatchObject({ code: 'validation' }) @@ -159,7 +189,8 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1'], - folderIds: ['folder-1'], + folderKeying: 'ids' as const, + folders: ['folder-1'], }, }) @@ -198,7 +229,8 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1'], - folderIds: ['folder-1'], + folderKeying: 'ids' as const, + folders: ['folder-1'], }, }) @@ -220,7 +252,8 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-locked', 'table-2'], - folderIds: [], + folderKeying: 'ids' as const, + folders: [], }, }) @@ -236,7 +269,12 @@ describe('table bulk application use cases', () => { const result = await bulkDeleteTables.execute({ principal, - input: { assertedWorkspaceId: 'workspace-1', tableIds: ['other-workspace'], folderIds: [] }, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'ids' as const, + tableIds: ['other-workspace'], + folders: [], + }, }) expect(result.notFound).toEqual([{ kind: 'table', id: 'other-workspace' }]) @@ -253,8 +291,9 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1'], - folderIds: [], - targetFolderId: 'foreign-folder', + folderKeying: 'ids' as const, + folders: [], + targetFolder: 'foreign-folder', }, }) ).rejects.toMatchObject({ code: 'not_found' }) @@ -273,15 +312,16 @@ describe('table bulk application use cases', () => { covered: new Set(['folder-2', 'folder-2-child']), }) - for (const targetFolderId of ['folder-2', 'folder-2-child']) { + for (const targetFolder of ['folder-2', 'folder-2-child']) { await expect( bulkMoveTables.execute({ principal, input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1'], - folderIds: ['folder-2'], - targetFolderId, + folderKeying: 'ids' as const, + folders: ['folder-2'], + targetFolder, }, }) ).rejects.toMatchObject({ code: 'validation' }) @@ -307,8 +347,9 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds, - folderIds: [], - targetFolderId: 'folder-1', + folderKeying: 'ids' as const, + folders: [], + targetFolder: 'folder-1', }, }) @@ -332,8 +373,9 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1', 'table-2', 'table-3'], - folderIds: [], - targetFolderId: 'folder-1', + folderKeying: 'ids' as const, + folders: [], + targetFolder: 'folder-1', }, }) @@ -360,8 +402,9 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1'], - folderIds: ['folder-2', 'folder-3', 'ghost-folder'], - targetFolderId: 'folder-1', + folderKeying: 'ids' as const, + folders: ['folder-2', 'folder-3', 'ghost-folder'], + targetFolder: 'folder-1', }, }) @@ -388,8 +431,9 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1', 'table-2', 'table-3'], - folderIds: [], - targetFolderId: 'folder-1', + folderKeying: 'ids' as const, + folders: [], + targetFolder: 'folder-1', }, }) @@ -412,7 +456,8 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'], - folderIds: [], + folderKeying: 'ids' as const, + folders: [], }, }) ).rejects.toThrow('connection reset') @@ -427,7 +472,12 @@ describe('table bulk application use cases', () => { await bulkDeleteTables.execute({ principal, - input: { assertedWorkspaceId: 'workspace-1', tableIds: ['ghost'], folderIds: [] }, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'ids' as const, + tableIds: ['ghost'], + folders: [], + }, }) expect(mocks.notifyTables).not.toHaveBeenCalled() @@ -445,7 +495,8 @@ describe('table bulk application use cases', () => { input: { assertedWorkspaceId: 'workspace-1', tableIds: ['table-1', 'table-2', 'table-3'], - folderIds: [], + folderKeying: 'ids' as const, + folders: [], }, }) ).rejects.toThrow('connection reset') @@ -456,3 +507,227 @@ describe('table bulk application use cases', () => { expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled() }) }) + +/** + * The v2 surface names folders by canonical path. Resolving one is an + * authorization-sensitive read of the workspace's folder tree, so it happens + * here rather than at a route — and everything the caller gets back is named + * the same way it asked, never by an id it has no way to use. + */ +describe('path-keyed bulk table selections', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mocks.resolveTableContext.mockImplementation(async (tableId: string) => tableContext(tableId)) + mocks.moveTableToFolder.mockResolvedValue({ name: 'Moved' }) + mocks.deleteTable.mockResolvedValue({ + archived: { name: 'Archived', workspaceId: 'workspace-1' }, + }) + mocks.bulkMoveFolders.mockResolvedValue({ succeeded: [], failed: [] }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [], + failed: [], + folderCount: 0, + resourceCount: 0, + }) + mocks.resolveTableFolderPath.mockResolvedValue({ folderId: null, index: { kind: 'index' } }) + mocks.resolveFolderPathFromIndex.mockImplementation( + (_index: unknown, path: string) => FOLDER_ID_BY_PATH[path] + ) + }) + + it('resolves selected folder paths to canonical ids before planning', async () => { + await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: [], + folders: ['/Sales', '/Sales/Enterprise'], + }, + }) + + expect(mocks.planFolderSelection).toHaveBeenCalledWith('workspace-1', 'table', [ + 'folder-1', + 'folder-2', + ]) + }) + + /** + * The selection deduplicates PATHS, so two spellings of one folder survive it + * and resolve to the same id. Left in, the batch carries that id twice while + * the path index is last-wins, so one of the two spellings is unreportable. + */ + it('deduplicates folders that two distinct paths resolve to', async () => { + await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: [], + folders: ['/Sales', '/Sales/'], + }, + }) + + expect(mocks.planFolderSelection).toHaveBeenCalledWith('workspace-1', 'table', ['folder-1']) + }) + + it('names a deduplicated folder by the first path that reached it', async () => { + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Sales' }], + failed: [], + folderCount: 1, + resourceCount: 0, + }) + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Sales' }], + notFound: [], + contained: [], + covered: new Set(), + }) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: [], + folders: ['/Sales', '/Sales/'], + }, + }) + + expect(result.deleted).toEqual([{ kind: 'folder', id: '/Sales', name: '/Sales' }]) + }) + + /** + * One index for the whole batch: `resolveTableFolderPath` takes the folder + * tree lock per call, so per-path resolution would be a lock acquisition each. + */ + it('reads the folder tree once however many paths the batch names', async () => { + await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: [], + folders: ['/Sales', '/Sales/Enterprise'], + }, + }) + + expect(mocks.resolveTableFolderPath).toHaveBeenCalledTimes(1) + }) + + it('reports a path naming no active folder as not found, without failing the batch', async () => { + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: ['table-1'], + folders: ['/Sales/Ghost'], + }, + }) + + expect(result.notFound).toEqual([{ kind: 'folder', id: '/Sales/Ghost' }]) + expect(result.deleted).toEqual([{ kind: 'table', id: 'table-1', name: 'Archived' }]) + }) + + /** The workspace root is not a folder row, so it can be neither moved nor deleted. */ + it('reports the workspace root as not found rather than acting on it', async () => { + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: ['table-1'], + folders: ['/'], + }, + }) + + expect(result.notFound).toEqual([{ kind: 'folder', id: '/' }]) + }) + + it('names every folder in the result by the path the caller used', async () => { + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Sales' }], + failed: [], + folderCount: 1, + resourceCount: 3, + }) + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Sales' }], + notFound: [], + contained: [], + covered: new Set(['folder-1']), + }) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: [], + folders: ['/Sales'], + }, + }) + + expect(result.deleted).toEqual([{ kind: 'folder', id: '/Sales', name: '/Sales' }]) + }) + + it('resolves the destination path and refuses one that names no folder', async () => { + await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: ['table-1'], + folders: [], + targetFolder: '/Sales', + }, + }) + expect(mocks.moveTableToFolder).toHaveBeenCalledWith( + 'table-1', + 'workspace-1', + 'folder-1', + 'request-1', + { notify: false } + ) + + await expect( + bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: ['table-1'], + folders: [], + targetFolder: '/Sales/Ghost', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('treats a null destination as the workspace root', async () => { + await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: ['table-1'], + folders: [], + targetFolder: null, + }, + }) + + expect(mocks.moveTableToFolder).toHaveBeenCalledWith( + 'table-1', + 'workspace-1', + null, + 'request-1', + { notify: false } + ) + }) +}) diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts index ba046391444..c369c6bf7d8 100644 --- a/apps/sim/lib/table/application/bulk.ts +++ b/apps/sim/lib/table/application/bulk.ts @@ -10,7 +10,8 @@ import { foldFolderPlan, planFolderSelection, } from '@/lib/folders/bulk' -import { findActiveFolder } from '@/lib/folders/queries' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { findActiveFolder, resolveFolderPathFromIndex } from '@/lib/folders/queries' import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' import { deleteTable, moveTableToFolder } from '@/lib/table' import { authorizeTableOperation } from '@/lib/table/application/authorization' @@ -29,6 +30,7 @@ import { resolveTableWorkspaceContext, type TableWorkspaceContext, } from '@/lib/table/application/context' +import { resolveTableFolderPath } from '@/lib/table/application/folder-paths' import { tableOperations } from '@/lib/table/application/operations' import { signalTableSchemaChanged } from '@/lib/table/events' import { TableLockedError } from '@/lib/table/mutation-locks' @@ -55,21 +57,56 @@ export interface BulkTableMissing { id: string } -interface BulkTablesContext extends TableWorkspaceContext, BoundedTableSelection {} +interface BulkTablesContext extends TableWorkspaceContext, BoundedTableSelection { + /** + * Projects a folder id back to the canonical path the caller named it by, for + * a path-keyed selection. Absent for an id-keyed one, whose caller already + * speaks ids. + */ + folderPathById?: ReadonlyMap + /** + * Path-keyed entries that resolved to no active folder, carried forward as + * `notFound` rather than failing the batch — the same disposition an id that + * resolves to nothing gets. + */ + unresolvedFolders: string[] + /** Resolves a destination folder reference under the same keying. */ + resolveTargetFolderId: (target: string | null) => string | null | undefined +} -export interface BulkMoveTablesInput { +/** + * How a caller names the folders in a bulk selection. + * + * The internal Tables list holds folder ids, so it addresses them directly. + * The v2 surface addresses folders by canonical PATH everywhere, so it names + * them that way here too and never sees an id. Resolving a path is an + * authorization-sensitive lookup against the workspace's active folder tree, so + * it happens inside the use case's context resolution, never at a route. + * + * Required on every input so a new surface must choose, in the same shape as + * the row surfaces' `dataKeying`. + */ +export type TableFolderKeying = 'ids' | 'paths' + +interface BulkTablesSelectionInput { assertedWorkspaceId: string + /** See {@link TableFolderKeying}. */ + folderKeying: TableFolderKeying tableIds: string[] - folderIds: string[] - targetFolderId: string | null + /** Folder identifiers or canonical folder paths, per {@link folderKeying}. */ + folders: string[] } -export interface BulkDeleteTablesInput { - assertedWorkspaceId: string - tableIds: string[] - folderIds: string[] +export interface BulkMoveTablesInput extends BulkTablesSelectionInput { + /** + * Destination folder identifier or canonical path, per `folderKeying`. + * `null` — and, for a path-keyed caller, `'/'` — is the workspace root. + */ + targetFolder: string | null } +export type BulkDeleteTablesInput = BulkTablesSelectionInput + interface BulkTablesOutcome { skipped: BulkTableItem[] notFound: BulkTableMissing[] @@ -86,19 +123,121 @@ export interface BulkDeleteTablesResult extends BulkTablesOutcome { deletedItems: { tables: number; folders: number } } -interface BulkMoveTablesExecutionResult extends BulkMoveTablesResult, TableBatchExecutionResult {} +interface BulkMoveTablesExecutionResult extends BulkMoveTablesResult, TableBatchExecutionResult { + /** Canonical destination, resolved once in `execute` so audit reads it rather than re-deriving it. */ + targetFolderId: string | null +} interface BulkDeleteTablesExecutionResult extends BulkDeleteTablesResult, TableBatchExecutionResult {} async function resolveBulkTablesContext( - input: { assertedWorkspaceId: string; tableIds: string[]; folderIds: string[] }, + input: BulkTablesSelectionInput, maxItems: number ): Promise { - const selection = requireBoundedTableSelection(input.tableIds, input.folderIds, maxItems) + const selection = requireBoundedTableSelection(input.tableIds, input.folders, maxItems) + const workspace = await resolveTableWorkspaceContext(input.assertedWorkspaceId) + if (input.folderKeying === 'ids') { + return { + ...workspace, + ...selection, + unresolvedFolders: [], + resolveTargetFolderId: (target) => target, + } + } + + /** + * One index for the whole batch. `resolveTableFolderPath` takes the folder + * tree lock per call, so resolving 100 paths through it would be 100 lock + * acquisitions of the same tree; taking it once and resolving from the + * returned index is the same read under one lock. + */ + const resolution = await resolveTableFolderPath(workspace.workspaceId, ROOT_FOLDER_PATH) + if (!resolution) throw new OrchestrationError('not_found', 'Folder not found in this workspace') + + const folderIds: string[] = [] + const unresolvedFolders: string[] = [] + const folderPathById = new Map() + for (const folderPath of selection.folderIds) { + const folderId = resolveFolderPathFromIndex(resolution.index, folderPath) + /** + * `undefined` names no folder; `null` is the workspace root, which is not a + * folder row and cannot be moved or deleted. Both are reported as an entry + * the batch could not resolve rather than failing the whole selection. + */ + if (!folderId) { + unresolvedFolders.push(folderPath) + continue + } + /** + * The selection deduplicates PATHS, but two distinct spellings of the same + * folder resolve to one id — so the batch would carry that id twice while + * `folderPathById` is last-wins, leaving one of the two paths unreportable. + * Deduplicate after resolution, keeping the first path that named the + * folder so the reported spelling matches the first one the caller sent. + */ + if (folderPathById.has(folderId)) continue + folderIds.push(folderId) + folderPathById.set(folderId, folderPath) + } + + return { + ...workspace, + tableIds: selection.tableIds, + folderIds, + unresolvedFolders, + folderPathById, + resolveTargetFolderId: (target) => + target === null ? null : resolveFolderPathFromIndex(resolution.index, target), + } +} + +/** + * Presents one batch item under the caller's own folder keying: a path-keyed + * caller gets back the canonical path it named, never an id it has no way to + * use. + */ +function projectFolderItem(item: BulkTableItem, context: BulkTablesContext): BulkTableItem { + if (item.kind !== 'folder' || !context.folderPathById) return item + const path = context.folderPathById.get(item.id) ?? item.id + return { kind: 'folder', id: path, name: path } +} + +/** + * Folds path-keyed entries that named no active folder into `notFound`, where + * an unresolvable id already lands. + */ +function withUnresolvedFolders( + outcome: T, + context: BulkTablesContext +): T { + if (context.unresolvedFolders.length === 0) return outcome return { - ...(await resolveTableWorkspaceContext(input.assertedWorkspaceId)), - ...selection, + ...outcome, + notFound: [ + ...outcome.notFound, + ...context.unresolvedFolders.map((id) => ({ kind: 'folder' as const, id })), + ], + } +} + +function projectBulkOutcome( + outcome: T, + context: BulkTablesContext +): T { + if (!context.folderPathById) return outcome + return { + ...outcome, + skipped: outcome.skipped.map((item) => projectFolderItem(item, context)), + notFound: outcome.notFound.map((item) => + item.kind === 'folder' + ? { kind: 'folder' as const, id: context.folderPathById?.get(item.id) ?? item.id } + : item + ), + failed: outcome.failed.map((item) => ({ + ...projectFolderItem(item, context), + reason: item.reason, + })), } } @@ -208,6 +347,11 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: BulkMoveTablesInput }) => resolveBulkTablesContext(input, BULK_MOVE_TABLES_COST_POLICY.maxItems), async execute({ principal, input, context }): Promise { + const targetFolderId = context.resolveTargetFolderId(input.targetFolder) + if (targetFolderId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + /** * The destination check and the folder plan read different rows and share * no data, so they overlap rather than serialize. Both still complete @@ -215,7 +359,7 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ * rather than leave half the selection moved. */ const [, plan] = await Promise.all([ - requireTableFolder(context.workspaceId, input.targetFolderId), + requireTableFolder(context.workspaceId, targetFolderId), planFolderSelection(context.workspaceId, TABLE_FOLDER_RESOURCE_TYPE, context.folderIds), ]) @@ -232,7 +376,7 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ * created. Losing that race costs a reported per-folder `failed` alongside resources that * did move, which is the batch's documented `sequential_best_effort` outcome, not corruption. */ - if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { + if (targetFolderId !== null && plan.covered.has(targetFolderId)) { throw new OrchestrationError( 'validation', 'Cannot move a folder into itself or one of its own subfolders' @@ -254,7 +398,7 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ await moveTableToFolder( canonical.table.id, context.workspaceId, - input.targetFolderId, + targetFolderId, generateRequestId(), { notify: false } ) @@ -271,7 +415,7 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId, folders: plan.selected, - targetParentId: input.targetFolderId, + targetParentId: targetFolderId, }) for (const folder of folders.succeeded) moved.push({ kind: 'folder', ...folder }) for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) @@ -285,15 +429,16 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ failed: outcome.failed.length, }) return { - moved, - ...outcome, + moved: moved.map((item) => projectFolderItem(item, context)), + targetFolderId, + ...withUnresolvedFolders(projectBulkOutcome(outcome, context), context), ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), } } finally { await notifyBatchedTableChanges(context.workspaceId, moved) } }, - projectAudit: ({ input, result }) => + projectAudit: ({ result }) => result.moved.map((item) => item.kind === 'folder' ? { @@ -302,12 +447,12 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ resourceId: item.id, resourceName: item.name, description: - input.targetFolderId === null + result.targetFolderId === null ? `Moved table folder "${item.name}" to the workspace root` : `Moved table folder "${item.name}" into another folder`, metadata: { folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, - parentId: input.targetFolderId, + parentId: result.targetFolderId, bulk: true, }, } @@ -317,10 +462,10 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ resourceId: item.id, resourceName: item.name, description: - input.targetFolderId === null + result.targetFolderId === null ? `Moved table "${item.name}" to the workspace root` : `Moved table "${item.name}" into a folder`, - metadata: { op: 'move', folderId: input.targetFolderId, bulk: true }, + metadata: { op: 'move', folderId: result.targetFolderId, bulk: true }, } ), afterSuccess: ({ result }) => { @@ -393,9 +538,9 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({ deletedItems, }) return { - deleted, + deleted: deleted.map((item) => projectFolderItem(item, context)), deletedItems, - ...outcome, + ...withUnresolvedFolders(projectBulkOutcome(outcome, context), context), ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), } } finally { diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts index 9989379e873..0ed0c14b2ae 100644 --- a/apps/sim/lib/table/application/context.test.ts +++ b/apps/sim/lib/table/application/context.test.ts @@ -14,7 +14,10 @@ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: loadWorkspace, })) -import { resolveActiveTableContext } from '@/lib/table/application/context' +import { + resolveActiveTableContext, + resolveArchivedTableContext, +} from '@/lib/table/application/context' const WORKSPACE_ONE = { workspaceId: 'workspace-1', @@ -223,3 +226,54 @@ describe('table application context', () => { expect(unhandled).toEqual([]) }) }) + +/** + * Restore is the one table operation whose subject is deliberately archived, so + * it needs a resolver the active one cannot provide — while keeping the same + * cross-workspace concealment. + */ +describe('resolveArchivedTableContext', () => { + beforeEach(() => { + vi.clearAllMocks() + loadWorkspace.mockResolvedValue(WORKSPACE_ONE) + }) + + it('loads a table the active resolver would report as missing', async () => { + const archived = { + id: 'table-1', + workspaceId: 'workspace-1', + archivedAt: new Date('2026-01-01'), + } + getTableById.mockResolvedValue(archived) + + const context = await resolveArchivedTableContext({ + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + }) + + expect(getTableById).toHaveBeenCalledWith('table-1', { includeArchived: true }) + expect(context.table).toBe(archived) + expect(context.workspaceId).toBe('workspace-1') + }) + + it('conceals an archived table in another workspace as not found', async () => { + getTableById.mockResolvedValue({ + id: 'table-1', + workspaceId: 'workspace-2', + archivedAt: new Date('2026-01-01'), + }) + + await expect( + resolveArchivedTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-1' }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(loadWorkspace).not.toHaveBeenCalled() + }) + + it('reports a table id that resolves to nothing as not found', async () => { + getTableById.mockResolvedValue(null) + + await expect( + resolveArchivedTableContext({ tableId: 'ghost', assertedWorkspaceId: 'workspace-1' }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) +}) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts index 3fb92664679..532d2a20d8f 100644 --- a/apps/sim/lib/table/application/context.ts +++ b/apps/sim/lib/table/application/context.ts @@ -95,3 +95,27 @@ export async function resolveActiveTableInWorkspace( const table = await requireTable(tableId, workspaceContext.workspaceId) return { ...workspaceContext, tableId: table.id, table } } + +/** + * Loads the canonical context an archived-table use case authorizes against. + * + * Restore is the one table operation whose subject is deliberately NOT active, + * so it cannot go through {@link resolveActiveTableContext} — that resolver's + * `getTableById` skips archived rows and would report every restorable table as + * missing. The asserted-workspace comparison and its not-found concealment are + * identical. + */ +export async function resolveArchivedTableContext(input: { + tableId: string + assertedWorkspaceId?: string +}): Promise { + const table = await getTableById(input.tableId, { includeArchived: true }) + if ( + !table || + (input.assertedWorkspaceId !== undefined && table.workspaceId !== input.assertedWorkspaceId) + ) { + throw new OrchestrationError('not_found', 'Table not found') + } + const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId) + return { ...workspaceContext, tableId: table.id, table } +} diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.test.ts b/apps/sim/lib/table/application/copilot-bulk-rows.test.ts index 031893dcff6..ae9e5258cf8 100644 --- a/apps/sim/lib/table/application/copilot-bulk-rows.test.ts +++ b/apps/sim/lib/table/application/copilot-bulk-rows.test.ts @@ -7,7 +7,6 @@ import type { TableDefinition } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ audit: vi.fn(), - batchUpdate: vi.fn(), deleteByFilter: vi.fn(), markJob: vi.fn(), releaseJob: vi.fn(), @@ -42,8 +41,6 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) vi.mock('@/lib/core/utils/background', () => ({ runDetached: vi.fn() })) vi.mock('@/lib/table', () => ({ - batchUpdateRows: mocks.batchUpdate, - CSV_MAX_BATCH_SIZE: 1000, deleteRowsByFilter: mocks.deleteByFilter, queryRows: vi.fn(), rowDataNameToId: (data: Record) => data, @@ -83,7 +80,6 @@ vi.mock('@/lib/table/update-runner', () => ({ })) import { - copilotBatchUpdateRows, copilotDeleteRowsByFilter, copilotUpdateRowsByFilter, } from '@/lib/table/application/copilot-bulk-rows' @@ -138,7 +134,6 @@ describe('Copilot bulk row application use cases', () => { mocks.translateFilter.mockReturnValue({}) mocks.updateByFilter.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) mocks.deleteByFilter.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) - mocks.batchUpdate.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) mocks.markJob.mockResolvedValue(true) mocks.releaseJob.mockResolvedValue(true) }) @@ -210,27 +205,6 @@ describe('Copilot bulk row application use cases', () => { expect(mocks.audit).toHaveBeenCalledTimes(1) }) - it('runs batch mutation behavior behind the same delegated boundary', async () => { - await copilotBatchUpdateRows.execute({ - principal, - input: { - tableId: 'table-1', - assertedWorkspaceId: 'workspace-1', - updates: [{ rowId: 'row-1', data: { name: 'Grace' } }], - }, - }) - - expect(mocks.batchUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - tableId: 'table-1', - workspaceId: 'workspace-1', - actorUserId: 'user-1', - }), - table, - 'job-1234' - ) - }) - it('propagates unknown infrastructure failures without audit or effects', async () => { const failure = new Error('database host unavailable') mocks.updateByFilter.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.ts b/apps/sim/lib/table/application/copilot-bulk-rows.ts index cd4393fe5ef..76c55db7350 100644 --- a/apps/sim/lib/table/application/copilot-bulk-rows.ts +++ b/apps/sim/lib/table/application/copilot-bulk-rows.ts @@ -7,8 +7,6 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { - batchUpdateRows, - CSV_MAX_BATCH_SIZE, deleteRowsByFilter, type Filter, queryRows, @@ -61,15 +59,6 @@ export type CopilotDeleteRowsByFilterResult = | { kind: 'inline'; affectedCount: number; affectedRowIds: string[] } | { kind: 'background'; doomedCount: number; jobId: string; bounded: boolean } -export interface CopilotBatchUpdateRowsInput extends CopilotBulkRowsInput { - updates: Array<{ rowId: string; data: RowData }> -} - -export interface CopilotBatchUpdateRowsResult { - affectedCount: number - affectedRowIds: string[] -} - function requestId(): string { return generateId().slice(0, 8) } @@ -371,54 +360,3 @@ export const copilotDeleteRowsByFilter = defineAuthorizedTableUseCase({ } }, }) - -export const copilotBatchUpdateRows = defineAuthorizedTableUseCase({ - operation: tableOperations.updateRows, - resolveContext: ({ input }: { input: CopilotBatchUpdateRowsInput }) => - resolveActiveTableContext(input), - async execute({ principal, input, context }): Promise { - if (input.updates.length < 1 || input.updates.length > CSV_MAX_BATCH_SIZE) { - throw new OrchestrationError( - 'validation', - `Batch update count must be between 1 and ${CSV_MAX_BATCH_SIZE}` - ) - } - const idByName = buildIdByName(context.table.schema) - const updates = input.updates.map((update) => ({ - rowId: update.rowId, - data: rowDataNameToId(update.data, idByName), - })) - return batchUpdateRows( - { - tableId: context.tableId, - updates, - workspaceId: context.workspaceId, - actorUserId: resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }).attributedUserId, - secretProvenanceByRowId: Object.fromEntries( - updates.map((update) => [ - update.rowId, - createExactEmptyTableRowSecretProvenance(update.data), - ]) - ), - }, - context.table, - requestId() - ) - }, - projectAudit({ context, result }) { - if (result.affectedCount === 0) return [] - return { - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: context.tableId, - resourceName: context.table.name, - description: `Updated ${result.affectedCount} row(s) in table "${context.table.name}"`, - metadata: { op: 'batch_update', rowsUpdated: result.affectedCount }, - } - }, - afterSuccess({ context, result }) { - if (result.affectedCount > 0) signalTableRowsChanged(context.tableId) - }, -}) diff --git a/apps/sim/lib/table/application/exports.ts b/apps/sim/lib/table/application/exports.ts index 83414144bce..01a86f5e1fd 100644 --- a/apps/sim/lib/table/application/exports.ts +++ b/apps/sim/lib/table/application/exports.ts @@ -30,6 +30,14 @@ export interface CreateTableExportInput { export interface TableExportResourceInput { exportId: string workspaceId: string + /** + * The table the caller addressed the export under. v2 nests the export reads beneath their + * parent table, so the id in the path is asserted against the export's stored `tableId` and + * a mismatch reports the same not-found an unknown id does — an export id cannot be used to + * probe which table it belongs to. The internal surface addresses exports by id alone and + * leaves this undefined. + */ + tableId?: string } export interface TableExportResult { @@ -52,6 +60,9 @@ async function resolveTableExportContext( input: TableExportResourceInput ): Promise { const record = await requireTableExport(input.exportId, input.workspaceId) + if (input.tableId !== undefined && input.tableId !== record.tableId) { + throw new OrchestrationError('not_found', 'Table export not found') + } const table = await getTableById(record.tableId) if (!table || table.workspaceId !== record.workspaceId) { throw new OrchestrationError('not_found', 'Table export not found') diff --git a/apps/sim/lib/table/application/folders.ts b/apps/sim/lib/table/application/folders.ts index dbf9c825d32..5bff78498fd 100644 --- a/apps/sim/lib/table/application/folders.ts +++ b/apps/sim/lib/table/application/folders.ts @@ -7,9 +7,11 @@ import { createFolderAtPathTransition, deleteFolderByPathTransition, relocateFolderByPathTransition, + restoreFolder, } from '@/lib/folders/orchestration' import { type FolderSortBy, + findArchivedFolderIdByPath, listActiveFolderRows, loadActiveFolderPathIndex, resolveFolderPathFromIndex, @@ -188,3 +190,79 @@ export const deleteTableFolderUseCase = defineAuthorizedTableUseCase({ } }, }) + +export interface RestoreTableFolderInput { + workspaceId: string + path: string +} + +/** + * Restores a soft-deleted table folder tree. + * + * `DELETE /api/v2/tables/folders` archives recursively, so without this a recursive delete + * was unrecoverable over the API: the archived tables stayed visible through + * `GET /api/v2/tables?scope=archived`, but nothing could put the folder structure back. + * + * The folder is addressed by the path it held when it was deleted. The restore itself may + * land it somewhere else — a folder whose parent is still archived is re-rooted, and a name + * an active sibling has taken meanwhile is deduplicated — so the response reports the + * folder's ACTUAL post-restore path rather than echoing the request. + */ +export const restoreTableFolderUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.restoreFolder, + resolveContext: ({ input }: { input: RestoreTableFolderInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const folderId = await findArchivedFolderIdByPath(context.workspaceId, 'table', input.path, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + if (!folderId) throw new OrchestrationError('not_found', 'Folder not found') + + const result = await restoreFolder( + { + resourceType: 'table', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + folderId, + }, + { projectAudit: false } + ) + if (!result.success || !result.restoredItems) { + throwTableOperationFailure(result, 'Failed to restore folder') + } + + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const folder = index.rowById.get(folderId) + if (!folder) { + throw new OrchestrationError('internal', 'Restored folder is missing from the folder tree') + } + return { + folder, + index, + requestedPath: input.path, + restoredItems: { + folders: result.restoredItems.folders, + tables: result.restoredItems.tables ?? 0, + }, + } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Restored table folder "${result.requestedPath}"`, + metadata: { + folderResourceType: 'table', + path: result.requestedPath, + restoredItems: result.restoredItems, + }, + } + }, +}) diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts index 269719d8655..29d9cde0dba 100644 --- a/apps/sim/lib/table/application/operations.test.ts +++ b/apps/sim/lib/table/application/operations.test.ts @@ -51,6 +51,10 @@ describe('table operation registry', () => { expect(tableOperations.createExport.minimumRole).toBe('read') expect(tableOperations.cancelExport.minimumRole).toBe('read') + + /** Reading the state of a run you started is a read; un-archiving is a write. */ + expect(tableOperations.readRun.minimumRole).toBe('read') + expect(tableOperations.restore.minimumRole).toBe('write') }) it('admits executor delegation only for the intentional internal route operations', () => { diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index dcd5879f74e..cb0e9b55025 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -89,6 +89,7 @@ export const tableOperations = { create: writeOperation('tables.create'), update: writeOperation('tables.update'), delete: writeOperation('tables.delete'), + restore: writeOperation('tables.restore'), bulkMove: writeOperation('tables.bulk_move'), bulkDelete: writeOperation('tables.bulk_delete'), renameByVfsPath: defineWorkspaceOperation({ @@ -113,12 +114,13 @@ export const tableOperations = { createFolder: writeOperation('tables.folders.create'), updateFolder: writeOperation('tables.folders.update'), deleteFolder: writeOperation('tables.folders.delete'), + restoreFolder: writeOperation('tables.folders.restore'), addColumn: writeOperation('tables.columns.add'), updateColumn: writeOperation('tables.columns.update'), deleteColumn: writeOperation('tables.columns.delete'), listRows: readOperation('tables.rows.list'), queryRows: readOperation('tables.rows.query'), - findRows: readOperation('tables.rows.find'), + searchRows: readOperation('tables.rows.search'), readRow: toolReadOperation('tables.rows.read'), createRows: writeOperation('tables.rows.create'), replaceRows: writeOperation('tables.rows.replace'), @@ -137,6 +139,8 @@ export const tableOperations = { updateGroup: toolWriteOperation('tables.groups.update'), deleteGroup: toolWriteOperation('tables.groups.delete'), startRun: writeOperation('tables.runs.start'), + /** Reading the state of a run — including one you started — is a read. */ + readRun: readOperation('tables.runs.read'), cancelRuns: writeOperation('tables.runs.cancel'), createImport: internalExecutorWriteOperation('tables.imports.create'), createFromWorkspaceFile: delegatedWriteOperation('tables.imports.create_from_workspace_file'), diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index 7c017cba175..f6310083ea7 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -28,6 +28,9 @@ const { mockUpdateRowsByFilter, mockValidateRowData, mockValidateBatchRows, + mockBatchUpdateRows, + mockGetRowSummaryById, + mockLoadExecutionsForRow, } = vi.hoisted(() => ({ mockReplaceRowsPrimitive: vi.fn(), mockDeleteRowsByIds: vi.fn(), @@ -51,6 +54,9 @@ const { mockUpdateRowsByFilter: vi.fn(), mockValidateRowData: vi.fn(), mockValidateBatchRows: vi.fn(), + mockBatchUpdateRows: vi.fn(), + mockGetRowSummaryById: vi.fn(), + mockLoadExecutionsForRow: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -74,13 +80,16 @@ vi.mock('@/lib/table', () => ({ MAX_BATCH_INSERT_SIZE: 1000, MAX_BULK_OPERATION_SIZE: 1000, MAX_QUERY_LIMIT: 1000, + MAX_ROW_RUN_STATE_BYTES: 256, }, batchInsertRows: mockBatchInsertRows, + batchUpdateRows: mockBatchUpdateRows, deleteRow: vi.fn(), deleteRowsByFilter: vi.fn(), deleteRowsByIds: mockDeleteRowsByIds, findRowMatches: vi.fn(), getRowById: vi.fn(), + getRowSummaryById: mockGetRowSummaryById, insertRow: mockInsertRow, queryRows: mockQueryRows, replaceTableRows: mockReplaceRowsPrimitive, @@ -137,17 +146,29 @@ vi.mock('@/lib/table/application/context', () => ({ resolveActiveTableContext: mockResolveContext, })) +vi.mock('@/lib/table/import', () => ({ + CSV_MAX_BATCH_SIZE: 5000, +})) + +vi.mock('@/lib/table/rows/executions', () => ({ + loadEnrichmentDetail: vi.fn(), + loadExecutionsForRow: mockLoadExecutionsForRow, +})) + vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged, signalTableRowsChangedByActor: mockSignalRowsChangedByActor, })) +import { TABLE_LIMITS } from '@/lib/table' import { + batchUpdateTableRows, createTableRows, deleteTableRows, listTableRows, ProjectedWireRowsValidationError, queryTableRows, + readTableRow, replaceProjectedWireRows, replaceTableRows, TableRowsValidationError, @@ -156,6 +177,7 @@ import { updateTableRows, upsertTableRow, } from '@/lib/table/application/rows' +import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { encodeCursor } from '@/lib/table/rows/cursor' const TABLE: TableDefinition = { @@ -637,6 +659,7 @@ describe('row query and upsert application semantics', () => { offset: undefined, includeTotal: false, withExecutions: false, + runStateBudgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES, }, expect.any(String) ) @@ -1286,3 +1309,293 @@ describe('row data keying', () => { ).rejects.toThrow(/Row 2: Unknown columns: zzz, qqq/) }) }) + +/** + * Per-cell run state is opt-in. These pin both halves of that: the default read + * is byte-identical to what shipped, and the flag changes only the projection — + * never which rows come back or in what order. + */ +describe('opt-in per-cell run state', () => { + const RUN_STATE = { + 'group-1': { + status: 'error' as const, + executionId: 'execution-1', + jobId: 'job-1', + workflowId: 'workflow-1', + error: 'boom', + }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('read') + mockResolveContext.mockResolvedValue(contextFor()) + mockQueryRows.mockResolvedValue({ + rows: [{ id: 'row-1', data: {}, executions: {} }], + rowCount: 1, + totalCount: null, + nextCursor: null, + }) + }) + + it('does not read the sidecar for a list that did not ask for it', async () => { + await listTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, limit: 25 }, + }) + + expect(mockQueryRows).toHaveBeenCalledWith( + TABLE, + expect.objectContaining({ withExecutions: false }), + expect.any(String) + ) + }) + + it('reads the sidecar for a list that asked for it', async () => { + await listTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, limit: 25, includeRunState: true }, + }) + + expect(mockQueryRows).toHaveBeenCalledWith( + TABLE, + expect.objectContaining({ withExecutions: true }), + expect.any(String) + ) + }) + + it('carries the flag through the predicate query the same way', async () => { + await queryTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, limit: 25, includeRunState: true }, + }) + + expect(mockQueryRows).toHaveBeenCalledWith( + TABLE, + expect.objectContaining({ withExecutions: true }), + expect.any(String) + ) + }) + + it('leaves the single-row read without run state by default', async () => { + mockGetRowSummaryById.mockResolvedValue({ id: 'row-1', data: {} }) + + const result = await readTableRow.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rowId: 'row-1' }, + }) + + expect(mockLoadExecutionsForRow).not.toHaveBeenCalled() + expect(result.runState).toBeUndefined() + }) + + it('attaches run state to the single-row read on request', async () => { + mockGetRowSummaryById.mockResolvedValue({ id: 'row-1', data: {} }) + mockLoadExecutionsForRow.mockResolvedValue(RUN_STATE) + + const result = await readTableRow.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rowId: 'row-1', includeRunState: true }, + }) + + expect(result.runState).toEqual(RUN_STATE) + }) + + /** + * `blockErrors` is unbounded jsonb, so a row carrying one has no ceiling of + * its own. The budget travels INTO the drain rather than being measured over + * its result: a ceiling checked after materialization can only report a heap + * spike that has already happened. + */ + it('hands the single-row sidecar read the published byte budget', async () => { + mockGetRowSummaryById.mockResolvedValue({ id: 'row-1', data: {} }) + mockLoadExecutionsForRow.mockResolvedValue(RUN_STATE) + + await readTableRow.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rowId: 'row-1', includeRunState: true }, + }) + + expect(mockLoadExecutionsForRow).toHaveBeenCalledWith(expect.anything(), 'row-1', { + budgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES, + }) + }) + + it('propagates the sidecar refusal rather than answering a short page', async () => { + const refusal = Object.assign(new Error('too large'), { code: 'payload_too_large' }) + mockQueryRows.mockRejectedValueOnce(refusal) + + await expect( + listTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, limit: 10, includeRunState: true }, + }) + ).rejects.toBe(refusal) + }) +}) + +/** + * The heterogeneous batch update, which Copilot's batch tool and the public + * `POST /rows/bulk-update` now share. + */ +describe('batchUpdateTableRows application use case', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('write') + mockResolveContext.mockResolvedValue(contextFor()) + mockBatchUpdateRows.mockResolvedValue({ affectedCount: 2, affectedRowIds: ['row-1', 'row-2'] }) + }) + + it('translates every name-keyed patch to storage ids under canonical scope', async () => { + const result = await batchUpdateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + strictWrite: true, + dataKeying: 'names', + updates: [ + { rowId: 'row-1', data: { name: 'Ada' } }, + { rowId: 'row-2', data: { name: 'Grace' } }, + ], + }, + }) + + expect(mockBatchUpdateRows).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + actorUserId: 'user-1', + updates: [ + { rowId: 'row-1', data: { 'column-name': 'Ada' } }, + { rowId: 'row-2', data: { 'column-name': 'Grace' } }, + ], + }), + TABLE, + expect.any(String) + ) + expect(result.affectedCount).toBe(2) + }) + + it('refuses a strict patch naming a column the table does not have', async () => { + await expect( + batchUpdateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + strictWrite: true, + dataKeying: 'names', + updates: [{ rowId: 'row-1', data: { nope: 1 } }], + }, + }) + ).rejects.toBeInstanceOf(TableRowsValidationError) + expect(mockBatchUpdateRows).not.toHaveBeenCalled() + }) + + it('drops the same key for a first-party caller instead of refusing', async () => { + await batchUpdateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + strictWrite: false, + dataKeying: 'names', + updates: [{ rowId: 'row-1', data: { nope: 1 } }], + }, + }) + + expect(mockBatchUpdateRows).toHaveBeenCalledWith( + expect.objectContaining({ updates: [{ rowId: 'row-1', data: {} }] }), + TABLE, + expect.any(String) + ) + }) + + it('rejects an empty batch before touching storage', async () => { + await expect( + batchUpdateTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, strictWrite: true, dataKeying: 'names', updates: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mockBatchUpdateRows).not.toHaveBeenCalled() + }) + + const batchOf = (length: number) => + Array.from({ length }, (_, index) => ({ rowId: `row-${index}`, data: { name: 'Ada' } })) + + /** + * The two surfaces cap differently on purpose — the contracts at 1000, the + * Copilot tool at 5000 — so the shared backstop sits at the LOOSER ceiling. + * Tightening it to the contract's number would make batches Copilot accepts + * today start failing here, which is the behavior change this pins against. + */ + it('admits a batch past the contract ceiling that the looser surface allows', async () => { + mockBatchUpdateRows.mockResolvedValue({ affectedCount: 1001, affectedRowIds: [] }) + + await batchUpdateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + strictWrite: false, + dataKeying: 'names', + updates: batchOf(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + 1), + }, + }) + + expect(mockBatchUpdateRows).toHaveBeenCalled() + }) + + it('refuses past the backstop, naming the bound that actually applied', async () => { + await expect( + batchUpdateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + strictWrite: true, + dataKeying: 'names', + updates: batchOf(CSV_MAX_BATCH_SIZE + 1), + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: `Batch update count must be between 1 and ${CSV_MAX_BATCH_SIZE}`, + }) + expect(mockBatchUpdateRows).not.toHaveBeenCalled() + }) + + it('audits and signals only the authoritative affected count', async () => { + mockBatchUpdateRows.mockResolvedValue({ affectedCount: 0, affectedRowIds: [] }) + + await batchUpdateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + strictWrite: true, + dataKeying: 'names', + updates: [{ rowId: 'row-1', data: { name: 'Ada' } }], + }, + }) + + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('propagates the missing-row refusal without audit or shared effects', async () => { + const failure = Object.assign(new Error('Rows not found: row-9'), { code: 'validation' }) + mockBatchUpdateRows.mockRejectedValueOnce(failure) + + await expect( + batchUpdateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + strictWrite: true, + dataKeying: 'names', + updates: [{ rowId: 'row-9', data: { name: 'Ada' } }], + }, + }) + ).rejects.toBe(failure) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 3b5301bb0ba..bbe91304777 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -17,6 +17,7 @@ import type { Filter, ReplaceRowsResult, RowData, + RowExecutions, Sort, SortSpec, TableDefinition, @@ -26,6 +27,7 @@ import type { } from '@/lib/table' import { batchInsertRows, + batchUpdateRows, deleteRow, deleteRowsByFilter, deleteRowsByIds, @@ -57,6 +59,7 @@ import { buildColumnNameById, buildIdByName, unknownColumnNames } from '@/lib/ta import { columnTypeOf } from '@/lib/table/column-types' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged, signalTableRowsChangedByActor } from '@/lib/table/events' +import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { validatePredicate, @@ -65,7 +68,7 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { assertCursorQueryBinding, decodeCursor } from '@/lib/table/rows/cursor' -import { loadEnrichmentDetail } from '@/lib/table/rows/executions' +import { loadEnrichmentDetail, loadExecutionsForRow } from '@/lib/table/rows/executions' import { createExactEmptyTableRowSecretProvenance, createTableRowSecretProvenanceFromRegistry, @@ -94,6 +97,20 @@ interface TableScopedInput { requestId?: string } +/** + * Opt-in on every read that returns whole rows. + * + * The projection stays byte-identical by default: the sidecar is a second query + * and its `blockErrors` are unbounded, so a shipped caller must never start + * paying for it. When a caller does opt in, the sidecar drain accumulates its + * own byte budget and refuses past `TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES` — the + * ceiling is spent inside the read rather than measured after it, so an + * over-budget page never gets materialized in the first place. + */ +interface RunStateReadInput { + includeRunState?: boolean +} + /** * The write policy `strictWrite` selects, for the row-service primitives. * @@ -362,7 +379,7 @@ function rethrowQueryValidation(error: unknown): never { throw error } -export interface ListTableRowsInput extends TableScopedInput { +export interface ListTableRowsInput extends TableScopedInput, RunStateReadInput { limit: number cursor?: string } @@ -387,7 +404,8 @@ export const listTableRows = defineAuthorizedTableUseCase({ after: cursor?.after, offset: cursor?.offset, includeTotal: false, - withExecutions: false, + withExecutions: input.includeRunState ?? false, + runStateBudgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES, }, requestId(input) ) @@ -402,7 +420,7 @@ export const listTableRows = defineAuthorizedTableUseCase({ }, }) -export interface QueryTableRowsInput extends TableScopedInput { +export interface QueryTableRowsInput extends TableScopedInput, RunStateReadInput { predicate?: TablePredicate sort?: SortSpec limit?: number @@ -451,7 +469,8 @@ export const queryTableRows = defineAuthorizedTableUseCase({ after: cursor?.after, offset: cursor?.offset, includeTotal: input.includeTotal ?? false, - withExecutions: false, + withExecutions: input.includeRunState ?? false, + runStateBudgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES, }, requestId(input) ) @@ -471,21 +490,21 @@ export const queryTableRows = defineAuthorizedTableUseCase({ }, }) -export interface FindTableRowsInput extends TableScopedInput { +export interface SearchTableRowsInput extends TableScopedInput { q: string predicate?: TablePredicate sort?: SortSpec } -export interface FindTableRowsResult extends TableResult { +export interface SearchTableRowsResult extends TableResult { matches: FindRowMatch[] truncated: boolean } -export const findTableRows = defineAuthorizedTableUseCase({ - operation: tableOperations.findRows, - resolveContext: ({ input }: { input: FindTableRowsInput }) => resolveActiveTableContext(input), - async execute({ input, context }): Promise { +export const searchTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.searchRows, + resolveContext: ({ input }: { input: SearchTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise { try { if (input.q.length === 0) { throw new TableRowsValidationError('q must be a non-empty search string') @@ -511,14 +530,16 @@ export const findTableRows = defineAuthorizedTableUseCase({ }, }) -export interface ReadTableRowInput extends TableScopedInput { +export interface ReadTableRowInput extends TableScopedInput, RunStateReadInput { rowId: string includePersistedSecretProvenance?: boolean } export interface ReadTableRowResult extends TableResult { - /** Without the executions sidecar — no read surface puts it on the wire. */ + /** The stored row without its sidecars; run state travels separately below. */ row: TableRowSummary + /** Per-group run state, present only when the read asked for it. */ + runState?: RowExecutions secretProvenance?: TableRowsProvenance } @@ -528,9 +549,15 @@ export const readTableRow = defineAuthorizedTableUseCase({ async execute({ principal, input, context }): Promise { const row = await getRowSummaryById(context.tableId, input.rowId, context.workspaceId) if (!row) throw new OrchestrationError('not_found', 'Row not found') + const runState = input.includeRunState + ? await loadExecutionsForRow(db, input.rowId, { + budgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES, + }) + : undefined return { table: context.table, row, + ...(runState ? { runState } : {}), secretProvenance: await loadAuthorizedRowsProvenance( principal, context.workspaceId, @@ -1037,6 +1064,99 @@ export const updateTableRows = defineAuthorizedTableUseCase({ }, }) +export interface BatchUpdateTableRowsInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean + /** See {@link TableRowDataKeying}. Required so a new write surface must choose. */ + dataKeying: TableRowDataKeying + /** One merge patch per row. A row identifier may appear at most once. */ + updates: readonly { rowId: string; data: RowData }[] +} + +export interface BatchUpdateTableRowsResult extends TableResult, BulkOperationResult {} + +/** + * Heterogeneous batch row update: a distinct merge patch per row, committed as + * one authorized operation. + * + * The sibling {@link updateTableRows} applies ONE patch to every row a + * predicate matches, so N different writes are N requests through it. This is + * the surface-neutral home of the behavior Copilot's batch tool and the public + * `POST /rows/bulk-update` both need: identical business semantics, so one + * semantic operation ({@link tableOperations.updateRows}) and one use case. + * + * Membership is atomic. `batchUpdateRows` refuses the whole batch when a + * `rowId` names no row in the table, which reaches the wire as a `400` listing + * the missing ids — a caller that sent explicit identifiers is better served by + * a refusal it can retry than by a partial commit it has to reconcile. + * + * The upper `CSV_MAX_BATCH_SIZE` bound is a BACKSTOP NO CURRENT SURFACE + * REACHES, and is set to the loosest surface's ceiling on purpose so it can + * never contradict one. Every caller is stopped earlier, by its own ceiling: + * the internal and v2 contracts cap `updates` at + * `TABLE_LIMITS.MAX_BULK_OPERATION_SIZE` (1000) and answer a `400` naming that + * number, and the Copilot tool — which parses no contract — refuses past + * `CSV_MAX_BATCH_SIZE` (5000) with a message the model can act on. The two + * surfaces legitimately differ; what matters is that each caller sees the bound + * that actually applies to it. This one exists for a future caller that arrives + * with neither guard, so do not tighten it to one surface's number — that would + * make the other surface's accepted batches start failing here. + */ +export const batchUpdateTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRows, + resolveContext: ({ input }: { input: BatchUpdateTableRowsInput }) => + resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + if (input.updates.length < 1 || input.updates.length > CSV_MAX_BATCH_SIZE) { + throw new OrchestrationError( + 'validation', + `Batch update count must be between 1 and ${CSV_MAX_BATCH_SIZE}` + ) + } + const storageData = rowsToStorage( + input.updates.map((update) => update.data), + context.table, + input.dataKeying, + input.strictWrite + ) + const updates = input.updates.map((update, index) => ({ + rowId: update.rowId, + data: storageData[index], + })) + const result = await batchUpdateRows( + { + tableId: context.tableId, + updates, + workspaceId: context.workspaceId, + actorUserId: actorUserId(principal, context.billedAccountUserId), + secretProvenanceByRowId: Object.fromEntries( + updates.map((update) => [ + update.rowId, + createExactEmptyTableRowSecretProvenance(update.data), + ]) + ), + }, + context.table, + requestId(input) + ) + return { table: context.table, ...result } + }, + projectAudit({ context, result }) { + if (result.affectedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.tableId, + resourceName: context.table.name, + description: `Updated ${result.affectedCount} row(s) in table "${context.table.name}"`, + metadata: { op: 'batch_update', rowsUpdated: result.affectedCount }, + } + }, + afterSuccess({ context, result }) { + if (result.affectedCount > 0) signalTableRowsChanged(context.tableId) + }, +}) + export interface DeleteTableRowInput extends TableScopedInput { rowId: string /** See {@link UpdateTableRowInput.actorClientId}. */ diff --git a/apps/sim/lib/table/application/runs.test.ts b/apps/sim/lib/table/application/runs.test.ts index 909afff9c88..93a1675e4ef 100644 --- a/apps/sim/lib/table/application/runs.test.ts +++ b/apps/sim/lib/table/application/runs.test.ts @@ -14,6 +14,11 @@ const { mockRunWorkflowColumn, mockSignalRowsChanged, mockTranslatePredicate, + mockGetTableById, + mockReadDispatch, + mockListActiveDispatches, + mockCancelDispatchById, + mockResolveWorkspaceContext, } = vi.hoisted(() => ({ mockCancelRuns: vi.fn(), mockGetRowById: vi.fn(), @@ -23,6 +28,11 @@ const { mockRunWorkflowColumn: vi.fn(), mockSignalRowsChanged: vi.fn(), mockTranslatePredicate: vi.fn(), + mockGetTableById: vi.fn(), + mockReadDispatch: vi.fn(), + mockListActiveDispatches: vi.fn(), + mockCancelDispatchById: vi.fn(), + mockResolveWorkspaceContext: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -38,12 +48,20 @@ vi.mock('@sim/platform-authz/workspace', () => ({ vi.mock('@/lib/table', () => ({ DEFAULT_TABLE_PLAN_LIMITS: { enterprise: { maxRowsPerTable: 2 } }, getRowById: mockGetRowById, + getTableById: mockGetTableById, requireTableRowIds: mockRequireTableRowIds, TABLE_LIMITS: { MAX_COLUMNS_PER_TABLE: 2 }, })) vi.mock('@/lib/table/application/context', () => ({ resolveActiveTableContext: mockResolveContext, + resolveTableWorkspaceContext: mockResolveWorkspaceContext, +})) + +vi.mock('@/lib/table/dispatcher', () => ({ + cancelDispatchById: mockCancelDispatchById, + listActiveDispatches: mockListActiveDispatches, + readDispatch: mockReadDispatch, })) vi.mock('@/lib/table/application/rows', () => ({ @@ -59,7 +77,13 @@ vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn, })) -import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' +import { + cancelTableDispatch, + cancelTableRuns, + listTableDispatches, + readTableDispatch, + startTableRun, +} from '@/lib/table/application/runs' const TABLE: TableDefinition = { id: 'table-1', @@ -270,3 +294,170 @@ describe('table run application use cases', () => { expect(mockSignalRowsChanged).not.toHaveBeenCalled() }) }) + +/** + * The dispatch resource `POST /tables/{tableId}/dispatches` hands back an id for. + * + * The regression these guard is the published status set: the first-party + * active-dispatch schema knows only `pending` and `dispatching`, so a resource + * read built on it would turn polling a finished run — the exact thing a poller + * is waiting for — into a 500. + */ +describe('table run dispatch reads', () => { + const DISPATCH = { + id: 'dispatch-1', + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + requestId: 'request-1', + mode: 'all' as const, + scope: { groupIds: ['group-1'] }, + status: 'dispatching' as const, + cursor: 0, + limit: null, + processedCount: 0, + isManualRun: true, + triggeredByUserId: 'user-1', + requestedAt: new Date('2026-01-01'), + completedAt: null, + cancelledAt: null, + } + + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('read') + mockResolveWorkspaceContext.mockResolvedValue({ + workspaceId: TABLE.workspaceId, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mockResolveContext.mockResolvedValue({ + tableId: TABLE.id, + table: TABLE, + workspaceId: TABLE.workspaceId, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mockGetTableById.mockResolvedValue(TABLE) + mockReadDispatch.mockResolvedValue(DISPATCH) + mockListActiveDispatches.mockResolvedValue([DISPATCH]) + }) + + it.each(['pending', 'dispatching', 'complete', 'cancelled'] as const)( + 'reads a %s dispatch', + async (status) => { + mockReadDispatch.mockResolvedValue({ ...DISPATCH, status }) + + const result = await readTableDispatch.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, dispatchId: DISPATCH.id, workspaceId: TABLE.workspaceId }, + }) + + expect(result.dispatch.status).toBe(status) + } + ) + + it('conceals a dispatch in another workspace as not found', async () => { + mockReadDispatch.mockResolvedValue({ ...DISPATCH, workspaceId: 'workspace-other' }) + + await expect( + readTableDispatch.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, dispatchId: DISPATCH.id, workspaceId: TABLE.workspaceId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('conceals a dispatch whose table is gone as not found', async () => { + mockGetTableById.mockResolvedValue(null) + + await expect( + readTableDispatch.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, dispatchId: DISPATCH.id, workspaceId: TABLE.workspaceId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('reports a dispatch id that never existed as not found', async () => { + mockReadDispatch.mockResolvedValue(null) + + await expect( + readTableDispatch.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, dispatchId: 'nope', workspaceId: TABLE.workspaceId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + /** + * Nesting the read under its table means the parent is authorized first — and a dispatch id + * belonging to a DIFFERENT table must not confirm its own existence through the table the + * caller named. + */ + it('conceals a dispatch belonging to another table as not found', async () => { + mockReadDispatch.mockResolvedValue({ ...DISPATCH, tableId: 'table-other' }) + + await expect( + readTableDispatch.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + dispatchId: DISPATCH.id, + workspaceId: TABLE.workspaceId, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('cancels an active dispatch by id and returns its settled state', async () => { + mockResolvePermission.mockResolvedValue('write') + mockReadDispatch.mockResolvedValueOnce(DISPATCH) + mockReadDispatch.mockResolvedValueOnce({ ...DISPATCH, status: 'cancelled' }) + + const result = await cancelTableDispatch.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, dispatchId: DISPATCH.id, workspaceId: TABLE.workspaceId }, + }) + + expect(mockCancelDispatchById).toHaveBeenCalledWith(DISPATCH.id) + expect(result.dispatch.status).toBe('cancelled') + }) + + it('leaves a terminal dispatch alone rather than re-cancelling it', async () => { + mockResolvePermission.mockResolvedValue('write') + mockReadDispatch.mockResolvedValue({ ...DISPATCH, status: 'complete' }) + + const result = await cancelTableDispatch.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, dispatchId: DISPATCH.id, workspaceId: TABLE.workspaceId }, + }) + + expect(mockCancelDispatchById).not.toHaveBeenCalled() + expect(result.dispatch.status).toBe('complete') + }) + + it('conceals a cancel of a dispatch belonging to another table as not found', async () => { + mockResolvePermission.mockResolvedValue('write') + mockReadDispatch.mockResolvedValue({ ...DISPATCH, tableId: 'table-other' }) + + await expect( + cancelTableDispatch.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, dispatchId: DISPATCH.id, workspaceId: TABLE.workspaceId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mockCancelDispatchById).not.toHaveBeenCalled() + }) + + it('lists the active dispatches for the canonical table', async () => { + const result = await listTableDispatches.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, assertedWorkspaceId: TABLE.workspaceId }, + }) + + expect(mockListActiveDispatches).toHaveBeenCalledWith(TABLE.id) + expect(result.dispatches).toEqual([DISPATCH]) + }) +}) diff --git a/apps/sim/lib/table/application/runs.ts b/apps/sim/lib/table/application/runs.ts index aa39b45dd31..7b9725f9414 100644 --- a/apps/sim/lib/table/application/runs.ts +++ b/apps/sim/lib/table/application/runs.ts @@ -5,16 +5,28 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { DEFAULT_TABLE_PLAN_LIMITS, getRowById, + getTableById, requireTableRowIds, TABLE_LIMITS, type TableDefinition, type TablePredicate, } from '@/lib/table' +import type { TableAuthorizationContext } from '@/lib/table/application/authorization' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' -import { resolveActiveTableContext } from '@/lib/table/application/context' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' import { tablePredicateNamesToFilter } from '@/lib/table/application/rows' -import type { DispatchLimit, DispatchMode } from '@/lib/table/dispatcher' +import { + cancelDispatchById, + type DispatchLimit, + type DispatchMode, + type DispatchRow, + listActiveDispatches, + readDispatch, +} from '@/lib/table/dispatcher' import { signalTableRowsChanged } from '@/lib/table/events' import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' @@ -208,3 +220,102 @@ export const cancelTableRuns = defineAuthorizedTableUseCase({ if (result.cancelled > 0) signalTableRowsChanged(context.tableId) }, }) + +export interface TableDispatchResourceInput { + dispatchId: string + workspaceId: string + /** The table the caller addressed the dispatch under; asserted against the stored row. */ + tableId: string +} + +export interface TableDispatchResult { + dispatch: DispatchRow +} + +interface TableDispatchContext extends TableAuthorizationContext { + dispatch: DispatchRow +} + +/** + * Loads one dispatch and derives its canonical table and workspace from the + * stored row, then asserts the caller's asserted scope against them. + * + * The canonical scope always comes from the dispatch itself — the asserted + * workspace and table are compared to it, never substituted for it. A workspace + * mismatch, a `tableId` naming a different table, a dispatch whose table was + * deleted, and a dispatch that never existed all report the same not-found, so + * the id space leaks nothing across tenants or across tables. + */ +async function resolveTableDispatchContext( + input: TableDispatchResourceInput +): Promise { + const dispatch = await readDispatch(input.dispatchId) + if ( + !dispatch || + dispatch.workspaceId !== input.workspaceId || + dispatch.tableId !== input.tableId + ) { + throw new OrchestrationError('not_found', 'Table run dispatch not found') + } + const table = await getTableById(dispatch.tableId) + if (!table || table.workspaceId !== dispatch.workspaceId) { + throw new OrchestrationError('not_found', 'Table run dispatch not found') + } + return { ...(await resolveTableWorkspaceContext(dispatch.workspaceId)), dispatch } +} + +/** Polls one run dispatch in any of its four states, including the terminal two. */ +export const readTableDispatch = defineAuthorizedTableUseCase({ + operation: tableOperations.readRun, + resolveContext: ({ input }: { input: TableDispatchResourceInput }) => + resolveTableDispatchContext(input), + async execute({ context }): Promise { + return { dispatch: context.dispatch } + }, +}) + +/** + * Cancels one dispatch by id — the counterpart to `POST /cancel-runs`, which cancels by + * predicate scope and cannot name a single dispatch. + * + * Stops the scheduler: the dispatcher observes the `cancelled` status at its next iteration + * and enqueues nothing further. Cells already handed to the queue are NOT cancelled here, + * because nothing links a cell execution back to the dispatch that enqueued it — cancelling + * those means `POST /cancel-runs`, whose predicate scope is the only way to name them. + * + * Idempotent: a dispatch already in a terminal state is returned unchanged. + */ +export const cancelTableDispatch = defineAuthorizedTableUseCase({ + operation: tableOperations.cancelRuns, + resolveContext: ({ input }: { input: TableDispatchResourceInput }) => + resolveTableDispatchContext(input), + async execute({ context }): Promise { + if (context.dispatch.status === 'complete' || context.dispatch.status === 'cancelled') { + return { dispatch: context.dispatch } + } + await cancelDispatchById(context.dispatch.id) + const dispatch = await readDispatch(context.dispatch.id) + return { dispatch: dispatch ?? context.dispatch } + }, +}) + +export interface ListTableDispatchesInput extends TableRunInput { + assertedWorkspaceId: string +} + +export interface ListTableDispatchesResult extends TableRunResult { + dispatches: DispatchRow[] +} + +/** + * The dispatches still in flight on one table. Bounded by the dispatcher rather + * than by a page size, which is why the surface publishes it unpaged. + */ +export const listTableDispatches = defineAuthorizedTableUseCase({ + operation: tableOperations.readRun, + resolveContext: ({ input }: { input: ListTableDispatchesInput }) => + resolveActiveTableContext(input), + async execute({ context }): Promise { + return { table: context.table, dispatches: await listActiveDispatches(context.tableId) } + }, +}) diff --git a/apps/sim/lib/table/application/tables.test.ts b/apps/sim/lib/table/application/tables.test.ts new file mode 100644 index 00000000000..16e58fe2791 --- /dev/null +++ b/apps/sim/lib/table/application/tables.test.ts @@ -0,0 +1,224 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + getTableById: vi.fn(), + loadFolderIndex: vi.fn(), + queryTables: vi.fn(), + resolveArchivedContext: vi.fn(), + resolveFolderPathFilter: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspaceContext: vi.fn(), + restoreTable: vi.fn(), + signal: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_RESTORED: 'table.restored' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolderIndex, + resolveFolderPathFilter: mocks.resolveFolderPathFilter, +})) + +vi.mock('@/lib/table', () => ({ + createTable: vi.fn(), + deleteTable: vi.fn(), + getTableById: mocks.getTableById, + getWorkspaceTableLimits: vi.fn(), + moveTableToFolder: vi.fn(), + queryTables: mocks.queryTables, + renameTable: vi.fn(), + restoreTable: mocks.restoreTable, + updateTableDescription: vi.fn(), +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: vi.fn(), + resolveArchivedTableContext: mocks.resolveArchivedContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) + +vi.mock('@/lib/table/application/folder-paths', () => ({ + resolveTableFolderPath: vi.fn(), + tableFolderPathForId: () => '/', +})) + +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) + +import { listTablesUseCase, restoreTableUseCase } from '@/lib/table/application/tables' + +const WORKSPACE = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + +const ARCHIVED: TableDefinition = { + id: 'table-1', + name: 'People (restored 4f2a)', + description: null, + schema: { columns: [] }, + metadata: null, + rowCount: 0, + maxRows: 10, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: new Date('2026-01-01'), + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), +} + +describe('table list scope', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkspaceContext.mockResolvedValue(WORKSPACE) + mocks.loadFolderIndex.mockResolvedValue({ pathById: new Map() }) + mocks.resolveFolderPathFilter.mockReturnValue({ kind: 'all' }) + mocks.queryTables.mockResolvedValue({ tables: [], nextKeys: null }) + }) + + it('lets the caller scope the listing without changing the default', async () => { + await listTablesUseCase.execute({ + principal: PRINCIPAL, + input: { + workspaceId: 'workspace-1', + sortBy: 'createdAt', + sortOrder: 'asc', + limit: 10, + }, + }) + expect(mocks.queryTables).toHaveBeenLastCalledWith( + 'workspace-1', + expect.objectContaining({ scope: undefined }) + ) + + await listTablesUseCase.execute({ + principal: PRINCIPAL, + input: { + workspaceId: 'workspace-1', + scope: 'archived', + sortBy: 'createdAt', + sortOrder: 'asc', + limit: 10, + }, + }) + expect(mocks.queryTables).toHaveBeenLastCalledWith( + 'workspace-1', + expect.objectContaining({ scope: 'archived' }) + ) + }) +}) + +/** + * Without a restore, a headless `DELETE` was unrecoverable: the table is + * archived, not erased, but nothing on the public surface could bring it back. + */ +describe('restoreTableUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveArchivedContext.mockResolvedValue({ + ...WORKSPACE, + tableId: ARCHIVED.id, + table: ARCHIVED, + }) + mocks.getTableById.mockResolvedValue({ ...ARCHIVED, archivedAt: null }) + mocks.loadFolderIndex.mockResolvedValue({ pathById: new Map() }) + mocks.restoreTable.mockResolvedValue(undefined) + }) + + it('restores the archived table and audits the authoritative restored row', async () => { + const result = await restoreTableUseCase.execute({ + principal: PRINCIPAL, + input: { tableId: ARCHIVED.id, workspaceId: 'workspace-1' }, + }) + + expect(mocks.restoreTable).toHaveBeenCalledWith(ARCHIVED.id, 'request-1') + expect(result.table.archivedAt).toBeNull() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'table.restored', + resourceId: ARCHIVED.id, + resourceName: ARCHIVED.name, + }) + ) + expect(mocks.signal).toHaveBeenCalledWith(ARCHIVED.id) + }) + + /** + * Restore is idempotent: a `409` for an already-active table would make a + * retry after a dropped response look like a failure, and there is no state a + * second restore could corrupt. Matches `restoreKnowledgeBase`. + */ + it('returns an already-active table unchanged, with no write and no audit', async () => { + const active = { ...ARCHIVED, archivedAt: null } + mocks.resolveArchivedContext.mockResolvedValue({ + ...WORKSPACE, + tableId: ARCHIVED.id, + table: active, + }) + + const result = await restoreTableUseCase.execute({ + principal: PRINCIPAL, + input: { tableId: ARCHIVED.id, workspaceId: 'workspace-1' }, + }) + + expect(result.table.archivedAt).toBeNull() + expect(mocks.restoreTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('refuses a caller without write permission before restoring', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + restoreTableUseCase.execute({ + principal: PRINCIPAL, + input: { tableId: ARCHIVED.id, workspaceId: 'workspace-1' }, + }) + ).rejects.toBeDefined() + + expect(mocks.restoreTable).not.toHaveBeenCalled() + }) + + it('propagates a name-collision conflict without audit or shared effects', async () => { + const failure = Object.assign(new Error('Table name is already taken'), { code: 'conflict' }) + mocks.restoreTable.mockRejectedValueOnce(failure) + + await expect( + restoreTableUseCase.execute({ + principal: PRINCIPAL, + input: { tableId: ARCHIVED.id, workspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 1cf4f0d5c87..b61e1233bfa 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -14,13 +14,16 @@ import { moveTableToFolder, queryTables, renameTable, + restoreTable, type TableDefinition, type TableSchema, + type TableScope, updateTableDescription, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveActiveTableContext, + resolveArchivedTableContext, resolveTableWorkspaceContext, } from '@/lib/table/application/context' import { resolveTableFolderPath, tableFolderPathForId } from '@/lib/table/application/folder-paths' @@ -29,6 +32,8 @@ import { signalTableSchemaChanged } from '@/lib/table/events' export interface ListTablesInput { workspaceId: string + /** Which lifecycle set to list. Omitted means `active`, matching every shipped caller. */ + scope?: TableScope folderPath?: string search?: string sortBy: V2TableSortBy @@ -51,6 +56,7 @@ export const listTablesUseCase = defineAuthorizedTableUseCase({ } const { tables, nextKeys } = await queryTables(context.workspaceId, { + scope: input.scope, folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, search: input.search, sortBy: input.sortBy, @@ -317,3 +323,58 @@ export const deleteTableUseCase = defineAuthorizedTableUseCase({ } }, }) + +/** + * Un-archives a table that {@link deleteTableUseCase} archived. + * + * Calls the service primitive rather than `performRestoreTable`: that + * orchestration records its own audit row keyed on a bare `userId`, which + * cannot represent a workspace-key or delegated principal. Audit is projected + * here instead, from the authoritative restored row. + * + * Restore is deliberately not gated by the delete lock — see `restoreTable`. + * + * Idempotent: a table that is already active is returned unchanged, with no + * restore performed and no audit entry recorded. A `409` there would make a + * retry after a dropped response look like a failure, and restore has no state + * a second call could corrupt — the same position the knowledge surface takes + * on its own restore. + */ +export const restoreTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.restore, + resolveContext: ({ input }: { input: ReadTableInput }) => + resolveArchivedTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ context }) { + const restored = context.table.archivedAt !== null + if (restored) { + await restoreTable(context.table.id, generateRequestId()) + } + const table = await getTableById(context.table.id) + if (!table || table.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + return { table, folderPath: tableFolderPathForId(index, table.folderId), restored } + }, + projectAudit({ result }) { + return result.restored + ? [ + { + action: AuditAction.TABLE_RESTORED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Restored table "${result.table.name}"`, + }, + ] + : [] + }, + afterSuccess({ result }) { + if (result.restored) signalTableSchemaChanged(result.table.id) + }, +}) diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index a673cd97356..2da9bf6b58a 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -54,6 +54,14 @@ export const TABLE_LIMITS = { EXPORT_ASYNC_THRESHOLD_ROWS: 10000, /** Cap on the exclusion set ("select all, minus these") sent to an async delete job. */ MAX_EXCLUDE_ROW_IDS: 10000, + /** + * Byte budget for the per-row run-state sidecar a read may materialize when + * it opts in. `blockErrors` is unbounded jsonb, so a full page of rows times + * a group each has no ceiling of its own. A read past the budget is refused + * (413) rather than silently truncated — a partial answer to "which of my + * rows errored" is a wrong answer. + */ + MAX_ROW_RUN_STATE_BYTES: 2 * 1024 * 1024, /** * Matching cells one Find returns. The scan fetches one extra to decide * `truncated`; matches carry no cursor, so a caller past the cap narrows its diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index e15a3547894..c20d1d837a5 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -99,6 +99,10 @@ export interface DispatchRow { /** User who triggered the run (for usage attribution); null for auto-fire. */ triggeredByUserId: string | null requestedAt: Date + /** Set when the dispatch reached `complete`; null while it is still active. */ + completedAt: Date | null + /** Set when the dispatch was cancelled; null otherwise. */ + cancelledAt: Date | null } async function deleteExecutionRows(trx: DbTransaction, filters: SQL[]): Promise { @@ -346,6 +350,8 @@ export async function listActiveDispatches(tableId: string): Promise diff --git a/apps/sim/lib/table/rows/errors.ts b/apps/sim/lib/table/rows/errors.ts index c02e48fdb61..8f83e84b4f2 100644 --- a/apps/sim/lib/table/rows/errors.ts +++ b/apps/sim/lib/table/rows/errors.ts @@ -12,3 +12,25 @@ export class TableRowNotFoundError extends OrchestrationError { this.name = 'TableRowNotFoundError' } } + +/** + * Refusal for a read whose opt-in run-state sidecar cannot be materialized + * within `TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES`. + * + * `payload_too_large` rather than a truncated success: the question run state + * answers is "which of my rows errored", and a silently short answer to that is + * wrong rather than merely incomplete. + * + * Lives beside the row errors rather than in the application layer because the + * budget is spent — and therefore blown — inside the sidecar drain itself, and + * `lib/table/rows/**` cannot import the use cases that sit above it. + */ +export class TableRunStateCollectionLimitExceededError extends OrchestrationError { + constructor(limitBytes: number) { + super( + 'payload_too_large', + `Run state for this page exceeds the ${limitBytes} byte limit; request a smaller limit or read the rows without includeRunState` + ) + this.name = 'TableRunStateCollectionLimitExceededError' + } +} diff --git a/apps/sim/lib/table/rows/executions.test.ts b/apps/sim/lib/table/rows/executions.test.ts index d9710260d61..dff9b36ae57 100644 --- a/apps/sim/lib/table/rows/executions.test.ts +++ b/apps/sim/lib/table/rows/executions.test.ts @@ -3,7 +3,8 @@ */ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { writeExecutionsPatch } from '@/lib/table/rows/executions' +import type { DbOrTx } from '@/lib/db/types' +import { loadExecutionsByRow, writeExecutionsPatch } from '@/lib/table/rows/executions' import type { RowExecutionMetadata } from '@/lib/table/types' const EXECUTION_STATE: RowExecutionMetadata = { @@ -110,3 +111,108 @@ describe('writeExecutionsPatch guards', () => { ).resolves.toBe('wrote') }) }) + +interface StoredExecution { + rowId: string + groupId: string + status: string + executionId: string | null + jobId: string | null + workflowId: string + error: string | null + runningBlockIds: string[] + blockErrors: unknown + cancelledAt: Date | null +} + +function storedExecution(overrides: Partial & { rowId: string }): StoredExecution { + return { + groupId: 'group-1', + status: 'completed', + executionId: 'execution-1', + jobId: null, + workflowId: 'workflow-1', + error: null, + runningBlockIds: [], + blockErrors: {}, + cancelledAt: null, + ...overrides, + } +} + +/** + * Stands in for the drizzle builder `loadExecutionsByRow` drives, handing back + * one queued page per `select()` so a test can assert how many round trips the + * drain made before it stopped. + */ +function fakeTrx(pages: StoredExecution[][]) { + const where = vi.fn() + for (const page of pages) where.mockResolvedValueOnce(page) + where.mockResolvedValue([]) + const select = vi.fn(() => ({ from: () => ({ where }) })) + return { trx: { select } as unknown as DbOrTx, select } +} + +describe('loadExecutionsByRow', () => { + it('drops block-error members that are not strings', async () => { + const { trx } = fakeTrx([ + [ + storedExecution({ + rowId: 'row-1', + blockErrors: { 'block-1': 'boom', 'block-2': 42, 'block-3': null }, + }), + ], + ]) + + const byRow = await loadExecutionsByRow(trx, ['row-1']) + + expect(byRow.get('row-1')?.['group-1'].blockErrors).toEqual({ 'block-1': 'boom' }) + }) + + /** + * `blockErrors` is schemaless jsonb, so a blob that is not an object at all is + * reachable on read. Omitting the key is what lets the published contract keep + * declaring `Record` without a drifted row becoming a 500. + */ + it('omits block errors entirely when the stored blob is not an object map', async () => { + const { trx } = fakeTrx([[storedExecution({ rowId: 'row-1', blockErrors: ['boom'] })]]) + + const byRow = await loadExecutionsByRow(trx, ['row-1']) + + expect(byRow.get('row-1')?.['group-1']).not.toHaveProperty('blockErrors') + }) + + /** + * The budget is spent DURING the drain: the refusal has to land before the + * remaining chunks are read, or the heap spike the ceiling exists to prevent + * has already happened by the time anything measures it. + */ + it('refuses past the byte budget without reading the remaining chunks', async () => { + const fat = 'x'.repeat(4096) + const page = (prefix: string) => + Array.from({ length: 250 }, (_, index) => + storedExecution({ rowId: `${prefix}-${index}`, error: fat }) + ) + const { trx, select } = fakeTrx([page('a'), page('b'), page('c')]) + const ids = Array.from({ length: 750 }, (_, index) => `row-${index}`) + + await expect(loadExecutionsByRow(trx, ids, { budgetBytes: 512 * 1024 })).rejects.toMatchObject({ + code: 'payload_too_large', + name: 'TableRunStateCollectionLimitExceededError', + }) + + expect(select.mock.calls.length).toBeLessThan(3) + }) + + it('reads every chunk when the sidecar fits the budget', async () => { + const page = (prefix: string) => + Array.from({ length: 250 }, (_, index) => storedExecution({ rowId: `${prefix}-${index}` })) + const { trx, select } = fakeTrx([page('a'), page('b'), page('c')]) + const ids = Array.from({ length: 750 }, (_, index) => `row-${index}`) + + const byRow = await loadExecutionsByRow(trx, ids, { budgetBytes: 2 * 1024 * 1024 }) + + expect(select).toHaveBeenCalledTimes(3) + expect(byRow.size).toBe(750) + }) +}) diff --git a/apps/sim/lib/table/rows/executions.ts b/apps/sim/lib/table/rows/executions.ts index c453b504b8e..e1cae632391 100644 --- a/apps/sim/lib/table/rows/executions.ts +++ b/apps/sim/lib/table/rows/executions.ts @@ -10,6 +10,8 @@ import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { getColumnId } from '@/lib/table/column-keys' import { areGroupDepsSatisfied } from '@/lib/table/deps' +import { TableRunStateCollectionLimitExceededError } from '@/lib/table/rows/errors' +import { normalizeBlockErrors } from '@/lib/table/rows/run-state' import type { EnrichmentRunDetail, RowData, @@ -19,60 +21,100 @@ import type { TableSchema, } from '@/lib/table/types' +/** + * Rows whose sidecar is fetched per round trip. Bounds the `IN (...)` list and, + * with it, the heap a single batch can materialize: `blockErrors` is unbounded + * jsonb, so one query over a whole page's row ids has no ceiling of its own. + */ +const RUN_STATE_ID_CHUNK_SIZE = 250 + +interface LoadExecutionsOptions { + /** + * Ceiling on the serialized sidecar this call may materialize. Accumulated as + * the drain proceeds and enforced BEFORE the next chunk is fetched, so a + * refusal costs one over-budget chunk rather than the whole page — measuring + * an already-materialized result could only report a spike that had already + * happened. + */ + budgetBytes?: number +} + /** * Loads `tableRowExecutions` rows for the given row ids and groups them into a * `Map` suitable for plugging into `TableRow.executions`. + * + * Drains in bounded chunks rather than one unbounded `IN (...)`. Pass + * `budgetBytes` on any path that hands the sidecar to a caller; without it the + * drain is still chunked but will read every named row. */ export async function loadExecutionsByRow( trx: DbOrTx, - rowIds: Iterable + rowIds: Iterable, + options?: LoadExecutionsOptions ): Promise> { const ids = Array.from(new Set(rowIds)) const result = new Map() if (ids.length === 0) return result - // Explicit column list, never `select()` — `enrichmentDetails` is large and - // must stay off the hot grid read path (fetched on demand via - // `loadEnrichmentDetail`). - const rows = await trx - .select({ - rowId: tableRowExecutions.rowId, - groupId: tableRowExecutions.groupId, - status: tableRowExecutions.status, - executionId: tableRowExecutions.executionId, - jobId: tableRowExecutions.jobId, - workflowId: tableRowExecutions.workflowId, - error: tableRowExecutions.error, - runningBlockIds: tableRowExecutions.runningBlockIds, - blockErrors: tableRowExecutions.blockErrors, - cancelledAt: tableRowExecutions.cancelledAt, - }) - .from(tableRowExecutions) - .where(inArray(tableRowExecutions.rowId, ids)) - for (const r of rows) { - const existing = result.get(r.rowId) ?? {} - const meta: RowExecutionMetadata = { - status: r.status as RowExecutionMetadata['status'], - executionId: r.executionId ?? null, - jobId: r.jobId ?? null, - workflowId: r.workflowId, - error: r.error ?? null, - ...(r.runningBlockIds && r.runningBlockIds.length > 0 - ? { runningBlockIds: r.runningBlockIds } - : {}), - ...(r.blockErrors && Object.keys(r.blockErrors as Record).length > 0 - ? { blockErrors: r.blockErrors as Record } - : {}), - ...(r.cancelledAt ? { cancelledAt: r.cancelledAt.toISOString() } : {}), + const budgetBytes = options?.budgetBytes + let bytes = 0 + for (let offset = 0; offset < ids.length; offset += RUN_STATE_ID_CHUNK_SIZE) { + if (budgetBytes !== undefined && bytes > budgetBytes) { + throw new TableRunStateCollectionLimitExceededError(budgetBytes) + } + const chunk = ids.slice(offset, offset + RUN_STATE_ID_CHUNK_SIZE) + // Explicit column list, never `select()` — `enrichmentDetails` is large and + // must stay off the hot grid read path (fetched on demand via + // `loadEnrichmentDetail`). + const rows = await trx + .select({ + rowId: tableRowExecutions.rowId, + groupId: tableRowExecutions.groupId, + status: tableRowExecutions.status, + executionId: tableRowExecutions.executionId, + jobId: tableRowExecutions.jobId, + workflowId: tableRowExecutions.workflowId, + error: tableRowExecutions.error, + runningBlockIds: tableRowExecutions.runningBlockIds, + blockErrors: tableRowExecutions.blockErrors, + cancelledAt: tableRowExecutions.cancelledAt, + }) + .from(tableRowExecutions) + .where(inArray(tableRowExecutions.rowId, chunk)) + for (const r of rows) { + const existing = result.get(r.rowId) ?? {} + const blockErrors = normalizeBlockErrors(r.blockErrors) + const meta: RowExecutionMetadata = { + status: r.status as RowExecutionMetadata['status'], + executionId: r.executionId ?? null, + jobId: r.jobId ?? null, + workflowId: r.workflowId, + error: r.error ?? null, + ...(r.runningBlockIds && r.runningBlockIds.length > 0 + ? { runningBlockIds: r.runningBlockIds } + : {}), + ...(blockErrors ? { blockErrors } : {}), + ...(r.cancelledAt ? { cancelledAt: r.cancelledAt.toISOString() } : {}), + } + if (budgetBytes !== undefined) { + bytes += Buffer.byteLength(JSON.stringify(meta), 'utf8') + if (bytes > budgetBytes) { + throw new TableRunStateCollectionLimitExceededError(budgetBytes) + } + } + existing[r.groupId] = meta + result.set(r.rowId, existing) } - existing[r.groupId] = meta - result.set(r.rowId, existing) } return result } /** Convenience: load executions for one row, returning `{}` when missing. */ -export async function loadExecutionsForRow(trx: DbOrTx, rowId: string): Promise { - const byRow = await loadExecutionsByRow(trx, [rowId]) +export async function loadExecutionsForRow( + trx: DbOrTx, + rowId: string, + options?: LoadExecutionsOptions +): Promise { + const byRow = await loadExecutionsByRow(trx, [rowId], options) return byRow.get(rowId) ?? {} } diff --git a/apps/sim/lib/table/rows/run-state.ts b/apps/sim/lib/table/rows/run-state.ts new file mode 100644 index 00000000000..b751a19041b --- /dev/null +++ b/apps/sim/lib/table/rows/run-state.ts @@ -0,0 +1,27 @@ +/** + * Shared normalizers for the `tableRowExecutions` sidecar columns that are + * stored looser than every consumer declares them. + * + * Internal module: not exposed via the `@/lib/table` barrel. + */ + +/** + * Projects the schemaless `blockErrors` jsonb column onto the + * `Record` shape the domain type and the published contract + * both declare, dropping any member that is not a string. + * + * The writers guard the shape today, so a drifted blob is latent rather than + * observed — but it is caller-reachable on read, and `z.record(z.string(), + * z.string())` in a response slot turns one bad row into a 500 on a well-formed + * request. Returns `undefined` for an empty result so callers can omit the key + * rather than publish an empty map. + */ +export function normalizeBlockErrors(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + + const blockErrors: Record = {} + for (const [blockId, error] of Object.entries(value)) { + if (typeof error === 'string') blockErrors[blockId] = error + } + return Object.keys(blockErrors).length > 0 ? blockErrors : undefined +} diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 8e0f48e20e6..f8933c05cdf 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1113,6 +1113,7 @@ export async function queryRows( after, includeTotal = true, withExecutions = true, + runStateBudgetBytes, columnIds, } = options @@ -1192,10 +1193,21 @@ export async function queryRows( const [fetched, totalCount] = await Promise.all([drainPromise, countPromise]) const rows = fetched.rows + /** + * The budget is opt-in, not a property of reading run state. + * + * It exists for the public row reads, which publish a `413` and a documented + * ceiling. The first-party grid reads run state too, at five times the row + * limit, and has no such contract: applying the budget there turns a large + * page into a hard failure — with an error naming a parameter the internal + * route does not expose — where it previously rendered. Callers that publish + * the ceiling pass it; callers that do not keep the unbounded read they had. + */ const executionsByRow = withExecutions ? await loadExecutionsByRow( db, - rows.map((r) => r.id) + rows.map((r) => r.id), + runStateBudgetBytes === undefined ? undefined : { budgetBytes: runStateBudgetBytes } ) : null diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 137124a22e6..9319e794c10 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -625,6 +625,13 @@ export interface QueryOptions { * (the public v1 route does not expose executions). */ withExecutions?: boolean + /** + * Byte ceiling for the run-state sidecar, spent during the read. + * + * Omitted means unbounded, which is what every first-party caller wants: only + * the public reads publish a `413` for this, so only they impose it. + */ + runStateBudgetBytes?: number /** * Stable column ids to keep in each returned row's `data`; omitted = every * column. Applied inside the drain before byte accounting, so the response diff --git a/apps/sim/lib/table/workflow-group-cancellation.ts b/apps/sim/lib/table/workflow-group-cancellation.ts index fd739d3aefa..bf7f638e51b 100644 --- a/apps/sim/lib/table/workflow-group-cancellation.ts +++ b/apps/sim/lib/table/workflow-group-cancellation.ts @@ -5,6 +5,7 @@ import { toError } from '@sim/utils/errors' import { and, eq, inArray } from 'drizzle-orm' import { cancelledExecutionLogFields } from '@/lib/logs/execution/cancellation' import { appendTableEvent } from '@/lib/table/events' +import { normalizeBlockErrors } from '@/lib/table/rows/run-state' const logger = createLogger('WorkflowGroupCancellation') const ACTIVE_WORKFLOW_GROUP_STATUSES = ['queued', 'running', 'pending'] as const @@ -66,16 +67,6 @@ interface WorkflowGroupExecutionTarget { blockErrors: unknown } -function normalizeBlockErrors(value: unknown): Record | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined - - const blockErrors: Record = {} - for (const [blockId, error] of Object.entries(value)) { - if (typeof error === 'string') blockErrors[blockId] = error - } - return Object.keys(blockErrors).length > 0 ? blockErrors : undefined -} - function getExecutionCorrelationSource(value: unknown): string | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null const executionData = value as Record diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index b4a4756ff65..8824e421a7a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -2234,6 +2234,17 @@ export async function purgeCreatedWorkspaceFile(params: { /** * Restore a soft-deleted workspace file. */ +export interface PermanentlyDeleteWorkspaceFileResult { + /** The record as it stood before its row was removed. */ + file: WorkspaceFileRecord + /** + * Whether the stored object was removed. `false` means the row is gone but + * the object outlived it and is now an orphan for the storage sweep, which is + * a recoverable state; the reverse never happens by construction. + */ + objectDeleted: boolean +} + export async function restoreWorkspaceFile(workspaceId: string, fileId: string): Promise { logger.info(`Restoring workspace file: ${fileId}`) diff --git a/apps/sim/lib/uploads/upload-session/application.test.ts b/apps/sim/lib/uploads/upload-session/application.test.ts index bdcde448e22..d5bef756c36 100644 --- a/apps/sim/lib/uploads/upload-session/application.test.ts +++ b/apps/sim/lib/uploads/upload-session/application.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ completeSession: vi.fn(), finalizePurpose: vi.fn(), getOwnedSession: vi.fn(), + getPrincipalSession: vi.fn(), reauthorizeWorkspacePurpose: vi.fn(), })) @@ -19,7 +20,7 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ createUploadPartUrls: vi.fn(), createUploadSession: vi.fn(), getOwnedUploadSession: mocks.getOwnedSession, - getPrincipalUploadSession: vi.fn(), + getPrincipalUploadSession: mocks.getPrincipalSession, })) vi.mock('@/app/api/files/uploads/finalizers', () => ({ @@ -36,7 +37,10 @@ vi.mock('@/app/api/files/uploads/purposes', () => ({ resolveUploadAttributionUserId: vi.fn(), })) -import { completeInternalUploadSession } from '@/lib/uploads/upload-session/application' +import { + completeInternalUploadSession, + readWorkspaceUploadSession, +} from '@/lib/uploads/upload-session/application' import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' const principal = { @@ -55,6 +59,7 @@ describe('upload session application', () => { value: { id: 'file-1' }, completedFileId: 'file-1', }) + mocks.getPrincipalSession.mockResolvedValue(session) mocks.completeSession.mockImplementation(async ({ session: claimed, finalize }) => { const finalized = await finalize(claimed) return { @@ -80,6 +85,37 @@ describe('upload session application', () => { expect.objectContaining({ actor, principal, request }) ) }) + + /** + * The read is a control leg, so it re-authorizes the caller's present + * workspace permission rather than trusting the session lookup alone. + */ + it('re-authorizes a session read against the read operation', async () => { + const session = await readWorkspaceUploadSession(principal, { + uploadId: 'upload-1', + workspaceId: 'workspace-1', + uploadToken: 'upload-token', + }) + + expect(session.id).toBe('upload-1') + expect(mocks.reauthorizeWorkspacePurpose).toHaveBeenCalledWith( + principal, + expect.objectContaining({ id: 'upload-1' }), + expect.objectContaining({ id: 'files.upload.read', minimumRole: 'read' }) + ) + }) + + it('does not return a session whose re-authorization fails', async () => { + mocks.reauthorizeWorkspacePurpose.mockRejectedValueOnce(new Error('Upload session not found')) + + await expect( + readWorkspaceUploadSession(principal, { + uploadId: 'upload-1', + workspaceId: 'workspace-1', + uploadToken: 'upload-token', + }) + ).rejects.toThrow('Upload session not found') + }) }) function workspaceUploadSession(): UploadSessionRecord { diff --git a/apps/sim/lib/uploads/upload-session/application.ts b/apps/sim/lib/uploads/upload-session/application.ts index e423b79dd85..2cebdf47312 100644 --- a/apps/sim/lib/uploads/upload-session/application.ts +++ b/apps/sim/lib/uploads/upload-session/application.ts @@ -185,6 +185,21 @@ export async function loadAuthorizedWorkspaceUploadSession( }) } +/** + * Reads an upload session's current state after current workspace + * authorization. The `GET` is a control leg like every other, so the session's + * auth binding and the caller's present workspace permission are both + * re-checked here rather than the session being looked up on its id alone. + */ +export async function readWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadRead) + return session +} + /** Issues multipart URLs after current workspace authorization. */ export async function issueWorkspaceUploadPartUrls( principal: Principal, @@ -323,6 +338,13 @@ export const completeWorkspaceFileUploadOperation = { }, } as const +export const readWorkspaceFileUploadOperation = { + operation: fileOperations.uploadRead, + async execute({ principal, input }: { principal: Principal; input: UploadSessionControlInput }) { + return readWorkspaceUploadSession(principal, input) + }, +} as const + export const abortWorkspaceFileUploadOperation = { operation: fileOperations.uploadCancel, async execute({ principal, input }: { principal: Principal; input: UploadSessionControlInput }) { diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index 79032282bcf..b51a2d78102 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { describe, expect, it } from 'vitest' import { extractStorageKey, + extractWorkspaceIdFromStorageKey, getMimeTypeFromExtension, inferContextFromKey, isAbortError, @@ -119,6 +120,36 @@ describe('inferContextFromKey', () => { }) }) +describe('extractWorkspaceIdFromStorageKey', () => { + const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' + const WORKFLOW_ID = '33333333-3333-4333-8333-333333333333' + const EXECUTION_ID = '44444444-4444-4444-8444-444444444444' + + it('reads the workspace out of the two key layouts that name one', () => { + expect( + extractWorkspaceIdFromStorageKey(`workspace/${WORKSPACE_ID}/1700000000000-abc-x.pdf`) + ).toBe(WORKSPACE_ID) + expect( + extractWorkspaceIdFromStorageKey( + `execution/${WORKSPACE_ID}/${WORKFLOW_ID}/${EXECUTION_ID}/x.png` + ) + ).toBe(WORKSPACE_ID) + }) + + it('returns null for key layouts that name no workspace', () => { + expect(extractWorkspaceIdFromStorageKey('chat/x')).toBeNull() + expect(extractWorkspaceIdFromStorageKey('kb/x')).toBeNull() + expect(extractWorkspaceIdFromStorageKey('copilot/x')).toBeNull() + expect(extractWorkspaceIdFromStorageKey('profile-pictures/x')).toBeNull() + expect(extractWorkspaceIdFromStorageKey('')).toBeNull() + }) + + it('refuses a workspace segment that is not a workspace id', () => { + expect(extractWorkspaceIdFromStorageKey('workspace/other-tenant/x.pdf')).toBeNull() + expect(extractWorkspaceIdFromStorageKey(`workspace/${WORKSPACE_ID}`)).toBeNull() + }) +}) + describe('resolveTrustedFileContext', () => { it('derives from the key prefix and ignores a mismatched caller context', () => { expect(resolveTrustedFileContext('workspace/ws/1700000000000-abc-x.pdf', 'og-images')).toBe( diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index cc549e26405..425a110b104 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -246,6 +246,16 @@ export function isGeneratedDocumentSourceType(contentType: string | undefined | * orders of magnitude smaller than the document it produces, so the declared size is no * bound at all and the rendered bytes need a cap of their own. */ +/** + * Ceiling on the source bytes fed to a text-extraction parser. + * + * The parsers have a documented denial-of-service history, so a text read is + * bounded on its *input* before extraction rather than on its output after. + * The individual parsers keep their own guards; those must not be relaxed to + * make a larger ceiling usable. + */ +export const MAX_TEXT_EXTRACTION_BYTES = 25 * 1024 * 1024 + export const MAX_RENDERED_DOCUMENT_BYTES = 50 * 1024 * 1024 /** True when `fileName` may be backed by a generation source rather than final bytes. */ @@ -1126,6 +1136,31 @@ export function extractWorkspaceIdFromExecutionKey(key: string): string | null { return null } +/** + * The workspace a storage key demonstrably belongs to, or `null` when the key's + * layout does not name one. + * + * Only two key layouts encode their tenant: `workspace/{workspaceId}/…` and + * `execution/{workspaceId}/{workflowId}/{executionId}/…`. Every other prefix + * (`kb/`, `chat/`, `copilot/`, the world-readable ones) carries no workspace + * segment, so no ownership can be proven from the key alone and this returns + * `null` rather than guessing. + * + * This is the only safe way to compare a key against an expected workspace when + * the key came from a caller: it reads the tenant out of the key's own layout + * instead of trusting an adjacent `context`, `workspaceId`, or URL field. + */ +export function extractWorkspaceIdFromStorageKey(key: string): string | null { + const segments = key.split('/') + + if (segments[0] === 'workspace' && segments.length >= 3) { + const workspaceId = segments[1] + return workspaceId && isUuid(workspaceId) ? workspaceId : null + } + + return extractWorkspaceIdFromExecutionKey(key) +} + /** * Construct viewer URL for a file * Viewer URL format: /workspace/{workspaceId}/files/{fileKey} diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index 102b6e69b17..488d747b343 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -15,6 +15,7 @@ import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-ke import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' +import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const v2WorkflowErrorPolicies = { @@ -30,6 +31,27 @@ export const v2WorkflowErrorPolicies = { concealWorkflowAuthorization: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Workflow not found', }), + /** + * Conceals cross-tenant reads exactly as + * {@link v2WorkflowErrorPolicies.concealWorkflowAuthorization} does, and adds + * the one refusal an edit batch has structured detail for: an `atomic` batch + * that could not be applied whole answers `409` carrying the declined + * operations and the block inputs that would have been dropped, so a pipeline + * can act on both without a second request. + */ + concealWorkflowGraphAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workflow not found', + render(error) { + if (error instanceof WorkflowOperationsNotAppliedError) { + return v2ErrorForOrchestration(error.code, error.message, { + code: 'OPERATIONS_NOT_APPLIED', + skipped: error.skipped, + droppedInputs: error.droppedInputs, + }) + } + return v2CaughtOrchestrationError(error) + }, + }), concealRunAuthorization: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Run not found', }), diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts new file mode 100644 index 00000000000..b5a8e14c2ab --- /dev/null +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -0,0 +1,586 @@ +/** + * @vitest-environment node + */ +import { WorkflowLockedError } from '@sim/platform-authz/workflow' +import { workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + notify: vi.fn(), + replace: vi.fn(), + validate: vi.fn(), + needsRedeployment: vi.fn(), + applyOperations: vi.fn(), + loadNormalized: vi.fn(), + normalizeState: vi.fn(), + sandboxAccess: vi.fn(), + blockVisibility: vi.fn(), + permissionConfig: vi.fn(), + preValidate: vi.fn(), + collectReferences: vi.fn(), + collectToolReferences: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_UPDATED: 'workflow.updated' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ + replaceWorkflowNormalizedState: mocks.replace, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mocks.loadNormalized, +})) +vi.mock('@/lib/workflows/sanitization/validation', () => ({ + validateWorkflowState: mocks.validate, +})) +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.needsRedeployment, +})) +vi.mock('@/lib/workflows/editing/engine', () => ({ + applyOperationsToWorkflowState: mocks.applyOperations, +})) +vi.mock('@/lib/workflows/editing/validation', () => ({ + collectUnresolvedAgentToolReferences: mocks.collectToolReferences, + collectUnresolvedReferences: mocks.collectReferences, + preValidateCredentialInputs: mocks.preValidate, + UNRESOLVABLE_AT_LINT_NOTE: 'lint note', +})) +vi.mock('@/lib/workflows/editing/lint', () => ({ + collectWorkflowFieldIssues: () => [], + lintEditedWorkflowState: () => ({ + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + }), +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceSandboxAccess: mocks.sandboxAccess, +})) +vi.mock('@/lib/core/config/block-visibility', () => ({ getBlockVisibility: mocks.blockVisibility })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.permissionConfig, +})) +vi.mock('@/blocks/visibility/server-context', () => ({ + withBlockVisibility: (_state: unknown, run: () => unknown) => run(), +})) +vi.mock('@/stores/workflows/workflow/utils', () => ({ + generateLoopBlocks: () => ({}), + generateParallelBlocks: () => ({}), +})) +vi.mock('@/stores/workflows/workflow/validation', () => ({ + normalizeWorkflowState: mocks.normalizeState, +})) +vi.mock('@/lib/workflows/autolayout', () => ({ + applyTargetedLayout: vi.fn(), + getTargetedLayoutImpact: () => ({ + layoutBlockIds: [], + resizedBlockIds: [], + shiftSourceBlockIds: [], + }), + transferBlockHeights: vi.fn(), +})) + +import { ForbiddenOperationError } from '@/lib/core/application' +import { applyWorkflowOperations } from '@/lib/workflows/application/apply-workflow-operations' +import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' + +const BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} + +const context = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Daily digest', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const sessionPrincipal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const copilotPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +const operations = [ + { + operation_type: 'add' as const, + block_id: 'block-2', + params: { type: 'agent', name: 'Triage' }, + }, +] + +function graph(blocks: Record = { 'block-1': BLOCK }) { + return { blocks, edges: [], loops: {}, parallels: {} } +} + +describe('applyWorkflowOperations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mocks.sandboxAccess.mockResolvedValue(true) + mocks.blockVisibility.mockResolvedValue({ revealed: [], disabled: [], previewTagged: [] }) + mocks.permissionConfig.mockResolvedValue(null) + mocks.loadNormalized.mockResolvedValue(graph()) + mocks.normalizeState.mockReturnValue({ state: graph(), warnings: [] }) + mocks.preValidate.mockResolvedValue({ filteredOperations: operations, errors: [] }) + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [], + }) + mocks.collectReferences.mockResolvedValue([]) + mocks.collectToolReferences.mockResolvedValue([]) + mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] }) + mocks.replace.mockResolvedValue({ warnings: [], state: graph() }) + mocks.needsRedeployment.mockResolvedValue(true) + }) + + it('writes once, through the shared persistence primitive', async () => { + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(mocks.replace).toHaveBeenCalledTimes(1) + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-1', workspaceId: 'workspace-1' }) + ) + expect(result.applied).toBe(1) + expect(result.needsRedeployment).toBe(true) + }) + + describe('dry run', () => { + it('runs the whole engine and stops at the write', async () => { + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + + expect(result.dryRun).toBe(true) + expect(result.applied).toBe(1) + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + /** The preview is worthless if it does not carry the findings. */ + it('reports the same lint a committed apply would', async () => { + const dry = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + const committed = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(dry.lint).toEqual(committed.lint) + expect(committed.dryRun).toBe(false) + }) + }) + + it('reports declined operations rather than failing the batch', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [ + { + type: 'duplicate_block_name', + operationType: 'add', + blockId: 'block-2', + reason: 'Name taken', + }, + ], + }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(result.skipped).toHaveLength(1) + expect(result.applied).toBe(0) + expect(mocks.replace).toHaveBeenCalledTimes(1) + }) + + it('separates self-healing deferrals from genuine failures', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [ + { + type: 'invalid_edge_target', + operationType: 'add', + blockId: 'block-2', + reason: 'Target not created yet', + }, + ], + }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(result.skipped).toHaveLength(0) + expect(result.deferred).toHaveLength(1) + }) + + it('aborts an atomic batch before the write and carries the declined operations', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [ + { + type: 'block_locked', + operationType: 'edit', + blockId: 'block-1', + reason: 'Block is locked', + }, + ], + }) + + const failure = await applyWorkflowOperations + .execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, atomic: true }, + }) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(WorkflowOperationsNotAppliedError) + expect((failure as WorkflowOperationsNotAppliedError).code).toBe('conflict') + expect((failure as WorkflowOperationsNotAppliedError).skipped).toHaveLength(1) + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + /** + * The legacy tool threw a bare `Error(MAX_PLAN_REQUIRED)`, which on a public + * surface is an unclassified 500. + */ + it('names the plan capability when the workspace cannot use sandboxes', async () => { + mocks.sandboxAccess.mockResolvedValue(false) + + const failure = await applyWorkflowOperations + .execute({ + principal: sessionPrincipal, + input: { + workflowId: 'workflow-1', + operations: [ + { + operation_type: 'edit', + block_id: 'block-1', + params: { inputs: { sandboxId: 'sandbox-1' } }, + }, + ], + }, + }) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(ForbiddenOperationError) + expect((failure as ForbiddenOperationError).detailCode).toBe( + 'WORKSPACE_PLAN_CAPABILITY_REQUIRED' + ) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('honours a caller-supplied base graph only for a delegated principal', async () => { + const baseGraph = graph({ 'block-9': { ...BLOCK, id: 'block-9' } }) + + await applyWorkflowOperations.execute({ + principal: copilotPrincipal, + input: { workflowId: 'workflow-1', operations, baseGraph }, + }) + expect(mocks.loadNormalized).not.toHaveBeenCalled() + expect(mocks.applyOperations).toHaveBeenCalledWith(baseGraph, operations, null) + + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + mocks.sandboxAccess.mockResolvedValue(true) + mocks.blockVisibility.mockResolvedValue({ revealed: [], disabled: [], previewTagged: [] }) + mocks.permissionConfig.mockResolvedValue(null) + mocks.loadNormalized.mockResolvedValue(graph()) + mocks.normalizeState.mockReturnValue({ state: graph(), warnings: [] }) + mocks.preValidate.mockResolvedValue({ filteredOperations: operations, errors: [] }) + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [], + }) + mocks.collectReferences.mockResolvedValue([]) + mocks.collectToolReferences.mockResolvedValue([]) + mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] }) + mocks.replace.mockResolvedValue({ warnings: [], state: graph() }) + mocks.needsRedeployment.mockResolvedValue(true) + + await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, baseGraph }, + }) + expect(mocks.loadNormalized).toHaveBeenCalledWith('workflow-1') + expect(mocks.applyOperations).not.toHaveBeenCalledWith(baseGraph, operations, null) + }) + + it('applies the block enablement slice and declines a locked block as a skipped item', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph({ 'block-1': { ...BLOCK, locked: true } }), + validationErrors: [], + skippedItems: [], + }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { + workflowId: 'workflow-1', + operations, + blockEnabledChanges: [{ blockId: 'block-1', enabled: false }], + }, + }) + + expect(result.skipped).toEqual([ + expect.objectContaining({ type: 'block_locked', operationType: 'set_block_enabled' }), + ]) + }) + + it('projects audit from the authoritative result and notifies after it', async () => { + await applyWorkflowOperations.execute({ + principal: copilotPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.updated', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ + operation: 'workflows.operations.apply', + op: 'apply_operations', + operationCount: 1, + appliedCount: 1, + skippedCount: 0, + source: 'copilot', + }), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledBefore(mocks.notify) + }) + + it('refuses a locked workflow before loading the graph', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( + new WorkflowLockedError('Workflow is locked') + ) + + await expect( + applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + ).rejects.toMatchObject({ code: 'locked' }) + + expect(mocks.loadNormalized).not.toHaveBeenCalled() + }) + + it('rejects a workspace API key, which this operation denies, before canonical loading', async () => { + await expect( + applyWorkflowOperations.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'ws-key-1' }, + input: { workflowId: 'workflow-1', operations }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + it('rejects a graph the engine produced that does not validate, without writing', async () => { + mocks.validate.mockReturnValue({ + valid: false, + errors: ['Dangling edge'], + warnings: [], + }) + + await expect( + applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + /** + * The enablement slice appends its refusals to the same `skippedItems` array + * the engine uses, so subtracting that array from the operation count charged + * enablement refusals against operations — and could go negative, which + * `Math.max` then hid. + */ + it('does not charge enablement refusals against the operation count', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph({ + 'block-1': { ...BLOCK, locked: true }, + 'block-2': { ...BLOCK, id: 'block-2', locked: true }, + }), + validationErrors: [], + skippedItems: [], + }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { + workflowId: 'workflow-1', + operations, + blockEnabledChanges: [ + { blockId: 'block-1', enabled: false }, + { blockId: 'block-2', enabled: false }, + ], + }, + }) + + expect(result.applied).toBe(1) + expect(result.skipped).toHaveLength(2) + }) + + /** + * `disabled_ancestor` is one of the three protection rules and has its own + * member of the published skip enum; reporting it as `block_locked` told a + * client to unlock a block that was never locked. + */ + it('names a disabled container as the reason rather than calling the block locked', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph({ + 'loop-1': { ...BLOCK, id: 'loop-1', type: 'loop', enabled: false }, + 'block-1': { ...BLOCK, enabled: false, data: { parentId: 'loop-1' } }, + }), + validationErrors: [], + skippedItems: [], + }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { + workflowId: 'workflow-1', + operations, + blockEnabledChanges: [{ blockId: 'block-1', enabled: true }], + }, + }) + + expect(result.skipped).toEqual([ + expect.objectContaining({ type: 'disabled_ancestor', operationType: 'set_block_enabled' }), + ]) + }) + + /** + * A stripped credential is a refusal too. `preValidateCredentialInputs` + * deletes the field rather than failing, so an atomic gate that only reads + * `skipped` would commit a block whose credential silently vanished. + */ + it('refuses an atomic batch whose credential was stripped, and carries the dropped input', async () => { + const dropped = { + blockId: 'block-2', + blockType: 'agent', + field: 'credential', + value: 'cred-9', + error: 'Invalid credential ID', + } + mocks.preValidate.mockResolvedValue({ filteredOperations: operations, errors: [dropped] }) + + const failure = await applyWorkflowOperations + .execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, atomic: true }, + }) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(WorkflowOperationsNotAppliedError) + expect((failure as WorkflowOperationsNotAppliedError).droppedInputs).toEqual([dropped]) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + /** + * `collectUnresolvedReferences` is read-only: the values it flags stay + * persisted. Reporting them as `inputValidationErrors` — documented as inputs + * "dropped rather than persisted" — double-reported them, and falsely. + */ + it('reports an unresolved reference only in lint, never as a dropped input', async () => { + const reference = { + blockId: 'block-2', + blockType: 'agent', + field: 'credential', + value: 'cred-9', + kind: 'credential' as const, + reason: 'Credential not accessible', + } + mocks.collectReferences.mockResolvedValue([reference]) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(result.lint.unresolvedReferences).toEqual([reference]) + expect(result.inputValidationErrors).toEqual([]) + expect(mocks.replace).toHaveBeenCalledTimes(1) + }) + + it('does not refuse an atomic batch for a reference that stays persisted', async () => { + mocks.collectReferences.mockResolvedValue([ + { + blockId: 'block-2', + blockType: 'agent', + field: 'credential', + value: 'cred-9', + kind: 'credential' as const, + reason: 'Credential not accessible', + }, + ]) + + await expect( + applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, atomic: true }, + }) + ).resolves.toMatchObject({ applied: 1 }) + }) +}) diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts new file mode 100644 index 00000000000..32909992679 --- /dev/null +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -0,0 +1,476 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { ForbiddenOperationError, principalAuditSource } from '@/lib/core/application' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { + type ActiveWorkflowApplicationContext, + resolveActiveWorkflowApplicationContext, +} from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireMutableWorkflow } from '@/lib/workflows/application/workflow-mutability' +import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' +import { + applyTargetedLayout, + getTargetedLayoutImpact, + transferBlockHeights, +} from '@/lib/workflows/autolayout' +import { + DEFAULT_HORIZONTAL_SPACING, + DEFAULT_VERTICAL_SPACING, +} from '@/lib/workflows/autolayout/constants' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import { + type BlockEnablementRefusal, + decideBlockEnablement, +} from '@/lib/workflows/editing/block-enablement' +import { applyOperationsToWorkflowState } from '@/lib/workflows/editing/engine' +import type { WorkflowLintReport } from '@/lib/workflows/editing/lint' +import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report' +import { operationsReferenceSimSandbox } from '@/lib/workflows/editing/sandbox-projection' +import { + type EditWorkflowOperation, + isDeferredSkippedItem, + type SkippedItem, + type SkippedItemType, + type ValidationError, +} from '@/lib/workflows/editing/types' +import { preValidateCredentialInputs } from '@/lib/workflows/editing/validation' +import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' +import { withBlockVisibility } from '@/blocks/visibility/server-context' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation' + +const logger = createLogger('ApplyWorkflowOperations') + +/** One enable/disable request riding along with an edit batch. */ +export interface WorkflowBlockEnabledChange { + blockId: string + enabled: boolean +} + +export interface ApplyWorkflowOperationsInput { + workflowId: string + assertedWorkspaceId?: string + operations: EditWorkflowOperation[] + /** Refuse the whole batch, writing nothing, when any operation is declined. */ + atomic?: boolean + /** `none` keeps every supplied position exactly as given. */ + layout?: 'targeted' | 'none' + blockEnabledChanges?: WorkflowBlockEnabledChange[] + /** + * A caller-supplied base graph to edit against instead of the stored one. + * + * Only ever honoured for a `delegated` principal: it is how Copilot edits the + * unsaved canvas a user is looking at. On any credential-authenticated surface + * it would be an authoritative-state substitute supplied by the caller, which + * is exactly what an application use case must never infer from its arguments. + */ + baseGraph?: Record + /** Cancellation checkpoint, invoked before each step that commits work. */ + checkAborted?: () => void + /** + * Apply the batch to an in-memory graph and report the outcome without + * persisting it. Same response as a committed apply of the same body. + */ + dryRun?: boolean +} + +export interface ApplyWorkflowOperationsResult { + workflowId: string + workflowName: string + workspaceId: string + graph: { + blocks: Record + edges: WorkflowState['edges'] + loops: ReturnType + parallels: ReturnType + } + operationCount: number + applied: number + skipped: SkippedItem[] + deferred: SkippedItem[] + inputValidationErrors: ValidationError[] + /** Requested `block_id` -> the id the block was given, when they differ. */ + mintedBlockIds: Record + lint: WorkflowLintReport + warnings: string[] + needsRedeployment: boolean + /** True when nothing was persisted because the caller asked for a dry run. */ + dryRun: boolean +} + +/** + * The engine models a graph as an open record; the layout helpers want the + * canonical shape. One conversion, named, rather than a cast at each call. + */ +function asGraph(value: Record): Pick { + // double-cast-allowed: the edit engine models a graph as an open record; this is the one place it is read back as the canonical shape, and the layout helpers tolerate missing keys. + return value as unknown as Pick +} + +async function loadStoredGraph(workflowId: string): Promise> { + const normalized = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalized) { + throw new OrchestrationError('validation', `Workflow ${workflowId} has no normalized state`) + } + const { state, warnings } = normalizeWorkflowState({ + blocks: normalized.blocks, + edges: normalized.edges, + loops: normalized.loops || {}, + parallels: normalized.parallels || {}, + }) + if (warnings.length > 0) { + logger.warn('Stored workflow state needed normalization before editing', { + workflowId, + warnings, + }) + } + // double-cast-allowed: the edit engine takes an open record; `normalizeWorkflowState` returns the canonical interface, which has no index signature. + return state as unknown as Record +} + +/** + * How many of the engine's skips charge against the operation batch. + * + * The enablement slice appends to the same array, and a deferred forward + * reference is not a refusal at all, so neither may be subtracted from the + * operation count. + */ +function countOperationSkips(skippedItems: readonly SkippedItem[]): number { + let count = 0 + for (const item of skippedItems) { + if (item.operationType !== 'set_block_enabled' && !isDeferredSkippedItem(item)) count += 1 + } + return count +} + +/** + * How each enablement refusal maps onto the machine-readable skip enum. Kept as + * a total `Record` so a new refusal reason fails to compile until it is + * classified, and kept aligned with `BLOCK_ENABLEMENT_REFUSAL_CODES`, which + * makes the same three-way distinction for the single-toggle operation. + */ +const BLOCK_ENABLEMENT_SKIPPED_ITEM_TYPES: Record< + BlockEnablementRefusal['reason'], + SkippedItemType +> = { + not_found: 'block_not_found', + locked: 'block_locked', + disabled_ancestor: 'disabled_ancestor', +} + +/** + * Applies the enable/disable slice of a batch in memory. + * + * A refusal is recorded as a skipped item rather than thrown, so the slice + * follows the same best-effort contract as the operations beside it and a single + * protected block cannot silently discard an otherwise valid batch. + */ +function applyBlockEnabledChanges( + blocks: Record, + changes: readonly WorkflowBlockEnabledChange[], + skippedItems: SkippedItem[] +): { blocks: Record; applied: number } { + let current = blocks + let applied = 0 + for (const change of changes) { + const decision = decideBlockEnablement(current, change.blockId, change.enabled) + if (decision.outcome === 'refused') { + skippedItems.push({ + type: BLOCK_ENABLEMENT_SKIPPED_ITEM_TYPES[decision.refusal.reason], + operationType: 'set_block_enabled', + blockId: change.blockId, + reason: decision.refusal.message, + }) + continue + } + if (decision.outcome === 'changed') { + current = decision.blocks + } + applied += 1 + } + return { blocks: current, applied } +} + +async function resolveBaseGraph( + principal: Principal, + input: ApplyWorkflowOperationsInput, + context: ActiveWorkflowApplicationContext +): Promise> { + if (input.baseGraph && principal.kind === 'delegated') return input.baseGraph + return loadStoredGraph(context.workflowId) +} + +/** + * The one semantic edit operation on a workflow graph. + * + * Best-effort at the operation level and atomic at the persistence level: the + * engine applies what it can to an in-memory graph and records the rest as typed + * skipped items, and exactly one write of the fully-resolved graph happens at the + * end, through the shared persistence primitive. A caller that needs all-or-nothing + * sets `atomic`, which decides between the in-memory apply and that single write. + * + * Copilot's `edit_workflow` tool and `POST /api/v2/workflows/{workflowId}/operations` are + * both adapters over this; the tool is the only caller allowed to supply + * `baseGraph`. + */ +export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.applyOperations, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ApplyWorkflowOperationsInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }): Promise { + if (input.operations.length === 0) { + throw new OrchestrationError('validation', 'operations cannot be empty') + } + await requireMutableWorkflow(context.workflowId) + + if ( + operationsReferenceSimSandbox(input.operations) && + !(await hasWorkspaceSandboxAccess(context.workspaceId)) + ) { + throw new ForbiddenOperationError('WORKSPACE_PLAN_CAPABILITY_REQUIRED', MAX_PLAN_REQUIRED) + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const subjectUserId = attribution.attributedUserId + + input.checkAborted?.() + const baseGraph = await resolveBaseGraph(principal, input, context) + + const [permissionConfig, blockVisibility] = await Promise.all([ + getUserPermissionConfig(subjectUserId, context.workspaceId), + getBlockVisibility({ userId: subjectUserId, orgId: context.workspaceOrganizationId }), + ]) + + const { filteredOperations, errors: credentialErrors } = await preValidateCredentialInputs( + input.operations, + { userId: subjectUserId, workspaceId: context.workspaceId }, + baseGraph + ) + + const { + state: modifiedGraph, + validationErrors, + skippedItems, + mintedBlockIds, + } = await withBlockVisibility(blockVisibility, async () => + applyOperationsToWorkflowState(baseGraph, filteredOperations, permissionConfig) + ) + validationErrors.push(...credentialErrors) + + /** + * Counted directly rather than as `operations - skipped`. The enablement + * slice pushes its own refusals into the same `skippedItems` array, so + * subtracting the whole array from the operation count charged enablement + * refusals against operations and could go negative. + */ + const appliedOperations = filteredOperations.length - countOperationSkips(skippedItems) + + const enablement = applyBlockEnabledChanges( + modifiedGraph.blocks as Record, + input.blockEnabledChanges ?? [], + skippedItems + ) + modifiedGraph.blocks = enablement.blocks + const applied = appliedOperations + enablement.applied + + const validation = validateWorkflowState(modifiedGraph, { sanitize: true }) + if (!validation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid edited workflow: ${validation.errors.join('; ')}` + ) + } + + const genuineSkippedItems = skippedItems.filter((item) => !isDeferredSkippedItem(item)) + const deferredItems = skippedItems.filter(isDeferredSkippedItem) + /** + * A dropped input refuses the batch as surely as a declined operation does. + * `preValidateCredentialInputs` and the engine both delete fields rather + * than fail, so an atomic batch that only reads `skipped` would commit a + * block whose credential or API key was silently stripped — the opposite of + * what all-or-nothing promises. + */ + if (input.atomic && (genuineSkippedItems.length > 0 || validationErrors.length > 0)) { + throw new WorkflowOperationsNotAppliedError(genuineSkippedItems, validationErrors) + } + + const finalGraph = validation.sanitizedState || modifiedGraph + const blocks: Record = + input.layout === 'none' + ? (finalGraph.blocks as Record) + : layoutChangedBlocks(context.workflowId, asGraph(baseGraph), asGraph(finalGraph)) + + const graph = { + blocks, + edges: finalGraph.edges as WorkflowState['edges'], + loops: generateLoopBlocks(blocks), + parallels: generateParallelBlocks(blocks), + } + + /** + * Linted on the graph that is about to be persisted, so every finding + * describes what the caller will actually have. This operation denies + * workspace API keys, so the acting principal always has a human subject. + */ + const lint = await buildWorkflowLintReport(graph, { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + subjectUserId, + }) + + input.checkAborted?.() + + /** + * A dry run still runs the whole engine — operations are applied, refusals + * collected, the result validated and linted — and stops at the write. An + * atomic batch has already thrown by here if anything was refused, so a + * dry run reports precisely what a committed apply of the same body would. + */ + if (input.dryRun) { + logger.info('Evaluated workflow operations without persisting', { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + operationCount: input.operations.length, + applied, + principalKind: principal.kind, + }) + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + graph, + operationCount: input.operations.length, + applied, + skipped: genuineSkippedItems, + deferred: deferredItems, + inputValidationErrors: validationErrors, + mintedBlockIds, + lint, + warnings: validation.warnings, + needsRedeployment: await checkNeedsRedeployment(context.workflowId), + dryRun: true, + } + } + + const persisted = await replaceWorkflowNormalizedState({ + workflowId: context.workflowId, + workspaceId: context.workspaceId, + attributedUserId: subjectUserId, + state: { blocks: graph.blocks, edges: graph.edges }, + }) + + logger.info('Applied workflow operations', { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + operationCount: input.operations.length, + applied, + skipped: genuineSkippedItems.length, + principalKind: principal.kind, + }) + + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + graph, + operationCount: input.operations.length, + applied, + skipped: genuineSkippedItems, + deferred: deferredItems, + inputValidationErrors: validationErrors, + mintedBlockIds, + lint, + warnings: [...validation.warnings, ...persisted.warnings], + needsRedeployment: await checkNeedsRedeployment(context.workflowId), + dryRun: false, + } + }, + /** A dry run changes nothing, so it projects no audit entry. */ + projectAudit: ({ principal, context, result }) => + result.dryRun + ? [] + : ({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflowName, + description: `Applied ${result.operationCount} edit operation(s) to workflow "${result.workflowName}"`, + metadata: { + op: 'apply_operations', + operationCount: result.operationCount, + appliedCount: result.applied, + skippedCount: result.skipped.length, + blocksCount: Object.keys(result.graph.blocks).length, + edgesCount: result.graph.edges.length, + source: principalAuditSource(principal), + }, + } as const), + afterSuccess: ({ context, result }) => { + if (result.dryRun) return + return notifyWorkflowUpdated(context.workflowId) + }, +}) + +/** + * Nudges only the blocks this batch touched, leaving the rest of the canvas + * where the user put it. A layout failure is never fatal: the graph is already + * correct, only its positions are less tidy. + */ +function layoutChangedBlocks( + workflowId: string, + before: Pick, + after: Pick +): Record { + const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({ + before, + after, + }) + if ( + layoutBlockIds.length === 0 && + resizedBlockIds.length === 0 && + shiftSourceBlockIds.length === 0 + ) { + return after.blocks + } + try { + transferBlockHeights(before.blocks, after.blocks) + return applyTargetedLayout(after.blocks, after.edges, { + changedBlockIds: layoutBlockIds, + resizedBlockIds, + shiftSourceBlockIds, + horizontalSpacing: DEFAULT_HORIZONTAL_SPACING, + verticalSpacing: DEFAULT_VERTICAL_SPACING, + previousBlocks: before.blocks, + }) as Record + } catch (error) { + logger.warn('Targeted autolayout failed, using supplied positions', { + workflowId, + error: toError(error).message, + }) + return after.blocks + } +} diff --git a/apps/sim/lib/workflows/application/chat-deployments.ts b/apps/sim/lib/workflows/application/chat-deployments.ts index 5c6a653681e..fa125332cfe 100644 --- a/apps/sim/lib/workflows/application/chat-deployments.ts +++ b/apps/sim/lib/workflows/application/chat-deployments.ts @@ -5,8 +5,16 @@ import { resolvePrincipalAttribution, toPrincipalActor, } from '@sim/auth/principal' -import { chat, db } from '@sim/db' -import { and, eq, isNull } from 'drizzle-orm' +import { + ChatIdentifierInUseError, + chatIdentifierUniquenessConflict, +} from '@/lib/chat-deployments/application/errors' +import { toChatDeploymentView } from '@/lib/chat-deployments/application/read-chat-deployments' +import { + getChatDeploymentIdOwningIdentifier, + getLiveChatDeploymentForWorkflow, +} from '@/lib/chat-deployments/queries' +import { ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' @@ -32,8 +40,13 @@ export interface DeployWorkflowChatInput { identifier?: string title?: string description?: string - versionDescription: string - versionName: string + /** + * Optional because only the Copilot surface accepts them: `deploy_as_chat` + * requires both and refuses the tool call without them, while neither HTTP + * create contract declares a way to send one. + */ + versionDescription?: string + versionName?: string customizations?: ChatCustomizations authType?: ChatAuthType password?: string | null @@ -86,11 +99,7 @@ export const deployWorkflowChat = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.deployChat, resolveContext: resolveWorkflowContext, async execute({ principal, input, context }) { - const [existingDeployment] = await db - .select() - .from(chat) - .where(and(eq(chat.workflowId, context.workflowId), isNull(chat.archivedAt))) - .limit(1) + const existingDeployment = await getLiveChatDeploymentForWorkflow(context.workflowId) const identifier = (input.identifier || existingDeployment?.identifier || '').trim() const title = (input.title || existingDeployment?.title || '').trim() @@ -104,13 +113,9 @@ export const deployWorkflowChat = defineAuthorizedWorkflowUseCase({ ) } - const [identifierOwner] = await db - .select({ id: chat.id }) - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1) - if (identifierOwner && identifierOwner.id !== existingDeployment?.id) { - throw new OrchestrationError('conflict', 'Identifier already in use') + const identifierOwnerId = await getChatDeploymentIdOwningIdentifier(identifier) + if (identifierOwnerId && identifierOwnerId !== existingDeployment?.id) { + throw new ChatIdentifierInUseError() } const existingCustomizations = @@ -139,13 +144,28 @@ export const deployWorkflowChat = defineAuthorizedWorkflowUseCase({ : {}), } + /** + * An email- or SSO-gated chat with an empty allow-list is unenterable: the + * login form has nothing to match, so the deployment fails closed for + * everyone. Enforced here rather than at the HTTP boundary because the + * Copilot tool reaches this use case without one. + */ + if ((authType === 'email' || authType === 'sso') && allowedEmails.length === 0) { + throw new OrchestrationError( + 'validation', + authType === 'email' + ? 'At least one email or domain is required when using email access control' + : 'At least one email or domain is required when using SSO access control' + ) + } + const subjectUserId = requirePrincipalSubjectUserId(principal) if (authType !== existingDeployment?.authType) { try { await validateChatDeployAuth(subjectUserId, context.workspaceId, authType) } catch (error) { if (error instanceof ChatDeployAuthNotAllowedError) { - throw new OrchestrationError('forbidden', error.message) + throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) } throw error } @@ -178,14 +198,35 @@ export const deployWorkflowChat = defineAuthorizedWorkflowUseCase({ ...(principal.kind === 'delegated' ? { captureDeploymentAnalytics: false as const, captureLegacyTelemetry: false } : {}), - }) - if (!result.success || !result.chatId || !result.chatUrl) { - throw new OrchestrationError('validation', result.error ?? 'Failed to deploy chat') + }).catch(chatIdentifierUniquenessConflict(identifier)) + if (!result.success) { + /** + * Classified by the orchestration rather than flattened to a `400`: an + * in-flight deployment is a `409` the caller can retry, and an invariant + * failure is a `500` rather than a claim that the request was malformed. + */ + const message = result.error ?? 'Failed to deploy chat' + if (!result.errorCode || result.errorCode === 'internal') throw new Error(message) + throw new OrchestrationError(result.errorCode, message) + } + if (!result.chatId || !result.chatUrl) { + throw new Error('Chat deployment succeeded without a chat id or URL') + } + /** + * Re-read the settled row so callers present what was actually stored + * rather than what was requested — the orchestration normalizes several + * fields (the gate columns, the customization defaults) on the way in. + */ + const deployment = await getLiveChatDeploymentForWorkflow(context.workflowId) + if (!deployment) { + throw new Error('Chat deployment succeeded without leaving a deployment row') } return { ...result, chatId: result.chatId, chatUrl: result.chatUrl, + deployment: toChatDeploymentView(deployment), + workspaceId: context.workspaceId, workflowId: context.workflowId, identifier, title, @@ -220,11 +261,7 @@ export const undeployWorkflowChat = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.undeployChat, resolveContext: resolveWorkflowContext, async execute({ principal, context }) { - const [deployment] = await db - .select() - .from(chat) - .where(and(eq(chat.workflowId, context.workflowId), isNull(chat.archivedAt))) - .limit(1) + const deployment = await getLiveChatDeploymentForWorkflow(context.workflowId) if (!deployment) { throw new OrchestrationError('not_found', 'No active chat deployment found for this workflow') } @@ -239,9 +276,12 @@ export const undeployWorkflowChat = defineAuthorizedWorkflowUseCase({ projectLegacyAudit: false, }) if (!result.success) { - throw new OrchestrationError('not_found', result.error ?? 'Failed to undeploy chat') + /** Only a genuinely absent deployment is concealed; anything else propagates. */ + const message = result.error ?? 'Failed to undeploy chat' + if (result.errorCode !== 'not_found') throw new Error(message) + throw new OrchestrationError('not_found', message) } - return { workflowId: context.workflowId, deployment } + return { workflowId: context.workflowId, deployment: toChatDeploymentView(deployment) } }, projectAudit: ({ result }) => ({ action: AuditAction.CHAT_DELETED, diff --git a/apps/sim/lib/workflows/application/context.test.ts b/apps/sim/lib/workflows/application/context.test.ts index d30b4afa24a..8a31c794da3 100644 --- a/apps/sim/lib/workflows/application/context.test.ts +++ b/apps/sim/lib/workflows/application/context.test.ts @@ -18,7 +18,6 @@ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ import { resolveActiveWorkflowApplicationContext, resolveActiveWorkflowRunApplicationContext, - resolveActiveWorkspaceApplicationContext, } from '@/lib/workflows/application/context' const workspace = { @@ -43,11 +42,6 @@ describe('workflow application contexts', () => { mocks.getJobQueue.mockResolvedValue({ getJob: mocks.getJob }) }) - it('uses the canonical loader for workspace-scoped operations', async () => { - await expect(resolveActiveWorkspaceApplicationContext('workspace-1')).resolves.toBe(workspace) - expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') - }) - it('derives workflow authorization from its canonical active workspace', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, diff --git a/apps/sim/lib/workflows/application/context.ts b/apps/sim/lib/workflows/application/context.ts index 49b7c657468..1d4f928cce7 100644 --- a/apps/sim/lib/workflows/application/context.ts +++ b/apps/sim/lib/workflows/application/context.ts @@ -4,10 +4,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { getJobQueue } from '@/lib/core/async-jobs' import { OrchestrationError } from '@/lib/core/orchestration/types' import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' -import { - type ActiveWorkspaceApplicationContext, - loadActiveWorkspaceApplicationContext, -} from '@/lib/workspaces/application/workspace-context' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ActiveWorkflowApplicationContext { workflowId: string @@ -22,14 +19,6 @@ export interface ActiveWorkflowRunApplicationContext extends ActiveWorkflowAppli runId: string } -export async function resolveActiveWorkspaceApplicationContext( - workspaceId: string -): Promise { - const context = await loadActiveWorkspaceApplicationContext(workspaceId) - if (!context) throw new OrchestrationError('not_found', 'Workspace not found') - return context -} - export async function resolveActiveWorkflowApplicationContext(input: { workflowId: string assertedWorkspaceId?: string @@ -58,6 +47,41 @@ export async function resolveActiveWorkflowApplicationContext(input: { return { ...workspaceContext, ...canonicalWorkflow, workspaceId: workspaceContext.workspaceId } } +/** + * Canonical context for a workflow that may be archived. + * + * Separate from {@link resolveActiveWorkflowApplicationContext}, which excludes + * archived rows by construction — a restore has to reach exactly the rows that + * one hides. + */ +export async function resolveArchivedWorkflowApplicationContext(input: { + workflowId: string + assertedWorkspaceId?: string +}): Promise { + const [canonicalWorkflow] = await db + .select({ + workflowId: workflow.id, + workflow, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, input.workflowId)) + .limit(1) + + if ( + !canonicalWorkflow?.workspaceId || + (input.assertedWorkspaceId !== undefined && + input.assertedWorkspaceId !== canonicalWorkflow.workspaceId) + ) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + const workspaceContext = await loadActiveWorkspaceApplicationContext( + canonicalWorkflow.workspaceId + ) + if (!workspaceContext) throw new OrchestrationError('not_found', 'Workflow not found') + return { ...workspaceContext, ...canonicalWorkflow, workspaceId: workspaceContext.workspaceId } +} + async function resolveCanonicalRunWorkflowId(runId: string): Promise { const [logRows, pausedRows, resumeRows] = await Promise.all([ db diff --git a/apps/sim/lib/workflows/application/create-workflow.ts b/apps/sim/lib/workflows/application/create-workflow.ts index 5e9db561f28..53580cd0e21 100644 --- a/apps/sim/lib/workflows/application/create-workflow.ts +++ b/apps/sim/lib/workflows/application/create-workflow.ts @@ -8,7 +8,6 @@ import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' import { @@ -17,6 +16,7 @@ import { } from '@/lib/workflows/application/workflow-folders' import { performCreateWorkflowTransition } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' const logger = createLogger('CreateWorkflow') diff --git a/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts b/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts new file mode 100644 index 00000000000..58ec147eee7 --- /dev/null +++ b/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getRunFiles: vi.fn(), + downloadFileStream: vi.fn(), + resolvePermission: vi.fn(), + resolveRunContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowRunApplicationContext: mocks.resolveRunContext, +})) + +vi.mock('@/lib/workflows/executor/execution-run-files', () => ({ + getWorkflowRunFiles: mocks.getRunFiles, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mocks.downloadFileStream, +})) + +import { Readable } from 'node:stream' +import { downloadWorkflowRunFileStream } from '@/lib/workflows/application/download-workflow-run-file' + +const WORKFLOW_ID = 'workflow-1' +const RUN_ID = 'run-1' +const FILE_ID = 'file_report' +const FILE_KEY = 'execution/workspace-1/workflow-1/run-1/report.pdf' + +const runContext = { + workflowId: WORKFLOW_ID, + workflow: { id: WORKFLOW_ID }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + runId: RUN_ID, +} + +const principals: Principal[] = [ + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-personal' }, + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-workspace' }, + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, +] + +const workspaceKeyPrincipal = principals[2] + +function runFile(overrides: Record = {}) { + return { + id: FILE_ID, + name: 'report.pdf', + url: `/api/files/serve/s3/${encodeURIComponent(FILE_KEY)}`, + size: 3, + type: 'application/pdf', + key: FILE_KEY, + ...overrides, + } +} + +function terminalRun(files: Record[] = [runFile()]) { + return { + terminal: true, + workspaceId: 'workspace-1', + filesById: new Map(files.map((file) => [file.id as string, file])), + } +} + +function input(overrides: Record = {}) { + return { workflowId: WORKFLOW_ID, runId: RUN_ID, fileId: FILE_ID, ...overrides } +} + +describe('downloadWorkflowRunFileStream', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveRunContext.mockResolvedValue(runContext) + mocks.getRunFiles.mockResolvedValue(terminalRun()) + mocks.downloadFileStream.mockResolvedValue(Readable.from([Buffer.from('pdf')])) + }) + + it.each(principals)('allows $kind at the read role', async (principal) => { + const result = await downloadWorkflowRunFileStream.execute({ principal, input: input() }) + + expect(result.file.id).toBe(FILE_ID) + expect(result.contentType).toBe('application/pdf') + expect(result.contentLength).toBe(3) + }) + + it('denies a principal below the read role', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: principals[0], input: input() }) + ).rejects.toThrow() + expect(mocks.downloadFileStream).not.toHaveBeenCalled() + }) + + /** + * The key-derivation invariant: the storage key handed to the object store is + * read off the run's own recording, so a caller can only ever reach bytes the + * addressed run produced. + */ + it('takes the storage key from the run record, not the request', async () => { + await downloadWorkflowRunFileStream.execute({ + principal: workspaceKeyPrincipal, + input: input({ key: 'execution/other-workspace/wf/run/secret.pdf' } as never), + }) + + expect(mocks.downloadFileStream).toHaveBeenCalledWith({ + key: FILE_KEY, + context: 'execution', + }) + }) + + it('resolves the run canonically before authorizing or reading', async () => { + mocks.resolveRunContext.mockRejectedValueOnce( + Object.assign(new Error('Run not found'), { code: 'not_found' }) + ) + + await expect( + downloadWorkflowRunFileStream.execute({ + principal: workspaceKeyPrincipal, + input: input({ workflowId: 'other-workflow' }), + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getRunFiles).not.toHaveBeenCalled() + }) + + it('reports an unknown run as not found', async () => { + mocks.getRunFiles.mockResolvedValueOnce(null) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: workspaceKeyPrincipal, input: input() }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + }) + + /** + * A file id belonging to a different run of the same workflow must not + * resolve — the run's own recording is the only index consulted. + */ + it('reports a file id absent from this run as not found', async () => { + mocks.getRunFiles.mockResolvedValueOnce(terminalRun([runFile({ id: 'file_other_run' })])) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: workspaceKeyPrincipal, input: input() }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + expect(mocks.downloadFileStream).not.toHaveBeenCalled() + }) + + /** Unknown run and unknown file share one message so neither can be probed. */ + it('uses one message for an unknown run and an unknown file', async () => { + mocks.getRunFiles.mockResolvedValueOnce(null) + const unknownRun = await downloadWorkflowRunFileStream + .execute({ principal: workspaceKeyPrincipal, input: input() }) + .catch((error: Error) => error.message) + + mocks.getRunFiles.mockResolvedValueOnce(terminalRun([])) + const unknownFile = await downloadWorkflowRunFileStream + .execute({ principal: workspaceKeyPrincipal, input: input() }) + .catch((error: Error) => error.message) + + expect(unknownRun).toBe(unknownFile) + }) + + it('reports a run still in flight as a conflict', async () => { + mocks.getRunFiles.mockResolvedValueOnce({ + terminal: false, + workspaceId: 'workspace-1', + filesById: new Map(), + }) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: workspaceKeyPrincipal, input: input() }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.downloadFileStream).not.toHaveBeenCalled() + }) + + /** Storage failure is infrastructure, not a missing resource. */ + it('propagates a storage failure rather than concealing it as not found', async () => { + mocks.downloadFileStream.mockRejectedValueOnce(new Error('s3 unavailable')) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: workspaceKeyPrincipal, input: input() }) + ).rejects.toThrow('s3 unavailable') + }) + + /** + * A run's log row outlives its bytes, so an object retention has collected is + * reachable through a perfectly valid run and file id. It resolves to the same + * shared not-found message as an unknown id, deliberately: separating them + * would tell a caller which ids exist. + */ + it.each(['NoSuchKey', 'BlobNotFound', 'NotFound'])( + 'reports a swept object (%s) as not found rather than a fault', + async (name) => { + mocks.downloadFileStream.mockRejectedValueOnce(Object.assign(new Error('gone'), { name })) + + await expect( + downloadWorkflowRunFileStream.execute({ + principal: workspaceKeyPrincipal, + input: input(), + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + it('falls back to a generic content type when the record has none', async () => { + mocks.getRunFiles.mockResolvedValueOnce(terminalRun([runFile({ type: '' })])) + + const result = await downloadWorkflowRunFileStream.execute({ + principal: workspaceKeyPrincipal, + input: input(), + }) + + expect(result.contentType).toBe('application/octet-stream') + }) +}) diff --git a/apps/sim/lib/workflows/application/download-workflow-run-file.ts b/apps/sim/lib/workflows/application/download-workflow-run-file.ts new file mode 100644 index 00000000000..397a5037fd4 --- /dev/null +++ b/apps/sim/lib/workflows/application/download-workflow-run-file.ts @@ -0,0 +1,132 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { + type ActiveWorkflowRunApplicationContext, + resolveActiveWorkflowRunApplicationContext, +} from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { getWorkflowRunFiles } from '@/lib/workflows/executor/execution-run-files' +import { classifyRunFileStorageError } from '@/lib/workflows/executor/run-file-storage-error' +import type { UserFile } from '@/executor/types' + +/** + * One message for every way a file fails to resolve — unknown run, unknown file + * id, a file id belonging to a different run, or an object the retention sweep + * has already collected. Distinguishing them would let a caller probe which run + * ids and file ids exist. + */ +const FILE_NOT_FOUND_MESSAGE = 'File not found' + +export interface DownloadWorkflowRunFileInput { + workflowId: string + runId: string + fileId: string +} + +export interface DownloadWorkflowRunFileResult { + file: UserFile + stream: ReadableStream + contentType: string + contentLength: number +} + +async function executeDownloadWorkflowRunFile({ + context, + input, +}: AuthorizedWorkspaceUseCaseContext< + typeof workflowOperations.downloadRunFile, + DownloadWorkflowRunFileInput, + ActiveWorkflowRunApplicationContext +>): Promise { + const runFiles = await getWorkflowRunFiles({ + workflowId: context.workflowId, + runId: context.runId, + }) + if (!runFiles) throw new OrchestrationError('not_found', FILE_NOT_FOUND_MESSAGE) + + /** + * A run still in flight has no settled output, so there is nothing + * authoritative to address yet. This is retryable rather than a fault. + */ + if (!runFiles.terminal) { + throw new OrchestrationError( + 'conflict', + 'Run has not finished yet; its output files are available once it reaches a terminal state.' + ) + } + + /** + * The caller's `fileId` selects a record; it never supplies one. `key` and + * `context` come off the run's own recording, so the bytes served are always + * bytes this run produced. + */ + const file = runFiles.filesById.get(input.fileId) + if (!file) throw new OrchestrationError('not_found', FILE_NOT_FOUND_MESSAGE) + + /** + * The storage context is inferred from the key rather than read off the + * record's `context` field, so the bucket a read targets is always the one + * the key itself names. This mirrors `getVerifiedStorageContext`, which + * treats a recorded context that disagrees with its key as untrustworthy. + */ + let stream: Awaited> + try { + stream = await downloadFileStream({ + key: file.key, + context: inferContextFromKey(file.key), + }) + } catch (error) { + /** + * An object retention has already collected is one of the four ways this + * read fails to resolve, and the shared message covers it deliberately — + * naming the storage cause here would separate "no such file" from "the + * bytes are gone", which is exactly the probe the single message prevents. + */ + throw classifyRunFileStorageError(error, FILE_NOT_FOUND_MESSAGE) + } + + return { + file, + stream: nodeReadableToWebStream(stream), + contentType: file.type || 'application/octet-stream', + contentLength: file.size, + } +} + +/** + * Authorized, audited binary download of one file a run produced. + * + * Authorization is the run's: `resolveActiveWorkflowRunApplicationContext` + * binds the run to its canonical workflow and workspace before the operation's + * role and workspace-key policy are applied, so a workspace-scoped key can only + * ever reach runs inside its own workspace. The file is then resolved against + * that run's recording rather than against any caller-supplied storage address. + */ +export const downloadWorkflowRunFileStream = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.downloadRunFile, + resolveContext: ({ input }: { input: DownloadWorkflowRunFileInput }) => + resolveActiveWorkflowRunApplicationContext({ + runId: input.runId, + assertedWorkflowId: input.workflowId, + }), + execute: executeDownloadWorkflowRunFile, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Downloaded run file "${result.file.name}"`, + metadata: { + fileId: result.file.id, + fileName: result.file.name, + bytes: result.contentLength, + workflowId: context.workflowId, + runId: context.runId, + }, + }), +}) diff --git a/apps/sim/lib/workflows/application/duplicate-workflow.ts b/apps/sim/lib/workflows/application/duplicate-workflow.ts index fd6f01cf884..a1a3abe0902 100644 --- a/apps/sim/lib/workflows/application/duplicate-workflow.ts +++ b/apps/sim/lib/workflows/application/duplicate-workflow.ts @@ -1,19 +1,32 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { db } from '@sim/db' +import { FolderLockedError } from '@sim/platform-authz/workflow' +import { principalAuditSource } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' import { duplicateWorkflow as duplicateWorkflowRecord } from '@/lib/workflows/persistence/duplicate' export interface DuplicateWorkflowInput { sourceWorkflowId: string assertedWorkspaceId?: string - folderId: string | null - name: string + /** Canonical destination folder. Mutually exclusive with `folderPath`. */ + folderId?: string | null + /** Destination folder by path, resolved against the workspace's folder tree. */ + folderPath?: string + /** Defaults to the source workflow's name, deduplicated within the destination folder. */ + name?: string } export const duplicateWorkflow = defineAuthorizedWorkflowUseCase({ @@ -24,28 +37,68 @@ export const duplicateWorkflow = defineAuthorizedWorkflowUseCase({ assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), }), async execute({ principal, input, context }) { + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Provide either folderPath or folderId, not both') + } + const resolution = + input.folderPath === undefined + ? { + folderId: input.folderId === undefined ? context.workflow.folderId : input.folderId, + index: await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }), + } + : await resolveWorkflowFolderPath(context.workspaceId, input.folderPath) + if (resolution.folderId && !resolution.index.pathById.has(resolution.folderId)) { + throw new OrchestrationError('not_found', 'Folder not found') + } + const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) - return db.transaction((tx) => - duplicateWorkflowRecord({ - sourceWorkflowId: context.workflowId, - userId: attribution.attributedUserId, - workspaceId: context.workspaceId, - folderId: input.folderId, - name: input.name, - requestId: generateRequestId(), - tx, + /** + * `assertTargetFolderMutable` walks the destination's ancestors and raises + * {@link FolderLockedError}, a plain `Error` carrying `status = 423` rather + * than an `OrchestrationError`. The v2 error policy classifies only + * `OrchestrationError` and `HttpError`, so propagating it verbatim rendered + * a `500` for a well-formed request against a locked folder — the defect + * class this surface treats as most severe. Converted here, matching + * `requireMutable` on the bulk-move path. + */ + const duplicated = await db + .transaction((tx) => + duplicateWorkflowRecord({ + sourceWorkflowId: context.workflowId, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + folderId: resolution.folderId, + name: input.name ?? context.workflow.name, + requestId: generateRequestId(), + tx, + }) + ) + .catch((error: unknown) => { + if (error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error }) - ) + return { + ...duplicated, + folderPath: workflowFolderPathForId(resolution.index, duplicated.folderId), + } }, - projectAudit: ({ context, result }) => ({ + projectAudit: ({ principal, context, result }) => ({ action: AuditAction.WORKFLOW_DUPLICATED, resourceType: AuditResourceType.WORKFLOW, resourceId: result.id, resourceName: result.name, description: `Duplicated workflow "${context.workflow.name}" as "${result.name}"`, - metadata: { sourceWorkflowId: context.workflowId, workspaceId: context.workspaceId }, + metadata: { + sourceWorkflowId: context.workflowId, + workspaceId: context.workspaceId, + source: principalAuditSource(principal), + }, }), afterSuccess: ({ result }) => notifyWorkflowUpdated(result.id), }) diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts index 49ba3869074..c205d225361 100644 --- a/apps/sim/lib/workflows/application/import-export.test.ts +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -14,8 +14,10 @@ const mocks = vi.hoisted(() => ({ recordAudit: vi.fn(), })) -vi.mock('@/lib/workflows/application/context', () => ({ +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, +})) +vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkflowApplicationContext: mocks.resolveWorkflow, })) vi.mock('@sim/platform-authz/workspace', () => ({ diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index 4516f02ce70..ecad2d84428 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -6,10 +6,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { - resolveActiveWorkflowApplicationContext, - resolveActiveWorkspaceApplicationContext, -} from '@/lib/workflows/application/context' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { resolveWorkflowFolderPath, @@ -24,6 +21,7 @@ import { type ImportedWorkflow, importWorkflowIntoWorkspaceTransition, } from '@/lib/workflows/operations/import-workflow' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ImportWorkflowInput { workspaceId: string diff --git a/apps/sim/lib/workflows/application/list-workflows.ts b/apps/sim/lib/workflows/application/list-workflows.ts index 9257735ec80..951d42b3b6e 100644 --- a/apps/sim/lib/workflows/application/list-workflows.ts +++ b/apps/sim/lib/workflows/application/list-workflows.ts @@ -3,20 +3,24 @@ import type { CursorKey } from '@/lib/api/list-query' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' -import { workflowFolderPathForId } from '@/lib/workflows/application/workflow-folders' +import { + archivableWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' import { listWorkspaceWorkflows, type WorkflowSortBy, type WorkflowSortOrder, } from '@/lib/workflows/queries' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' const logger = createLogger('ListWorkflows') export interface ListWorkflowsInput { workspaceId: string folderPath?: string + scope: 'active' | 'archived' deployedOnly: boolean search?: string sortBy: WorkflowSortBy @@ -49,6 +53,7 @@ export const listWorkflows = defineAuthorizedWorkflowUseCase({ const page = await listWorkspaceWorkflows({ workspaceId: context.workspaceId, folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, + scope: input.scope, deployedOnly: input.deployedOnly, search: input.search, sortBy: input.sortBy, @@ -66,7 +71,10 @@ export const listWorkflows = defineAuthorizedWorkflowUseCase({ workflows: page.data.map((workflow) => ({ ...workflow, workspaceId: workflow.workspaceId ?? context.workspaceId, - folderPath: workflowFolderPathForId(folderIndex, workflow.folderId), + folderPath: + input.scope === 'archived' + ? archivableWorkflowFolderPath(folderIndex, workflow.folderId) + : workflowFolderPathForId(folderIndex, workflow.folderId), })), nextCursorKeys: page.nextCursorKeys, sortBy: input.sortBy, diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts index d2a83f8eb61..20260e59dfd 100644 --- a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts @@ -40,7 +40,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.permission, })) -vi.mock('@/lib/workflows/application/context', () => ({ +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveContext, })) @@ -141,14 +141,37 @@ describe('moveWorkflowsBulk', () => { expect(mocks.audit).not.toHaveBeenCalled() }) - it('rejects a non-Copilot principal before canonical workspace loading', async () => { + it('rejects a delegated service the operation does not accept, before canonical loading', async () => { await expect( moveWorkflowsBulk.execute({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T01:00:00Z'), + }, input: { workspaceId: 'workspace-1', workflowIds: ['workflow-1'], folderId: null }, }) ).rejects.toMatchObject({ code: 'forbidden' }) expect(mocks.resolveContext).not.toHaveBeenCalled() }) + + it('refuses folderPath and folderId together', async () => { + await expect( + moveWorkflowsBulk.execute({ + principal, + input: { + workspaceId: 'workspace-1', + workflowIds: ['workflow-1'], + folderId: null, + folderPath: '/Operations', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) }) diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.ts index 62c6acc0e5c..2232a78150a 100644 --- a/apps/sim/lib/workflows/application/move-workflows-bulk.ts +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.ts @@ -9,20 +9,25 @@ import { WorkflowLockedError, } from '@sim/platform-authz/workflow' import { and, eq, inArray, isNull } from 'drizzle-orm' +import { principalAuditSource } from '@/lib/core/application' import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { resolveWorkflowFolderPath } from '@/lib/workflows/application/workflow-folders' import { updateWorkflowRecord } from '@/lib/workflows/orchestration' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' const MAX_BULK_WORKFLOW_MOVES = 100 export interface MoveWorkflowsBulkInput { workspaceId: string workflowIds: string[] - folderId: string | null + /** Canonical destination folder. Mutually exclusive with `folderPath`. */ + folderId?: string | null + /** Destination folder by path, resolved against the workspace's folder tree. */ + folderPath?: string } interface MovedWorkflow { @@ -68,7 +73,14 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: MoveWorkflowsBulkInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ principal, input, context }): Promise { + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Provide either folderPath or folderId, not both') + } const workflowIds = normalizeWorkflowIds(input.workflowIds) + const folderId = + input.folderPath === undefined + ? (input.folderId ?? null) + : (await resolveWorkflowFolderPath(context.workspaceId, input.folderPath)).folderId const rows = await db .select({ id: workflow.id, @@ -99,7 +111,7 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ } try { - await requireMutable(workflowId, input.folderId) + await requireMutable(workflowId, folderId) const changed = await db.transaction(async (tx) => { const [current] = await tx .select({ @@ -125,7 +137,7 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ workspaceId: context.workspaceId, currentName: current.name, currentFolderId: current.folderId, - folderId: input.folderId, + folderId, tx, }) requireWorkflowTransition(transition, 'Failed to move workflow') @@ -144,9 +156,9 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ } } - return { moved, failed, folderId: input.folderId, changes } + return { moved, failed, folderId, changes } }, - projectAudit: ({ result }) => + projectAudit: ({ principal, result }) => result.changes.map((change) => ({ action: AuditAction.WORKFLOW_UPDATED, resourceType: AuditResourceType.WORKFLOW, @@ -156,6 +168,7 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ metadata: { previousFolderId: change.previousFolderId, folderId: result.folderId, + source: principalAuditSource(principal), }, })), afterSuccess: async ({ result }) => { diff --git a/apps/sim/lib/workflows/application/operations.test.ts b/apps/sim/lib/workflows/application/operations.test.ts new file mode 100644 index 00000000000..063f54217a8 --- /dev/null +++ b/apps/sim/lib/workflows/application/operations.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ + +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { describe, expect, it } from 'vitest' +import { workflowOperations } from '@/lib/workflows/application/operations' + +describe('workflow operation registry', () => { + it('uses unique stable operation IDs', () => { + const ids = Object.values(workflowOperations).map((operation) => operation.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('keeps every workspace-key operation consistent and at or below the write ceiling', () => { + for (const operation of Object.values(workflowOperations)) { + expect( + operation.principalKinds.includes('workspace_api_key'), + `${operation.id} has inconsistent workspace API-key declarations` + ).toBe(operation.workspaceApiKey === 'allow') + + if (operation.workspaceApiKey === 'allow') { + expect( + permissionSatisfies('write', operation.minimumRole), + `${operation.id} exceeds the workspace API-key write ceiling` + ).toBe(true) + } + } + }) + + /** + * Headless variable editing was widened to workspace API keys when the v2 + * surface shipped. It is a plain `write` on workflow-scoped data, so the key's + * write ceiling is the whole policy — a role increase here would silently make + * the declaration self-contradictory rather than fail. + */ + it('opens variable edits to every workflow principal at the write role', () => { + expect(workflowOperations.applyVariableOperations).toMatchObject({ + id: 'workflows.variables.apply_operations', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + expect(Object.isFrozen(workflowOperations.applyVariableOperations)).toBe(true) + }) + + it('opens bulk moves to every workflow principal at the write role', () => { + expect(workflowOperations.moveBulk).toMatchObject({ + id: 'workflows.bulk.move', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + expect(Object.isFrozen(workflowOperations.moveBulk)).toBe(true) + }) + + /** + * Toggling unauthenticated public execution removes the authentication + * requirement from a deployed workflow, so it takes an accountable human: + * admin role, no workspace key, and — unlike every other admin write in this + * registry — no Copilot delegation. + */ + it('reserves public-execution changes for an accountable human admin', () => { + expect(workflowOperations.updatePublicApi).toMatchObject({ + id: 'workflows.public_api.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }) + expect(workflowOperations.updatePublicApi.principalKinds).not.toContain('workspace_api_key') + expect(workflowOperations.updatePublicApi.principalKinds).not.toContain('delegated') + expect(workflowOperations.updatePublicApi.delegatedServices).toBeUndefined() + }) + + /** + * `workflows.operations.apply` stays denied to workspace API keys: the three + * permission lookups it performs need a human subject, and both substitutes + * for an actorless key fail *open* — attributing to the workspace billing + * owner evaluates the batch as the least-restricted account, and passing no + * user makes `getUserPermissionConfig` return `null`, which every caller + * reads as unrestricted. Re-open it only once those lookups fail closed. + */ + it('keeps workflow edit batches denied to actorless workspace keys', () => { + expect(workflowOperations.applyOperations).toMatchObject({ + id: 'workflows.operations.apply', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + expect(workflowOperations.applyOperations.principalKinds).not.toContain('workspace_api_key') + }) +}) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 5cfae2226f7..c6091987a62 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -69,6 +69,55 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + /** + * Denied to workspace API keys for the same reason as its sibling + * {@link applyOperations} below, and stated here because the two are the only + * doors that write a whole graph through this surface. + * + * A replace stores blocks and their tool wiring wholesale. The policies that + * decide which of those a member may add — the EE permission config and block + * visibility — take a human subject, and an actorless workspace key has none; + * both available substitutes fail *open*. Allowing one here made `PUT …/state` + * a way to store what `POST …/operations` refuses. + * + * Personal keys keep the capability, so headless authoring is unaffected for a + * credential that names a human. + */ + replaceState: defineWorkspaceOperation({ + id: 'workflows.state.replace', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, + }), + /** + * Denied to workspace API keys, unlike its sibling writes. + * + * Applying an edit batch authorizes against three per-user policies — the EE + * permission config, block visibility, and credential reachability — and all + * three take a human subject. An actorless workspace key has none, and the + * two available substitutes both fail *open*: attributing to the workspace + * billing owner evaluates the batch as the least-restricted account in the + * workspace, and passing no user at all makes `getUserPermissionConfig` + * return `null`, which every caller reads as "unrestricted". Either way a + * workspace whose members are constrained by an allowlist would be edited as + * though it were not. + * + * Personal keys keep the capability, so headless editing is unaffected for a + * credential that names a human. Re-open this to workspace keys only once the + * three lookups can express a workspace-scoped policy that fails closed. + */ + applyOperations: defineWorkspaceOperation({ + id: 'workflows.operations.apply', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, + }), + restore: defineWorkspaceOperation({ + id: 'workflows.restore', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), updatePolicy: defineWorkspaceOperation({ id: 'workflows.policy.update', minimumRole: 'admin', @@ -78,8 +127,8 @@ export const workflowOperations = { applyVariableOperations: defineWorkspaceOperation({ id: 'workflows.variables.apply_operations', minimumRole: 'write', - workspaceApiKey: 'deny', - ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), setBlockEnabled: defineWorkspaceOperation({ id: 'workflows.blocks.set_enabled', @@ -90,8 +139,8 @@ export const workflowOperations = { moveBulk: defineWorkspaceOperation({ id: 'workflows.bulk.move', minimumRole: 'write', - workspaceApiKey: 'deny', - ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), createVfsFolders: defineWorkspaceOperation({ id: 'workflows.vfs.folders.create', @@ -202,11 +251,19 @@ export const workflowOperations = { workspaceApiKey: 'deny', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + /** + * Toggling unauthenticated public execution is an admin-role change a human + * key-holder may legitimately make from a script, so personal API keys are + * accepted alongside sessions. Workspace keys stay denied and Copilot is not + * a principal here: the operation removes the authentication requirement from + * a deployed workflow, which needs an accountable human rather than a machine + * credential or an agent acting on a prompt. + */ updatePublicApi: defineWorkspaceOperation({ id: 'workflows.public_api.update', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['session'], + principalKinds: ['session', 'personal_api_key'], }), activateVersion: defineWorkspaceOperation({ id: 'workflows.versions.activate', @@ -274,6 +331,19 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + /** + * Downloading one file a run produced. Separate from `readRun` because it + * hands out bytes and records a `FILE_DOWNLOADED` audit event, which reading + * the run resource does not; it keeps `readRun`'s policy because the resource + * being authorized is still the run — a run file is reachable only through + * the run that recorded it, never as a standalone workspace file. + */ + downloadRunFile: defineWorkspaceOperation({ + id: 'workflows.download_run_file', + minimumRole: 'read', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), cancelRun: defineWorkspaceOperation({ id: 'workflows.runs.cancel', minimumRole: 'write', diff --git a/apps/sim/lib/workflows/application/read-workflow-graph.test.ts b/apps/sim/lib/workflows/application/read-workflow-graph.test.ts new file mode 100644 index 00000000000..138449333fc --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-graph.test.ts @@ -0,0 +1,188 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + loadSnapshot: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: {}, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) +vi.mock('@/lib/workflows/queries', () => ({ loadWorkflowReadSnapshot: mocks.loadSnapshot })) + +import { readWorkflowGraph } from '@/lib/workflows/application/read-workflow-graph' + +const context = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Daily digest', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const input = { workflowId: 'workflow-1' } + +/** The stored column shape, including the `workflowId` this surface withholds. */ +const STORED_VARIABLES = { + 'var-1': { id: 'var-1', workflowId: 'workflow-1', name: 'region', type: 'string', value: 'eu' }, +} +/** What the read projects: canonical variables, no `workflowId`. */ +const PROJECTED_VARIABLES = { + 'var-1': { id: 'var-1', name: 'region', type: 'string', value: 'eu' }, +} + +describe('readWorkflowGraph', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.loadSnapshot.mockResolvedValue({ + workflowRecord: { id: 'workflow-1', variables: STORED_VARIABLES }, + normalizedData: { blocks: { 'block-1': { id: 'block-1' } }, edges: [] }, + }) + }) + + it('returns the unsanitized draft graph with loop and parallel containers always present', async () => { + await expect(readWorkflowGraph.execute({ principal, input })).resolves.toEqual({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + blocks: { 'block-1': { id: 'block-1' } }, + edges: [], + loops: {}, + parallels: {}, + variables: PROJECTED_VARIABLES, + }) + }) + + /** + * The column has carried a JSON string and a legacy array as well as the + * current record, and nothing on any write path bounds `type` to the enum the + * response publishes. Parsing is what stops a strict outbound schema from + * rejecting a workflow this endpoint exists to open. + */ + it.each([ + ['a JSON string', JSON.stringify(STORED_VARIABLES)], + ['a legacy array', Object.values(STORED_VARIABLES)], + ])('reads variables stored as %s', async (_shape, stored) => { + mocks.loadSnapshot.mockResolvedValue({ + workflowRecord: { id: 'workflow-1', variables: stored }, + normalizedData: { blocks: { 'block-1': { id: 'block-1' } }, edges: [] }, + }) + + await expect(readWorkflowGraph.execute({ principal, input })).resolves.toMatchObject({ + variables: PROJECTED_VARIABLES, + }) + }) + + /** + * The pollability guarantee: auditing this read would force `headSafe: false` + * and make the endpoint unusable for the polling it exists to serve. + */ + it('records no audit event', async () => { + await readWorkflowGraph.execute({ principal, input }) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + /** + * A `PUT /state` of `{ blocks: {}, edges: [] }` deletes every block row, and + * the loader answers `null` for a blockless workflow. Existence is the + * workflow row's to decide, so the round trip has to close on an empty graph + * rather than a 404 the list endpoint contradicts. + */ + it('reads a blockless draft back as an empty graph, not as not found', async () => { + mocks.loadSnapshot.mockResolvedValue({ + workflowRecord: { id: 'workflow-1', variables: null }, + normalizedData: null, + }) + + await expect(readWorkflowGraph.execute({ principal, input })).resolves.toEqual({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) + }) + + it('is not found when the workflow row is gone', async () => { + mocks.loadSnapshot.mockResolvedValue({ workflowRecord: null, normalizedData: null }) + + await expect(readWorkflowGraph.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('rejects a principal kind the operation does not accept before canonical loading', async () => { + await expect( + readWorkflowGraph.execute({ + principal: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'someone@example.com', + invitationTokenHash: 'hash', + }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + /** + * The `HEAD` existence-leak guard. `defineV2JsonRoute` answers a head-safe + * probe by calling `authorize()` alone, so an absent or permissive + * `authorize` would turn every `HEAD` into an unauthenticated existence + * oracle. Both halves are asserted: it must exist, it must refuse a principal + * without the role, and it must reach that verdict without reading the graph. + */ + describe('authorize', () => { + it('is exposed by the use case', () => { + expect(typeof readWorkflowGraph.authorize).toBe('function') + }) + + it('refuses a principal without the minimum role, and never loads the graph', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect(readWorkflowGraph.authorize!({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + + expect(mocks.loadSnapshot).not.toHaveBeenCalled() + }) + + it('admits an authorized principal without loading the graph', async () => { + await expect(readWorkflowGraph.authorize!({ principal, input })).resolves.toBeUndefined() + + expect(mocks.resolveContext).toHaveBeenCalledOnce() + expect(mocks.loadSnapshot).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-graph.ts b/apps/sim/lib/workflows/application/read-workflow-graph.ts new file mode 100644 index 00000000000..91c734d65d3 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-graph.ts @@ -0,0 +1,71 @@ +import type { Principal } from '@sim/auth/principal' +import type { BlockState, Variable, WorkflowState } from '@sim/workflow-types/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' +import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' + +export interface ReadWorkflowGraphInput { + workflowId: string + assertedWorkspaceId?: string +} + +export interface ReadWorkflowGraphResult { + workflowId: string + workspaceId: string + blocks: Record + edges: WorkflowState['edges'] + loops: WorkflowState['loops'] + parallels: WorkflowState['parallels'] + variables: Record +} + +/** + * Reads a workflow's editable draft graph, unsanitized. + * + * The same semantic operation as `readWorkflow` — "read this workflow" — and the + * same loader, so the two reads cannot disagree about migrate-on-read. + * + * Records **no** semantic audit, deliberately. This is the pollable read; the + * audited, portable, sanitized one is `workflows.export`, and auditing here + * would force `headSafe: false` and make the endpoint unusable for polling. + * + * Existence is decided by the workflow row alone, never by the block rows. + * `loadWorkflowFromNormalizedTables` answers `null` for a workflow with zero + * blocks, and a blockless draft is a legitimate state a client can reach — a + * `PUT /state` of `{ blocks: {}, edges: [] }` writes exactly that. Reading it + * back as `not_found` would break the read-modify-write round trip the graph + * schema promises, so the null is projected as an empty graph instead. + */ +export const readWorkflowGraph = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ principal, input }: { principal: Principal; input: ReadWorkflowGraphInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ context }): Promise { + const snapshot = await loadWorkflowReadSnapshot(context.workflowId, context.workspaceId) + if (!snapshot.workflowRecord) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + // The column has carried three shapes over time (JSON string, legacy array, + // current record) and nothing bounds what a write puts in `type`. Parsing it + // is what keeps a strict outbound response from rejecting a workflow it + // exists to open — the same rule `normalizeStoredBlockRetry` states for + // blocks. The export read already does this; this one did not. + const variables = parseWorkflowVariables(snapshot.workflowRecord.variables) + return { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + blocks: (snapshot.normalizedData?.blocks ?? {}) as Record, + edges: (snapshot.normalizedData?.edges ?? []) as WorkflowState['edges'], + loops: snapshot.normalizedData?.loops ?? {}, + parallels: snapshot.normalizedData?.parallels ?? {}, + variables: variables ?? {}, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index 185d8f0c5b2..e8f186875ec 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -6,6 +6,11 @@ import { import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' +import { + describeWorkflowRunFiles, + getWorkflowRunFiles, + type WorkflowRunFileDescriptor, +} from '@/lib/workflows/executor/execution-run-files' import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' export interface ReadWorkflowRunInput { @@ -13,6 +18,8 @@ export interface ReadWorkflowRunInput { runId: string includeOutput: boolean selectedOutputs: string[] + includeFileBase64?: boolean + base64MaxBytes?: number } export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ @@ -31,7 +38,36 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ selectedOutputs: input.selectedOutputs, }) if (!status) throw new OrchestrationError('not_found', 'Run not found') - return status + + /** + * File descriptors follow `output`'s gating: they describe the run's + * output, so a caller that did not ask for output gets `null` rather than + * a list it did not request. Derived from the run's own recording, which + * is also where the download endpoint re-derives each storage key. + * + * This re-reads the run rather than reusing what the status read already + * loaded, and must: the status read materializes execution data *for + * display*, a projection that strips `key` and `context` — exactly the + * fields a file descriptor needs — and it also answers from the job queue + * for runs that have no log row yet. + */ + let files: WorkflowRunFileDescriptor[] | null = null + if (input.includeOutput) { + const runFiles = await getWorkflowRunFiles({ + workflowId: context.workflowId, + runId: context.runId, + }) + files = runFiles + ? await describeWorkflowRunFiles(runFiles.filesById, { + workflowId: context.workflowId, + runId: context.runId, + includeBase64: input.includeFileBase64 === true, + base64MaxBytes: input.base64MaxBytes, + }) + : [] + } + + return { ...status, files } } catch (error) { if (error instanceof FunctionalOutputsUnavailableError) { throw new OrchestrationError('conflict', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts new file mode 100644 index 00000000000..60e5552712a --- /dev/null +++ b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts @@ -0,0 +1,378 @@ +/** + * @vitest-environment node + */ +import { WorkflowLockedError } from '@sim/platform-authz/workflow' +import { workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + notify: vi.fn(), + replace: vi.fn(), + validate: vi.fn(), + needsRedeployment: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_UPDATED: 'workflow.updated' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ + replaceWorkflowNormalizedState: mocks.replace, +})) +vi.mock('@/lib/workflows/sanitization/validation', () => ({ + validateWorkflowState: mocks.validate, +})) +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.needsRedeployment, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { replaceWorkflowState } from '@/lib/workflows/application/replace-workflow-state' +import { REFERENCES_UNCHECKED_NOTE } from '@/lib/workflows/editing/lint-report' + +const BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} + +const context = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Daily digest', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const sessionPrincipal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} + +const input = { workflowId: 'workflow-1', blocks: { 'block-1': BLOCK }, edges: [] } + +describe('replaceWorkflowState', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] }) + mocks.replace.mockResolvedValue({ + warnings: [], + state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + }) + mocks.needsRedeployment.mockResolvedValue(true) + }) + + /** + * Two things this pins that a same-shape input and output cannot: the write + * carries the **sanitized** graph, not the caller's body, and the reported + * counts come from what was persisted, not from what was asked for. The + * fixture deliberately makes the two differ. + */ + it('writes the sanitized graph and counts what was persisted, not what was sent', async () => { + const DROPPED_BLOCK = { ...BLOCK, id: 'block-2', name: 'Dropped' } + const DROPPED_EDGE = { id: 'edge-9', source: 'block-1', target: 'block-2' } + mocks.validate.mockReturnValue({ + valid: true, + errors: [], + warnings: ['Dropped block "block-2"'], + sanitizedState: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + }) + + await expect( + replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { + workflowId: 'workflow-1', + blocks: { 'block-1': BLOCK, 'block-2': DROPPED_BLOCK }, + edges: [DROPPED_EDGE], + }, + }) + ).resolves.toMatchObject({ + workflowId: 'workflow-1', + blocksCount: 1, + edgesCount: 0, + needsRedeployment: true, + }) + + expect(mocks.replace).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + attributedUserId: 'user-1', + state: { blocks: { 'block-1': BLOCK }, edges: [], variables: undefined }, + }) + }) + + /** + * `PUT /state` used to pass `variables` through verbatim while + * `PATCH /variables` re-keyed by variable id and coerced each value onto its + * declared type, so the same column held two shapes depending on which write + * reached it last — which is why the read side carries defensive parsing. + * Both writes now share one normalizer. + */ + it('re-keys variables by their own id and coerces each value onto its declared type', async () => { + await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { + ...input, + variables: { + 'stale-key': { id: 'var-1', name: 'retries', type: 'number', value: '42' }, + 'another-stale-key': { id: 'var-2', name: 'enabled', type: 'boolean', value: 'true' }, + 'json-key': { id: 'var-3', name: 'tags', type: 'array', value: '["a","b"]' }, + }, + }, + }) + + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + variables: { + 'var-1': { id: 'var-1', name: 'retries', type: 'number', value: 42 }, + 'var-2': { id: 'var-2', name: 'enabled', type: 'boolean', value: true }, + 'var-3': { id: 'var-3', name: 'tags', type: 'array', value: ['a', 'b'] }, + }, + }), + }) + ) + }) + + it('derives the audit source from the acting principal and notifies after it', async () => { + await replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.updated', + resourceId: 'workflow-1', + resourceName: 'Daily digest', + metadata: expect.objectContaining({ + operation: 'workflows.state.replace', + op: 'replace_state', + blocksCount: 1, + source: 'session', + }), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledBefore(mocks.notify) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + }) + + it('names the delegated service rather than the principal kind', async () => { + await replaceWorkflowState.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + }, + input, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ metadata: expect.objectContaining({ source: 'copilot' }) }) + ) + }) + + it('refuses a role below the operation floor', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('rejects a principal kind the operation does not accept before canonical loading', async () => { + await expect( + replaceWorkflowState.execute({ + principal: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'someone@example.com', + invitationTokenHash: 'hash', + }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + it('conceals an asserted-workspace mismatch as not found', async () => { + mocks.resolveContext.mockRejectedValue( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, assertedWorkspaceId: 'other-workspace' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('refuses a locked workflow before validating or writing', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( + new WorkflowLockedError('Workflow is locked') + ) + + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).rejects.toMatchObject({ code: 'locked' }) + + expect(mocks.validate).not.toHaveBeenCalled() + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('rejects a semantically invalid graph without writing', async () => { + mocks.validate.mockReturnValue({ + valid: false, + errors: ['Edge references an unknown block'], + warnings: [], + }) + + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('records neither audit nor notification when the write fails', async () => { + mocks.replace.mockRejectedValue(new Error('constraint violation')) + + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).rejects.toThrow('constraint violation') + + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + /** + * The report is the whole point of the endpoint for a headless builder: an + * agent that authors a graph from scratch needs the same findings as one that + * edits it incrementally through `POST /operations`. + */ + it('reports lint findings alongside a committed write', async () => { + const result = await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input, + }) + + expect(result.dryRun).toBe(false) + expect(result.lint).toMatchObject({ + sources: expect.any(Array), + sinks: expect.any(Array), + orphanBlocks: expect.any(Array), + fieldIssues: expect.any(Array), + unresolvedReferences: expect.any(Array), + notes: expect.any(Array), + }) + }) + + describe('dry run', () => { + it('persists nothing, audits nothing, and notifies nobody', async () => { + const result = await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + + expect(result.dryRun).toBe(true) + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + /** A preview a caller cannot act on is worthless; it must carry the findings. */ + it('still reports the findings a committed write would produce', async () => { + const dry = await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + const committed = await replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + + expect(dry.lint).toEqual(committed.lint) + expect(dry.blocksCount).toBe(committed.blocksCount) + expect(dry.edgesCount).toBe(committed.edgesCount) + }) + + /** A locked workflow refuses the preview too, or the preview would lie. */ + it('refuses when the workflow cannot be mutated', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValueOnce( + new WorkflowLockedError('workflow-1') + ) + + await expect( + replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + ).rejects.toThrow() + }) + }) + + /** + * A replace stores blocks and their tool wiring wholesale, and the policies + * deciding which of those a member may add take a human subject. A workspace + * API key has none, and both substitutes fail open — the billing owner is a + * different, typically less-constrained person — so the operation refuses one + * outright rather than writing a graph it cannot evaluate. Without this, + * `PUT …/state` stored what `POST …/operations` refuses. + */ + describe('reference resolution identity', () => { + it('refuses a workspace API key, which names no human to evaluate', async () => { + await expect( + replaceWorkflowState.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + input, + }) + ).rejects.toThrow() + }) + + it('runs the reference pass for a human principal', async () => { + const result = await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input, + }) + + expect(result.lint.notes).not.toContain(REFERENCES_UNCHECKED_NOTE) + }) + }) +}) diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.ts b/apps/sim/lib/workflows/application/replace-workflow-state.ts new file mode 100644 index 00000000000..5dff1adfa36 --- /dev/null +++ b/apps/sim/lib/workflows/application/replace-workflow-state.ts @@ -0,0 +1,220 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { + type Principal, + PrincipalSubjectUserRequiredError, + requirePrincipalSubjectUserId, + resolvePrincipalAttribution, +} from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { principalAuditSource } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireMutableWorkflow } from '@/lib/workflows/application/workflow-mutability' +import { normalizeWorkflowVariables } from '@/lib/workflows/application/workflow-variables' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import type { WorkflowLintReport } from '@/lib/workflows/editing/lint' +import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report' +import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state' +import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' + +const logger = createLogger('ReplaceWorkflowState') + +/** + * The human a principal acts as, or `null` when it does not act as one. + * + * Deliberately not `resolvePrincipalAttribution`: that answers a workspace API + * key with the workspace's billing owner, which is correct for billing and + * wrong for anything that reads a person's own grants. + * + * `workflows.state.replace` now admits only principals that name a human, so + * the `null` branch is unreachable through this operation. It is kept as a + * fail-safe: if that policy is ever widened, the reference pass degrades and + * says so in `lint.notes` rather than silently resolving one person's grants + * against another's. + */ +function humanSubjectUserId(principal: Principal): string | null { + try { + return requirePrincipalSubjectUserId(principal) + } catch (error) { + if (error instanceof PrincipalSubjectUserRequiredError) return null + throw error + } +} + +export interface ReplaceWorkflowStateInput { + workflowId: string + assertedWorkspaceId?: string + blocks: Record + edges: WorkflowState['edges'] + /** Omitted leaves the stored variables untouched. */ + variables?: Record + /** + * Validate and lint without persisting. The response is byte-identical to a + * committed write of the same body, so a caller can inspect the findings it + * would get and then send the same request for real. + */ + dryRun?: boolean +} + +export interface ReplaceWorkflowStateResult { + workflowId: string + workflowName: string + workspaceId: string + blocksCount: number + edgesCount: number + warnings: string[] + needsRedeployment: boolean + /** Advisory findings about the graph. Never blocks the write. */ + lint: WorkflowLintReport + /** True when nothing was persisted because the caller asked for a dry run. */ + dryRun: boolean +} + +/** + * Replaces a workflow's editable draft graph wholesale. + * + * Semantic validation runs **before** the write, not because the persistence + * layer would accept nonsense but because it would fault on it — a well-formed + * body describing an impossible graph would otherwise be a caller-reachable 500. + * + * Nothing here touches deployments, schedules, or webhooks: those are only + * changed on the deploy/undeploy path. The one observable consequence is that + * the live deployment now differs from the draft. + */ +export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.replaceState, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReplaceWorkflowStateInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }): Promise { + await requireMutableWorkflow(context.workflowId) + + const candidate = { + blocks: input.blocks, + edges: input.edges, + loops: {}, + parallels: {}, + } + const validation = validateWorkflowState(candidate, { sanitize: true }) + if (!validation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid workflow state: ${validation.errors.join('; ')}` + ) + } + const sanitized = validation.sanitizedState ?? candidate + + const graph = { + blocks: sanitized.blocks as Record, + edges: sanitized.edges as WorkflowState['edges'], + } + + /** + * Linted before the write so a dry run and a committed write report the + * same findings for the same body. Unlike its sibling `applyOperations`, + * this operation admits workspace API keys, which have no human subject — + * the reference pass is skipped for them rather than resolved against the + * billing owner. See {@link buildWorkflowLintReport}. + */ + const lint = await buildWorkflowLintReport(graph, { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + subjectUserId: humanSubjectUserId(principal), + }) + + if (input.dryRun) { + logger.info('Validated workflow state without persisting', { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + principalKind: principal.kind, + }) + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + blocksCount: Object.keys(graph.blocks).length, + edgesCount: graph.edges.length, + warnings: validation.warnings, + needsRedeployment: await checkNeedsRedeployment(context.workflowId), + lint, + dryRun: true, + } + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const persisted = await replaceWorkflowNormalizedState({ + workflowId: context.workflowId, + workspaceId: context.workspaceId, + attributedUserId: attribution.attributedUserId, + state: { + blocks: graph.blocks, + edges: graph.edges, + /** + * Re-keyed by variable id and coerced onto each declared type by the + * same helper `PATCH /workflows/{id}/variables` uses, so a full + * replacement cannot write a shape the incremental path never + * produces. Omitted stays omitted — that leaves the column untouched. + */ + variables: + input.variables === undefined + ? undefined + : normalizeWorkflowVariables(input.variables, { coerceValues: true }), + }, + }) + + logger.info('Replaced workflow state', { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + principalKind: principal.kind, + }) + + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + blocksCount: Object.keys(persisted.state.blocks).length, + edgesCount: persisted.state.edges.length, + warnings: [...validation.warnings, ...persisted.warnings], + needsRedeployment: await checkNeedsRedeployment(context.workflowId), + lint, + dryRun: false, + } + }, + /** A dry run changes nothing, so it projects no audit entry. */ + projectAudit: ({ principal, context, result }) => + result.dryRun + ? [] + : ({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflowName, + description: `Replaced the draft graph of workflow "${result.workflowName}"`, + metadata: { + op: 'replace_state', + blocksCount: result.blocksCount, + edgesCount: result.edgesCount, + warnings: result.warnings, + source: principalAuditSource(principal), + }, + } as const), + afterSuccess: ({ context, result }) => { + if (result.dryRun) return + return notifyWorkflowUpdated(context.workflowId) + }, +}) diff --git a/apps/sim/lib/workflows/application/restore-workflow.test.ts b/apps/sim/lib/workflows/application/restore-workflow.test.ts new file mode 100644 index 00000000000..def7c6931a9 --- /dev/null +++ b/apps/sim/lib/workflows/application/restore-workflow.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { FolderLockedError } from '@sim/platform-authz/workflow' +import { workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + notify: vi.fn(), + restoreRecord: vi.fn(), + folderIndex: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_RESTORED: 'workflow.restored' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveArchivedWorkflowApplicationContext: mocks.resolveContext, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/lifecycle', () => ({ restoreWorkflow: mocks.restoreRecord })) +vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.folderIndex })) + +import { restoreWorkflow } from '@/lib/workflows/application/restore-workflow' + +const archivedWorkflow = { + id: 'workflow-1', + name: 'Daily digest', + workspaceId: 'workspace-1', + folderId: null, + locked: false, +} + +const context = { + workflowId: 'workflow-1', + workflow: archivedWorkflow, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const input = { workflowId: 'workflow-1' } + +describe('restoreWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) + mocks.folderIndex.mockResolvedValue({ pathById: new Map() }) + mocks.restoreRecord.mockResolvedValue({ + restored: true, + workflow: { ...archivedWorkflow, archivedAt: null }, + }) + }) + + it('restores through the lifecycle primitive and projects its own audit row', async () => { + await expect(restoreWorkflow.execute({ principal, input })).resolves.toMatchObject({ + workspaceId: 'workspace-1', + folderPath: '/', + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.restored', + resourceId: 'workflow-1', + resourceName: 'Daily digest', + metadata: expect.objectContaining({ operation: 'workflows.restore' }), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledBefore(mocks.notify) + }) + + it('refuses a workflow that is not archived as a conflict', async () => { + mocks.restoreRecord.mockResolvedValue({ restored: false, workflow: archivedWorkflow }) + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'conflict', + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('is not found when the workflow row is gone', async () => { + mocks.restoreRecord.mockResolvedValue({ restored: false, workflow: null }) + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('refuses a locked workflow before restoring', async () => { + mocks.resolveContext.mockResolvedValue({ + ...context, + workflow: { ...archivedWorkflow, locked: true }, + }) + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'locked', + }) + expect(mocks.restoreRecord).not.toHaveBeenCalled() + }) + + it('refuses a locked destination folder before restoring', async () => { + workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValue( + new FolderLockedError('Folder is locked') + ) + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'locked', + }) + expect(mocks.restoreRecord).not.toHaveBeenCalled() + }) + + it('refuses a role below the operation floor', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.restoreRecord).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/restore-workflow.ts b/apps/sim/lib/workflows/application/restore-workflow.ts new file mode 100644 index 00000000000..44573566e88 --- /dev/null +++ b/apps/sim/lib/workflows/application/restore-workflow.ts @@ -0,0 +1,98 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { principalAuditSource } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveArchivedWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { workflowFolderPathForId } from '@/lib/workflows/application/workflow-folders' +import { restoreWorkflow as restoreWorkflowRecord } from '@/lib/workflows/lifecycle' + +const logger = createLogger('RestoreWorkflow') + +export interface RestoreWorkflowInput { + workflowId: string + assertedWorkspaceId?: string +} + +/** + * Brings an archived workflow, and the schedules, webhooks, MCP tools, and chats + * archived alongside it, back to active. + * + * Calls the lifecycle primitive rather than `performRestoreWorkflow`: that + * orchestration records its own audit row keyed on a bare `userId`, which cannot + * represent a workspace-key or delegated principal. Audit is projected here + * instead, from the authoritative restored row. + */ +export const restoreWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.restore, + resolveContext: ({ principal, input }: { principal: Principal; input: RestoreWorkflowInput }) => + resolveArchivedWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, context }) { + if (context.workflow.locked) { + throw new OrchestrationError('locked', 'Workflow is locked') + } + try { + await assertFolderMutable(context.workflow.folderId) + } catch (error) { + if (error instanceof FolderLockedError || error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + const restored = await restoreWorkflowRecord(context.workflowId, { + requestId: generateRequestId(), + }) + if (!restored.workflow) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + if (!restored.restored) { + throw new OrchestrationError('conflict', 'Workflow is not archived') + } + + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) + logger.info('Restored workflow', { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + principalKind: principal.kind, + }) + return { + workflow: restored.workflow, + workspaceId: context.workspaceId, + folderPath: workflowFolderPathForId(folderIndex, restored.workflow.folderId), + } + }, + projectAudit: ({ principal, context, result }) => ({ + action: AuditAction.WORKFLOW_RESTORED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflow.name, + description: `Restored workflow "${result.workflow.name}"`, + metadata: { + workflowName: result.workflow.name, + workspaceId: context.workspaceId, + source: principalAuditSource(principal), + }, + }), + afterSuccess: ({ context }) => notifyWorkflowUpdated(context.workflowId), +}) diff --git a/apps/sim/lib/workflows/application/update-workflow-content.test.ts b/apps/sim/lib/workflows/application/update-workflow-content.test.ts index 6379ad2e3f7..86383f35fc8 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.test.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.test.ts @@ -9,10 +9,16 @@ const mocks = vi.hoisted(() => ({ resolveContext: vi.fn(), resolvePermission: vi.fn(), notify: vi.fn(), + loadNormalized: vi.fn(), + replace: vi.fn(), + requireMutable: vi.fn(), })) vi.mock('@sim/audit', () => ({ - AuditAction: { WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated' }, + AuditAction: { + WORKFLOW_UPDATED: 'workflow.updated', + WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', + }, AuditResourceType: { WORKFLOW: 'workflow' }, recordAudit: mocks.recordAudit, })) @@ -32,8 +38,20 @@ vi.mock('@/lib/workflows/application/context', () => ({ })) vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mocks.loadNormalized, +})) +vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ + replaceWorkflowNormalizedState: mocks.replace, +})) +vi.mock('@/lib/workflows/application/workflow-mutability', () => ({ + requireMutableWorkflow: mocks.requireMutable, +})) -import { applyWorkflowVariableOperations } from '@/lib/workflows/application/update-workflow-content' +import { + applyWorkflowVariableOperations, + setWorkflowBlockEnabled, +} from '@/lib/workflows/application/update-workflow-content' const context = { workflowId: 'workflow-1', @@ -128,10 +146,37 @@ describe('applyWorkflowVariableOperations', () => { expect(mocks.notify).not.toHaveBeenCalled() }) - it('rejects a non-Copilot principal before canonical loading', async () => { + it('admits a session principal and attributes the audit row to it, not to copilot', async () => { await expect( applyWorkflowVariableOperations.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }], + }, + }) + ).resolves.toMatchObject({ changed: true }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ source: 'session' }), + }) + ) + }) + + it('rejects a delegated service the operation does not accept, before canonical loading', async () => { + await expect( + applyWorkflowVariableOperations.execute({ + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T01:00:00Z'), + }, input: { workflowId: 'workflow-1', operations: [] }, }) ).rejects.toMatchObject({ code: 'forbidden' }) @@ -139,3 +184,109 @@ describe('applyWorkflowVariableOperations', () => { expect(mocks.resolveContext).not.toHaveBeenCalled() }) }) + +describe('setWorkflowBlockEnabled', () => { + const BLOCK = { + id: 'block-1', + type: 'agent', + name: 'Triage', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: {}, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + mocks.requireMutable.mockResolvedValue(undefined) + mocks.loadNormalized.mockResolvedValue({ + blocks: { 'block-1': BLOCK }, + edges: [], + loops: {}, + parallels: {}, + }) + mocks.replace.mockResolvedValue({ + warnings: [], + state: { blocks: { 'block-1': { ...BLOCK, enabled: false } }, edges: [] }, + }) + }) + + /** + * The third graph-write door. It must not write the normalized tables itself: + * bypassing the shared primitive is how it lost state preparation and + * custom-tool extraction that `replaceWorkflowState` and + * `applyWorkflowOperations` both get. + */ + it('writes through the shared persistence primitive rather than saving the graph itself', async () => { + await expect( + setWorkflowBlockEnabled.execute({ + principal, + input: { workflowId: 'workflow-1', blockId: 'block-1', enabled: false }, + }) + ).resolves.toMatchObject({ changed: true, affectedBlockIds: ['block-1'] }) + + expect(mocks.replace).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + attributedUserId: 'user-1', + state: expect.any(Function), + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + /** + * The graph is produced inside the primitive's transaction, not handed to it + * pre-read: the editor's own save takes the same row lock, so a graph read + * before the lock can be a stale copy that this write — a whole graph, not a + * delta — would persist over a concurrent autosave. + */ + it('re-reads and re-decides inside the write transaction', async () => { + await setWorkflowBlockEnabled.execute({ + principal, + input: { workflowId: 'workflow-1', blockId: 'block-1', enabled: false }, + }) + + const { state } = mocks.replace.mock.calls[0]![0] + expect(typeof state).toBe('function') + + mocks.loadNormalized.mockClear() + const tx = Symbol('tx') + await expect(state(tx)).resolves.toEqual({ + blocks: { 'block-1': { ...BLOCK, enabled: false } }, + edges: [], + }) + expect(mocks.loadNormalized).toHaveBeenCalledWith('workflow-1', tx) + }) + + /** The returned state is what was persisted, not what was proposed. */ + it('returns the graph the persistence primitive actually wrote', async () => { + mocks.replace.mockResolvedValue({ + warnings: [], + state: { blocks: { 'block-1': { ...BLOCK, enabled: false, name: 'Normalized' } }, edges: [] }, + }) + + const result = await setWorkflowBlockEnabled.execute({ + principal, + input: { workflowId: 'workflow-1', blockId: 'block-1', enabled: false }, + }) + + expect(result.state.blocks['block-1'].name).toBe('Normalized') + }) + + it('does not write, audit, or notify an authoritative no-op', async () => { + await expect( + setWorkflowBlockEnabled.execute({ + principal, + input: { workflowId: 'workflow-1', blockId: 'block-1', enabled: true }, + }) + ).resolves.toMatchObject({ changed: false }) + + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/update-workflow-content.ts b/apps/sim/lib/workflows/application/update-workflow-content.ts index d654907c506..0ae8ef511a1 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.ts @@ -1,44 +1,48 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import type { Principal } from '@sim/auth/principal' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' import { and, eq, isNull } from 'drizzle-orm' +import { principalAuditSource } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireMutableWorkflow } from '@/lib/workflows/application/workflow-mutability' import { - loadWorkflowFromNormalizedTables, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/persistence/utils' + coerceWorkflowVariableValue, + normalizeWorkflowVariables, + type WorkflowVariable, +} from '@/lib/workflows/application/workflow-variables' +import { + type BlockEnablementRefusal, + decideBlockEnablement, +} from '@/lib/workflows/editing/block-enablement' +import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -const logger = createLogger('UpdateWorkflowContent') const MAX_WORKFLOW_VARIABLE_OPERATIONS = 100 +/** How each protection refusal is classified when a single block toggle is the whole request. */ +const BLOCK_ENABLEMENT_REFUSAL_CODES: Record< + BlockEnablementRefusal['reason'], + 'not_found' | 'locked' | 'validation' +> = { + not_found: 'not_found', + locked: 'locked', + disabled_ancestor: 'validation', +} + interface WorkflowContentInput { workflowId: string assertedWorkspaceId?: string } -async function requireMutableWorkflow(workflowId: string): Promise { - try { - await assertWorkflowMutable(workflowId) - } catch (error) { - if (error instanceof WorkflowLockedError) { - throw new OrchestrationError('locked', error.message) - } - throw error - } -} - function resolveWorkflowContentContext({ principal, input, @@ -52,14 +56,6 @@ function resolveWorkflowContentContext({ }) } -interface WorkflowVariable { - id: string - workflowId?: string - name: string - type: string - value?: unknown -} - export interface WorkflowVariableOperation { name: string operation: 'add' | 'edit' | 'delete' @@ -71,59 +67,14 @@ export interface ApplyWorkflowVariableOperationsInput extends WorkflowContentInp operations: WorkflowVariableOperation[] } -function coerceWorkflowVariableValue(value: unknown, type: string): unknown { - if (value === undefined) return value - if (type === 'number') { - const number = Number(value) - return Number.isNaN(number) ? value : number - } - if (type === 'boolean') { - const normalized = String(value).trim().toLowerCase() - if (normalized === 'true') return true - if (normalized === 'false') return false - return value - } - if (type !== 'array' && type !== 'object') return value - - try { - const parsed: unknown = JSON.parse(String(value)) - if (type === 'array' && Array.isArray(parsed)) return parsed - if (type === 'object' && isRecordLike(parsed)) { - return parsed - } - } catch (error) { - logger.warn('Failed to parse JSON value for workflow variable coercion', { - error: getErrorMessage(error), - }) - } - return value -} - function applyVariableOperations( workflowId: string, currentVariables: unknown, operations: readonly WorkflowVariableOperation[] ): { variables: Record; changed: boolean } { - const current = isRecordLike(currentVariables) - ? (currentVariables as Record) - : {} const byName = new Map() - for (const value of Object.values(current)) { - if ( - value && - typeof value === 'object' && - 'id' in value && - typeof value.id === 'string' && - 'name' in value && - typeof value.name === 'string' - ) { - byName.set(value.name, { - ...value, - id: value.id, - name: value.name, - type: 'type' in value && typeof value.type === 'string' ? value.type : 'plain', - }) - } + for (const variable of Object.values(normalizeWorkflowVariables(currentVariables))) { + byName.set(variable.name, variable) } let changed = false @@ -146,10 +97,7 @@ function applyVariableOperations( changed = true } - return { - variables: Object.fromEntries([...byName.values()].map((variable) => [variable.id, variable])), - changed, - } + return { variables: normalizeWorkflowVariables([...byName.values()]), changed } } export const applyWorkflowVariableOperations = defineAuthorizedWorkflowUseCase({ @@ -203,7 +151,7 @@ export const applyWorkflowVariableOperations = defineAuthorizedWorkflowUseCase({ return { updated: Object.keys(transformed.variables).length, changed: true } }) }, - projectAudit: ({ input, context, result }) => + projectAudit: ({ principal, input, context, result }) => result.changed ? { action: AuditAction.WORKFLOW_VARIABLES_UPDATED, @@ -211,169 +159,125 @@ export const applyWorkflowVariableOperations = defineAuthorizedWorkflowUseCase({ resourceId: context.workflowId, resourceName: context.workflow.name, description: 'Updated workflow variables', - metadata: { operationCount: input.operations.length, source: 'copilot' }, + metadata: { + operationCount: input.operations.length, + source: principalAuditSource(principal), + }, } : [], afterSuccess: ({ context, result }) => result.changed ? notifyWorkflowUpdated(context.workflowId) : undefined, }) -function isBlockProtected(blockId: string, blocksById: Record): boolean { - const block = blocksById[blockId] - if (!block) return false - if (block.locked) return true - - const visited = new Set() - let parentId = block.data?.parentId - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - if (blocksById[parentId]?.locked) return true - parentId = blocksById[parentId]?.data?.parentId - } - return false -} - -function hasDisabledAncestor(blockId: string, blocksById: Record): boolean { - const visited = new Set() - let parentId = blocksById[blockId]?.data?.parentId - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - const parent = blocksById[parentId] - if (!parent) return false - if (parent.enabled === false) return true - parentId = parent.data?.parentId - } - return false -} - -function findDescendants(containerId: string, blocksById: Record): string[] { - const descendants: string[] = [] - const stack = [containerId] - const visited = new Set() - while (stack.length > 0) { - const current = stack.pop()! - if (visited.has(current)) continue - visited.add(current) - for (const [blockId, block] of Object.entries(blocksById)) { - if (block.data?.parentId === current) { - descendants.push(blockId) - stack.push(blockId) - } - } - } - return descendants -} - export interface SetWorkflowBlockEnabledInput extends WorkflowContentInput { blockId: string enabled: boolean } +/** + * Toggles one block, or a container and its unlocked descendants. + * + * The write goes through {@link replaceWorkflowNormalizedState}, the same door + * `replaceWorkflowState` and `applyWorkflowOperations` use, so this toggle + * cannot acquire different persistence behavior by being a different entry + * point: it gets the same state preparation, the same row-locked replace + * transaction, the same `lastSynced` stamp, and the same custom-tool + * extraction. Writing the graph here directly was how those diverged. + */ export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.setBlockEnabled, resolveContext: resolveWorkflowContentContext, - async execute({ input, context }) { + async execute({ principal, input, context }) { await requireMutableWorkflow(context.workflowId) - return db.transaction(async (tx) => { - const [active] = await tx - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where( - and( - eq(workflow.id, context.workflowId), - eq(workflow.workspaceId, context.workspaceId), - isNull(workflow.archivedAt) - ) - ) - .limit(1) - .for('update') - if (!active) throw new OrchestrationError('not_found', 'Workflow not found') - const normalized = await loadWorkflowFromNormalizedTables(context.workflowId, tx) - if (!normalized) { - throw new OrchestrationError( - 'validation', - `Workflow ${context.workflowId} has no normalized state` - ) - } - const currentState: WorkflowState = { - blocks: normalized.blocks as Record, - edges: normalized.edges || [], - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - lastSaved: Date.now(), - } - const targetBlock = currentState.blocks[input.blockId] - if (!targetBlock) { - throw new OrchestrationError( - 'not_found', - `Block ${input.blockId} not found in workflow ${context.workflowId}` - ) - } - if (isBlockProtected(input.blockId, currentState.blocks)) { - throw new OrchestrationError( - 'locked', - `Block ${input.blockId} is locked or inside a locked container and cannot be updated` - ) - } - if (input.enabled && hasDisabledAncestor(input.blockId, currentState.blocks)) { - throw new OrchestrationError( - 'validation', - `Cannot enable block ${input.blockId} while one of its parent containers is disabled. Enable the parent first.` - ) + const normalized = await loadWorkflowFromNormalizedTables(context.workflowId) + if (!normalized) { + throw new OrchestrationError( + 'validation', + `Workflow ${context.workflowId} has no normalized state` + ) + } + const currentState: WorkflowState = { + blocks: normalized.blocks as Record, + edges: normalized.edges || [], + loops: normalized.loops || {}, + parallels: normalized.parallels || {}, + lastSaved: Date.now(), + } + const decision = decideBlockEnablement(currentState.blocks, input.blockId, input.enabled) + if (decision.outcome === 'refused') { + throw new OrchestrationError( + BLOCK_ENABLEMENT_REFUSAL_CODES[decision.refusal.reason], + decision.refusal.reason === 'not_found' + ? `Block ${input.blockId} not found in workflow ${context.workflowId}` + : decision.refusal.message + ) + } + if (decision.outcome === 'unchanged') { + return { + changed: false, + workflowName: context.workflow.name, + affectedBlockIds: decision.affectedBlockIds, + state: currentState, } + } - const affectedBlockIds = new Set([input.blockId]) - if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { - for (const descendantId of findDescendants(input.blockId, currentState.blocks)) { - if (!isBlockProtected(descendantId, currentState.blocks)) { - affectedBlockIds.add(descendantId) - } + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + /** + * The graph is re-read and the toggle re-decided inside the row lock. + * + * The read above is advisory: it answers "is this a refusal or a no-op" + * cheaply, but a graph read outside the lock cannot be written back safely. + * The editor's own save takes the same lock, so between that read and this + * write a canvas autosave can commit — and this operation writes a whole + * graph, not a delta, so persisting the stale copy would discard it wholly. + */ + const persisted = await replaceWorkflowNormalizedState({ + workflowId: context.workflowId, + workspaceId: context.workspaceId, + attributedUserId: attribution.attributedUserId, + state: async (tx) => { + const locked = await loadWorkflowFromNormalizedTables(context.workflowId, tx) + if (!locked) { + throw new OrchestrationError( + 'validation', + `Workflow ${context.workflowId} has no normalized state` + ) + } + const lockedBlocks = locked.blocks as Record + const lockedDecision = decideBlockEnablement(lockedBlocks, input.blockId, input.enabled) + if (lockedDecision.outcome === 'refused') { + throw new OrchestrationError( + BLOCK_ENABLEMENT_REFUSAL_CODES[lockedDecision.refusal.reason], + lockedDecision.refusal.reason === 'not_found' + ? `Block ${input.blockId} not found in workflow ${context.workflowId}` + : lockedDecision.refusal.message + ) } - } - if (targetBlock.enabled === input.enabled) { return { - changed: false, - workflowName: active.name, - affectedBlockIds: [input.blockId], - state: currentState, + blocks: lockedDecision.outcome === 'unchanged' ? lockedBlocks : lockedDecision.blocks, + edges: locked.edges || [], } - } + }, + }) - const nextBlocks = { ...currentState.blocks } - for (const blockId of affectedBlockIds) { - nextBlocks[blockId] = { ...nextBlocks[blockId], enabled: input.enabled } - } - const nextState: WorkflowState = { - ...currentState, - blocks: nextBlocks, + const blocks = persisted.state.blocks as Record + return { + changed: true, + workflowName: context.workflow.name, + affectedBlockIds: decision.affectedBlockIds, + state: { + blocks, + edges: persisted.state.edges, + loops: generateLoopBlocks(blocks), + parallels: generateParallelBlocks(blocks), lastSaved: Date.now(), - } - const saveResult = await saveWorkflowToNormalizedTables(context.workflowId, nextState, tx) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to save workflow state') - } - const [updated] = await tx - .update(workflow) - .set({ lastSynced: new Date(), updatedAt: new Date() }) - .where( - and( - eq(workflow.id, context.workflowId), - eq(workflow.workspaceId, context.workspaceId), - isNull(workflow.archivedAt) - ) - ) - .returning({ id: workflow.id }) - if (!updated) throw new OrchestrationError('not_found', 'Workflow not found') - return { - changed: true, - workflowName: active.name, - affectedBlockIds: [...affectedBlockIds], - state: nextState, - } - }) + } satisfies WorkflowState, + } }, - projectAudit: ({ input, context, result }) => + projectAudit: ({ principal, input, context, result }) => result.changed ? { action: AuditAction.WORKFLOW_UPDATED, @@ -386,7 +290,7 @@ export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ blockId: input.blockId, enabled: input.enabled, affectedBlockIds: result.affectedBlockIds, - source: 'copilot', + source: principalAuditSource(principal), }, } : [], diff --git a/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts b/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts index 8fb3038dcb7..cf7800553a4 100644 --- a/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts +++ b/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts @@ -1,8 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import type { Principal } from '@sim/auth/principal' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' import { db, workflow } from '@sim/db' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { eq } from 'drizzle-orm' +import { ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' @@ -34,20 +35,21 @@ export const updateWorkflowPublicApi = defineAuthorizedWorkflowUseCase({ assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), }), async execute({ principal, input, context }) { - if (principal.kind !== 'session') { - throw new Error('Workflow public API settings require a session principal') - } + const actingUserId = requirePrincipalSubjectUserId(principal) try { await assertWorkflowMutable(context.workflowId) if (input.isPublicApi) { - await validatePublicApiAllowed(principal.userId, context.workspaceId) + await validatePublicApiAllowed(actingUserId, context.workspaceId) } } catch (error) { if (error instanceof WorkflowLockedError) { throw new OrchestrationError('locked', error.message) } if (error instanceof PublicApiNotAllowedError) { - throw new OrchestrationError('forbidden', 'Public API access is disabled') + throw new ForbiddenOperationError( + 'PUBLIC_SHARING_NOT_ALLOWED', + 'Public API access is disabled' + ) } throw error } diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts index e6df3d563b8..8c7168eab0d 100644 --- a/apps/sim/lib/workflows/application/workflow-crud.test.ts +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -51,8 +51,11 @@ vi.mock('@sim/platform-authz/workflow', () => ({ WorkflowLockedError: class WorkflowLockedError extends Error {}, })) -vi.mock('@/lib/workflows/application/context', () => ({ +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspaceContext, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, })) diff --git a/apps/sim/lib/workflows/application/workflow-folders.test.ts b/apps/sim/lib/workflows/application/workflow-folders.test.ts index b99d0a0715b..9b7e173b7c7 100644 --- a/apps/sim/lib/workflows/application/workflow-folders.test.ts +++ b/apps/sim/lib/workflows/application/workflow-folders.test.ts @@ -16,7 +16,7 @@ const mocks = vi.hoisted(() => ({ recordAudit: vi.fn(), })) -vi.mock('@/lib/workflows/application/context', () => ({ +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveContext, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -46,9 +46,11 @@ vi.mock('@/lib/folders/queries', () => ({ })) import { + archivableWorkflowFolderPath, createWorkflowFolder, deleteWorkflowFolder, listWorkflowFolders, + workflowFolderPathForId, } from '@/lib/workflows/application/workflow-folders' const folder = { @@ -211,3 +213,30 @@ describe('workflow folder application operations', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) }) + +/** + * Archiving a folder cascades onto the workflows inside it but leaves their + * `folderId` pointing at the inactive row, and the folder index holds active + * folders only. `scope=archived` selects exactly that population, so the strict + * projector would throw a bare `Error` — an unclassified 500 that takes the + * whole page down with no cursor position able to step past the row. + */ +describe('folder path projection for archived workflows', () => { + const archivedFolderId = 'folder-archived' + + it('throws on a dangling folder when the workflow is expected to be active', () => { + expect(() => workflowFolderPathForId(index, archivedFolderId)).toThrow() + }) + + it('answers the root path instead, which is where restore would place it', () => { + expect(archivableWorkflowFolderPath(index, archivedFolderId)).toBe('/') + }) + + it('still resolves a folder that is active', () => { + expect(archivableWorkflowFolderPath(index, folder.id)).toBe('/Reports') + }) + + it('treats no folder as the root', () => { + expect(archivableWorkflowFolderPath(index, null)).toBe('/') + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-folders.ts b/apps/sim/lib/workflows/application/workflow-folders.ts index ae1bd746e89..0880af13496 100644 --- a/apps/sim/lib/workflows/application/workflow-folders.ts +++ b/apps/sim/lib/workflows/application/workflow-folders.ts @@ -18,8 +18,8 @@ import { resolveFolderPathFromIndex, } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' type WorkflowFolderRecord = typeof folder.$inferSelect type WorkflowFolderIndex = FolderPathIndex @@ -107,6 +107,28 @@ export function workflowFolderPathForId( return path } +/** + * The same projection for a workflow that may itself be archived. + * + * Archiving a folder cascades onto the workflows inside it but leaves their + * `folderId` pointing at the now-inactive row — which is exactly why restore has + * to null a dangling `folderId` before it re-reads. So on any read that can + * surface an archived workflow, an unresolvable folder is the expected state + * rather than the inconsistency {@link workflowFolderPathForId} treats it as, + * and one such row would otherwise throw a bare `Error` and 500 the whole page + * with no cursor position able to skip past it. + * + * The root is the honest answer: it is where restore would put the workflow if + * the caller restored it now. + */ +export function archivableWorkflowFolderPath( + index: WorkflowFolderIndex, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + return index.pathById.get(folderId) ?? ROOT_FOLDER_PATH +} + export const listWorkflowFolders = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.listFolders, resolveContext: ({ input }: { input: ListWorkflowFoldersInput }) => diff --git a/apps/sim/lib/workflows/application/workflow-mutability.ts b/apps/sim/lib/workflows/application/workflow-mutability.ts new file mode 100644 index 00000000000..5f5f9897c5c --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-mutability.ts @@ -0,0 +1,14 @@ +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Refuses a mutation against a locked workflow as the `423` every surface renders. */ +export async function requireMutableWorkflow(workflowId: string): Promise { + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } +} diff --git a/apps/sim/lib/workflows/application/workflow-operations-error.ts b/apps/sim/lib/workflows/application/workflow-operations-error.ts new file mode 100644 index 00000000000..e2e58a5e937 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-operations-error.ts @@ -0,0 +1,30 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { SkippedItem, ValidationError } from '@/lib/workflows/editing/types' + +/** + * An `atomic` edit batch that could not be applied whole. + * + * This lives apart from the use case that throws it so a route error policy can + * narrow on it without importing the edit engine. `route-policies.ts` is reached + * by every workflow route, and pulling `apply-workflow-operations` in from there + * would drag the engine — and its diff and comparison dependencies — into each + * one. {@link WorkflowImportError} is split out for the same reason. + */ +export class WorkflowOperationsNotAppliedError extends OrchestrationError { + constructor( + readonly skipped: SkippedItem[], + /** + * Block inputs the batch would have dropped rather than persisted — an + * invalid credential or a platform-managed API key. Refusals in their own + * right under `atomic`, and reported separately because no operation was + * declined: the operation would have applied, minus a field. + */ + readonly droppedInputs: ValidationError[] = [] + ) { + super( + 'conflict', + `${skipped.length} operation(s) could not be applied and ${droppedInputs.length} input(s) would have been dropped; atomic was requested, so nothing was written` + ) + this.name = 'WorkflowOperationsNotAppliedError' + } +} diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts index 3d93fd066f0..6a5aab30bca 100644 --- a/apps/sim/lib/workflows/application/workflow-runs.test.ts +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), resolveRunContext: vi.fn(), resolveWorkflowContext: vi.fn(), + getRunFiles: vi.fn(), + describeFiles: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -35,6 +37,11 @@ vi.mock('@/lib/workflows/executor/execution-status', () => ({ getWorkflowExecutionStatus: mocks.getStatus, })) +vi.mock('@/lib/workflows/executor/execution-run-files', () => ({ + getWorkflowRunFiles: mocks.getRunFiles, + describeWorkflowRunFiles: mocks.describeFiles, +})) + import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs' import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' @@ -78,6 +85,12 @@ describe('workflow run application use cases', () => { workflowId: 'workflow-1', status: 'completed', }) + mocks.getRunFiles.mockResolvedValue({ + terminal: true, + workspaceId: 'workspace-1', + filesById: new Map(), + }) + mocks.describeFiles.mockResolvedValue([]) }) it.each(principals)( @@ -120,6 +133,108 @@ describe('workflow run application use cases', () => { }) }) + /** + * File descriptors follow `output`'s gating: a caller that did not ask for + * output must not receive a file list it did not request. + */ + it('reports files as null when output was not requested', async () => { + const result = await readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: false, + selectedOutputs: [], + }, + }) + + expect(result.files).toBeNull() + expect(mocks.getRunFiles).not.toHaveBeenCalled() + }) + + it('describes the run files when output was requested', async () => { + mocks.describeFiles.mockResolvedValueOnce([ + { + id: 'file_1', + name: 'report.pdf', + size: 10, + type: 'application/pdf', + downloadPath: '/api/v2/workflows/workflow-1/runs/run-1/files/file_1', + base64: null, + }, + ]) + + const result = await readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: true, + selectedOutputs: [], + }, + }) + + expect(result.files).toHaveLength(1) + expect(mocks.describeFiles).toHaveBeenCalledWith( + expect.any(Map), + expect.objectContaining({ workflowId: 'workflow-1', runId: 'run-1', includeBase64: false }) + ) + }) + + it('forwards the inline request and ceiling to the descriptor projection', async () => { + await readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: true, + selectedOutputs: [], + includeFileBase64: true, + base64MaxBytes: 4096, + }, + }) + + expect(mocks.describeFiles).toHaveBeenCalledWith( + expect.any(Map), + expect.objectContaining({ includeBase64: true, base64MaxBytes: 4096 }) + ) + }) + + it('reports an empty file list for a run with no recording', async () => { + mocks.getRunFiles.mockResolvedValueOnce(null) + + const result = await readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: true, + selectedOutputs: [], + }, + }) + + expect(result.files).toEqual([]) + }) + + it('propagates an over-ceiling inline request as payload_too_large', async () => { + mocks.describeFiles.mockRejectedValueOnce( + Object.assign(new Error('exceeds the 16MB inline limit'), { code: 'payload_too_large' }) + ) + + await expect( + readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: true, + selectedOutputs: [], + includeFileBase64: true, + }, + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + }) + it('stops before authorization and data access when canonical run scope disagrees', async () => { mocks.resolveRunContext.mockRejectedValueOnce( Object.assign(new Error('Run not found'), { code: 'not_found' }) diff --git a/apps/sim/lib/workflows/application/workflow-variables.ts b/apps/sim/lib/workflows/application/workflow-variables.ts new file mode 100644 index 00000000000..76dca525cfe --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-variables.ts @@ -0,0 +1,101 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' + +const logger = createLogger('WorkflowVariables') + +/** + * A workflow variable as it is stored in `workflow.variables`. + * + * The column is schemaless JSONB, so the only guarantee it has is the one every + * write path applies through {@link normalizeWorkflowVariables}: a record keyed + * by variable id, each entry carrying its own `id`, `name`, and `type`. + */ +export interface WorkflowVariable { + id: string + workflowId?: string + name: string + type: string + value?: unknown +} + +/** + * Projects a declared-typed variable value onto that type. + * + * A caller may send `"42"` for a `number` or `"true"` for a `boolean` — the UI + * edits every variable as text — and the executor reads the stored value + * without re-coercing it, so the coercion has to happen on the way in. A value + * that cannot be coerced is stored verbatim rather than rejected: the type is + * advisory and validated per use site. + */ +export function coerceWorkflowVariableValue(value: unknown, type: string): unknown { + if (value === undefined) return value + if (type === 'number') { + const number = Number(value) + return Number.isNaN(number) ? value : number + } + if (type === 'boolean') { + const normalized = String(value).trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + return value + } + if (type !== 'array' && type !== 'object') return value + + try { + const parsed: unknown = JSON.parse(String(value)) + if (type === 'array' && Array.isArray(parsed)) return parsed + if (type === 'object' && isRecordLike(parsed)) { + return parsed + } + } catch (error) { + logger.warn('Failed to parse JSON value for workflow variable coercion', { + error: getErrorMessage(error), + }) + } + return value +} + +function toVariableEntries(variables: unknown): unknown[] { + if (Array.isArray(variables)) return variables + if (isRecordLike(variables)) return Object.values(variables) + return [] +} + +/** + * The single door onto `workflow.variables`. + * + * Both write paths — `PATCH /workflows/{id}/variables` and + * `PUT /workflows/{id}/state` — go through this, so the column can only ever + * hold one shape. `PUT /state` used to pass the caller's record through + * verbatim, which let the record key disagree with the entry's own `id` and let + * a `number` variable hold the string `"42"`; the read side then had to carry + * defensive parsing for a shape that should never have been written. + * + * Entries without a string `id` and `name` are dropped: they cannot be + * addressed by either key, and a keyless entry is what the defensive read + * machinery exists to survive. + * + * `coerceValues` is set by the caller that is writing the values themselves. + * `PATCH /variables` coerces per operation and carries untouched entries + * through unchanged, so re-coercing its whole set would re-parse values that + * are already in their declared type. + */ +export function normalizeWorkflowVariables( + variables: unknown, + options: { coerceValues?: boolean } = {} +): Record { + const normalized: Record = {} + for (const entry of toVariableEntries(variables)) { + if (!isRecordLike(entry)) continue + const { id, name } = entry + if (typeof id !== 'string' || typeof name !== 'string') continue + const type = typeof entry.type === 'string' ? entry.type : 'plain' + const projected: WorkflowVariable = { ...entry, id, name, type } + if (options.coerceValues && 'value' in entry) { + projected.value = coerceWorkflowVariableValue(entry.value, type) + } + normalized[id] = projected + } + return normalized +} diff --git a/apps/sim/lib/workflows/application/workflow-vfs.test.ts b/apps/sim/lib/workflows/application/workflow-vfs.test.ts index 62af1155e8a..bb6303f2a26 100644 --- a/apps/sim/lib/workflows/application/workflow-vfs.test.ts +++ b/apps/sim/lib/workflows/application/workflow-vfs.test.ts @@ -69,7 +69,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.permission, })) -vi.mock('@/lib/workflows/application/context', () => ({ +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveContext, })) diff --git a/apps/sim/lib/workflows/application/workflow-vfs.ts b/apps/sim/lib/workflows/application/workflow-vfs.ts index deb32724d46..93c49e1af74 100644 --- a/apps/sim/lib/workflows/application/workflow-vfs.ts +++ b/apps/sim/lib/workflows/application/workflow-vfs.ts @@ -35,12 +35,12 @@ import { import { VfsPathLimitError, validateVfsPathSegments } from '@/lib/vfs/limits' import { encodeVfsPathSegments } from '@/lib/vfs/path' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' import { deleteWorkflowRecord, updateWorkflowRecord } from '@/lib/workflows/orchestration' import { duplicateWorkflow as duplicateWorkflowRecord } from '@/lib/workflows/persistence/duplicate' import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' const MAX_WORKFLOW_VFS_ITEMS = 100 const MAX_WORKFLOW_VFS_INDEX_ROWS = 10_000 diff --git a/apps/sim/lib/workflows/editing/block-enablement.ts b/apps/sim/lib/workflows/editing/block-enablement.ts new file mode 100644 index 00000000000..dc8d92a21f9 --- /dev/null +++ b/apps/sim/lib/workflows/editing/block-enablement.ts @@ -0,0 +1,128 @@ +import type { BlockState } from '@sim/workflow-types/workflow' + +/** Whether a block, or any container above it, is locked against edits. */ +export function isBlockProtected(blockId: string, blocksById: Record): boolean { + const block = blocksById[blockId] + if (!block) return false + if (block.locked) return true + + const visited = new Set() + let parentId = block.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + if (blocksById[parentId]?.locked) return true + parentId = blocksById[parentId]?.data?.parentId + } + return false +} + +/** Whether any container above a block is disabled, which keeps the block from running. */ +export function hasDisabledAncestor( + blockId: string, + blocksById: Record +): boolean { + const visited = new Set() + let parentId = blocksById[blockId]?.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = blocksById[parentId] + if (!parent) return false + if (parent.enabled === false) return true + parentId = parent.data?.parentId + } + return false +} + +/** Every block nested, at any depth, inside a container. */ +export function findDescendants( + containerId: string, + blocksById: Record +): string[] { + const descendants: string[] = [] + const stack = [containerId] + const visited = new Set() + while (stack.length > 0) { + const current = stack.pop()! + if (visited.has(current)) continue + visited.add(current) + for (const [blockId, block] of Object.entries(blocksById)) { + if (block.data?.parentId === current) { + descendants.push(blockId) + stack.push(blockId) + } + } + } + return descendants +} + +export type BlockEnablementRefusal = + | { reason: 'not_found'; message: string } + | { reason: 'locked'; message: string } + | { reason: 'disabled_ancestor'; message: string } + +export type BlockEnablementDecision = + | { outcome: 'refused'; refusal: BlockEnablementRefusal } + | { outcome: 'unchanged'; affectedBlockIds: string[] } + | { outcome: 'changed'; blocks: Record; affectedBlockIds: string[] } + +/** + * Decides what enabling or disabling one block does to a graph. + * + * Pure, and the single source of truth for the three protection rules — a + * locked block or locked container cannot be toggled, a block cannot be enabled + * while a container above it is disabled, and toggling a loop or parallel + * cascades to its unlocked descendants. Both the dedicated + * `workflows.blocks.set_enabled` use case and the `setBlockEnabled` slice of a + * `workflows.operations.apply` batch call it, so the two cannot drift into + * disagreeing about what is protected. + */ +export function decideBlockEnablement( + blocks: Record, + blockId: string, + enabled: boolean +): BlockEnablementDecision { + const targetBlock = blocks[blockId] + if (!targetBlock) { + return { + outcome: 'refused', + refusal: { reason: 'not_found', message: `Block ${blockId} not found` }, + } + } + if (isBlockProtected(blockId, blocks)) { + return { + outcome: 'refused', + refusal: { + reason: 'locked', + message: `Block ${blockId} is locked or inside a locked container and cannot be updated`, + }, + } + } + if (enabled && hasDisabledAncestor(blockId, blocks)) { + return { + outcome: 'refused', + refusal: { + reason: 'disabled_ancestor', + message: `Cannot enable block ${blockId} while one of its parent containers is disabled. Enable the parent first.`, + }, + } + } + + const affectedBlockIds = new Set([blockId]) + if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { + for (const descendantId of findDescendants(blockId, blocks)) { + if (!isBlockProtected(descendantId, blocks)) { + affectedBlockIds.add(descendantId) + } + } + } + + if (targetBlock.enabled === enabled) { + return { outcome: 'unchanged', affectedBlockIds: [blockId] } + } + + const nextBlocks = { ...blocks } + for (const affectedId of affectedBlockIds) { + nextBlocks[affectedId] = { ...nextBlocks[affectedId], enabled } + } + return { outcome: 'changed', blocks: nextBlocks, affectedBlockIds: [...affectedBlockIds] } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts b/apps/sim/lib/workflows/editing/builders.test.ts similarity index 98% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts rename to apps/sim/lib/workflows/editing/builders.test.ts index bf328ff899a..d618b7a2d7c 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts +++ b/apps/sim/lib/workflows/editing/builders.test.ts @@ -9,8 +9,8 @@ import { filterDisallowedTools, normalizeSubblockValue, resolveBlockRetryUpdate, -} from '@/lib/copilot/tools/server/workflow/edit-workflow/builders' -import type { SkippedItem } from '@/lib/copilot/tools/server/workflow/edit-workflow/types' +} from '@/lib/workflows/editing/builders' +import type { SkippedItem } from '@/lib/workflows/editing/types' const { mockIsIntegrationDeploymentAvailable } = vi.hoisted(() => ({ mockIsIntegrationDeploymentAvailable: vi.fn(() => true), diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts b/apps/sim/lib/workflows/editing/builders.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts rename to apps/sim/lib/workflows/editing/builders.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts b/apps/sim/lib/workflows/editing/engine.ts similarity index 98% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts rename to apps/sim/lib/workflows/editing/engine.ts index 11405480bf4..b58642d30ad 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts +++ b/apps/sim/lib/workflows/editing/engine.ts @@ -165,7 +165,7 @@ export function applyOperationsToWorkflowState( const skippedItems: SkippedItem[] = [] // Normalize block IDs to UUIDs before processing - const { normalizedOperations } = normalizeBlockIdsInOperations(operations) + const { normalizedOperations, idMapping } = normalizeBlockIdsInOperations(operations) // Order operations for deterministic application const orderedOperations = orderOperations(normalizedOperations) @@ -288,7 +288,12 @@ export function applyOperationsToWorkflowState( ) } - return { state: modifiedState, validationErrors, skippedItems } + return { + state: modifiedState, + validationErrors, + skippedItems, + mintedBlockIds: Object.fromEntries(idMapping), + } } /** diff --git a/apps/sim/lib/workflows/editing/lint-report.ts b/apps/sim/lib/workflows/editing/lint-report.ts new file mode 100644 index 00000000000..7aab7d9980c --- /dev/null +++ b/apps/sim/lib/workflows/editing/lint-report.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { WorkflowState } from '@sim/workflow-types/workflow' +import { + collectWorkflowFieldIssues, + lintEditedWorkflowState, + type WorkflowLintReport, + type WorkflowLintUnresolvedReference, +} from '@/lib/workflows/editing/lint' +import { + collectUnresolvedAgentToolReferences, + collectUnresolvedReferences, + UNRESOLVABLE_AT_LINT_NOTE, +} from '@/lib/workflows/editing/validation' + +const logger = createLogger('WorkflowLintReport') + +/** + * Why a report carries no reference findings for a non-human caller. + * + * Credential, tool, and skill references resolve against a specific human's + * grants. A workspace API key has no human subject, and the identity that would + * stand in for one is the workspace's billing owner — a different person, whose + * credentials this caller cannot use. Resolving against them would report a + * reference as resolvable when the workflow cannot in fact reach it, and would + * disclose which credentials that human holds. So the reference pass is skipped + * and said to be skipped, rather than answered against the wrong identity. + */ +export const REFERENCES_UNCHECKED_NOTE = + 'Credential, tool, and skill references were not checked because this caller does not act as a human user. Structural and field findings are complete.' + +export interface WorkflowLintScope { + workflowId: string + workspaceId: string + /** + * The human whose grants references resolve against, or `null` when the + * caller is not acting as one. Never a billing-owner or creator stand-in. + */ + subjectUserId: string | null +} + +/** + * Builds the advisory report published by both graph writes. + * + * Shared so `PUT /state` and `POST /operations` cannot drift: an agent that + * authors a whole graph and one that edits it incrementally need the same + * findings, and a finding added for one caller must reach the other. + * + * Findings never block a write. Reference resolution is best-effort — its + * collectors read the database, and a failure there must not fail a write that + * has already been validated — so a collector that throws is logged and its + * findings omitted rather than propagated. + */ +export async function buildWorkflowLintReport( + graph: Pick, + scope: WorkflowLintScope +): Promise { + const unresolvedReferences: WorkflowLintUnresolvedReference[] = [] + + if (scope.subjectUserId) { + for (const collect of [collectUnresolvedReferences, collectUnresolvedAgentToolReferences]) { + try { + /** + * Reported only through `lint`. These collectors are read-only, so the + * values they flag stay persisted — pushing them into + * `inputValidationErrors` as well would double-report them, and falsely, + * since that field means "dropped rather than persisted". + */ + const references = await collect(graph, { + userId: scope.subjectUserId, + workspaceId: scope.workspaceId, + }) + unresolvedReferences.push(...references) + } catch (error) { + logger.warn('Reference resolution lint failed', { + workflowId: scope.workflowId, + error: getErrorMessage(error), + }) + } + } + } + + const notes: string[] = [] + if (!scope.subjectUserId) notes.push(REFERENCES_UNCHECKED_NOTE) + if (unresolvedReferences.length > 0) notes.push(UNRESOLVABLE_AT_LINT_NOTE) + + /** + * `fieldIssues`, `unresolvedReferences`, and `notes` are assigned after the + * graph-lint spread and that is safe: {@link lintEditedWorkflowState} returns + * `WorkflowLintResult`, which declares none of them, so the assignment can + * never discard a finding the linter made. + */ + return { + ...lintEditedWorkflowState(graph), + fieldIssues: collectWorkflowFieldIssues(graph.blocks), + unresolvedReferences, + notes, + } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts b/apps/sim/lib/workflows/editing/lint.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts rename to apps/sim/lib/workflows/editing/lint.test.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts b/apps/sim/lib/workflows/editing/lint.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts rename to apps/sim/lib/workflows/editing/lint.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/workflows/editing/operations.test.ts similarity index 94% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts rename to apps/sim/lib/workflows/editing/operations.test.ts index 30312b308e9..7d0c21e3fd2 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts +++ b/apps/sim/lib/workflows/editing/operations.test.ts @@ -751,3 +751,33 @@ describe('forward-reference connections (pending resolution)', () => { expect(state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined() }) }) + +/** + * A caller that names a new block `triage` gets a UUID instead, because the + * graph holds one id shape. Without the mapping coming back out, it cannot + * reference what it just created except by re-reading the graph and matching on + * name — which is why `POST /workflows/{workflowId}/operations` publishes it. + */ +describe('minted block ids', () => { + it('reports the id a non-UUID block_id was replaced with', () => { + const { state, mintedBlockIds } = applyOperationsToWorkflowState(makeDependentWorkflow(), [ + { operation_type: 'add', block_id: 'triage', params: { type: 'agent', name: 'Triage' } }, + ]) + + expect(Object.keys(mintedBlockIds)).toEqual(['triage']) + const mintedId = mintedBlockIds.triage + expect(mintedId).not.toBe('triage') + expect(state.blocks[mintedId]).toBeDefined() + expect(state.blocks.triage).toBeUndefined() + }) + + it('reports nothing for a block_id that is already a UUID', () => { + const uuid = 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77' + const { state, mintedBlockIds } = applyOperationsToWorkflowState(makeDependentWorkflow(), [ + { operation_type: 'add', block_id: uuid, params: { type: 'agent', name: 'Kept' } }, + ]) + + expect(mintedBlockIds).toEqual({}) + expect(state.blocks[uuid]).toBeDefined() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/workflows/editing/operations.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts rename to apps/sim/lib/workflows/editing/operations.ts diff --git a/apps/sim/lib/copilot/sim-sandbox-projection.test.ts b/apps/sim/lib/workflows/editing/sandbox-projection.test.ts similarity index 88% rename from apps/sim/lib/copilot/sim-sandbox-projection.test.ts rename to apps/sim/lib/workflows/editing/sandbox-projection.test.ts index c576b3ee409..fd803088901 100644 --- a/apps/sim/lib/copilot/sim-sandbox-projection.test.ts +++ b/apps/sim/lib/workflows/editing/sandbox-projection.test.ts @@ -1,7 +1,7 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { operationsReferenceSimSandbox } from '@/lib/copilot/sim-sandbox-projection' +import { operationsReferenceSimSandbox } from '@/lib/workflows/editing/sandbox-projection' describe('operationsReferenceSimSandbox', () => { it('detects add and edit inputs that set or clear sandboxId', () => { diff --git a/apps/sim/lib/workflows/editing/sandbox-projection.ts b/apps/sim/lib/workflows/editing/sandbox-projection.ts new file mode 100644 index 00000000000..8f313907808 --- /dev/null +++ b/apps/sim/lib/workflows/editing/sandbox-projection.ts @@ -0,0 +1,9 @@ +/** Whether an edit-operation batch tries to set or clear a Function block's `sandboxId`. */ +export function operationsReferenceSimSandbox( + operations: ReadonlyArray<{ params?: Record }> +): boolean { + return operations.some((operation) => { + const inputs = operation.params?.inputs + return Boolean(inputs && typeof inputs === 'object' && 'sandboxId' in inputs) + }) +} diff --git a/apps/sim/lib/copilot/validation/selector-validator.test.ts b/apps/sim/lib/workflows/editing/selector-validator.test.ts similarity index 70% rename from apps/sim/lib/copilot/validation/selector-validator.test.ts rename to apps/sim/lib/workflows/editing/selector-validator.test.ts index 6dbb96d81b1..57ebe83e15a 100644 --- a/apps/sim/lib/copilot/validation/selector-validator.test.ts +++ b/apps/sim/lib/workflows/editing/selector-validator.test.ts @@ -67,6 +67,12 @@ describe('validateSelectorIds', () => { expect(result.warning).toContain('Shared Gmail [cred-2]') }) + /** + * The mocked `where` hands back the row whatever predicate it is given, so + * the returned value proves nothing on its own. What has to be asserted is + * the predicate: the admin branch drops the credential-membership clause, + * and the member branch keeps it. + */ it('lets a derived workspace admin reference shared credentials without membership', async () => { mockCheckWorkspaceAccess.mockResolvedValueOnce({ canAdmin: true }) dbChainMockFns.where.mockResolvedValueOnce([{ credentialId: 'shared-cred', accountId: null }]) @@ -78,5 +84,30 @@ describe('validateSelectorIds', () => { expect(result).toEqual({ valid: ['shared-cred'], invalid: [] }) expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(membershipClauseOf(dbChainMockFns.where.mock.calls[0][0])).toBeUndefined() + }) + + it('still requires credential membership for a non-admin member', async () => { + dbChainMockFns.where.mockResolvedValueOnce([{ credentialId: 'shared-cred', accountId: null }]) + + await validateSelectorIds('oauth-input', ['shared-cred'], { + userId: 'member-user', + workspaceId: 'workspace-1', + }) + + expect(membershipClauseOf(dbChainMockFns.where.mock.calls[0][0])).toMatchObject({ + type: 'isNotNull', + }) }) }) + +/** + * The second argument of the outer `and(...)` the query is filtered by. The + * mocked drizzle helpers record their arguments verbatim, so the membership + * clause is either an `isNotNull` node or `undefined`. + */ +function membershipClauseOf(predicate: unknown): unknown { + const node = predicate as { type?: string; args?: unknown[] } + expect(node.type).toBe('and') + return node.args?.[1] +} diff --git a/apps/sim/lib/copilot/validation/selector-validator.ts b/apps/sim/lib/workflows/editing/selector-validator.ts similarity index 100% rename from apps/sim/lib/copilot/validation/selector-validator.ts rename to apps/sim/lib/workflows/editing/selector-validator.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts b/apps/sim/lib/workflows/editing/types.ts similarity index 69% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts rename to apps/sim/lib/workflows/editing/types.ts index 305e175c162..32a471becb2 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts +++ b/apps/sim/lib/workflows/editing/types.ts @@ -28,29 +28,44 @@ export interface ValidationError { error: string } +/** + * Every reason the engine can decline one operation. + * + * An array rather than a bare union so the public contract can publish the set + * with `z.enum(...)` and a new reason cannot reach the wire undocumented. + */ +export const WORKFLOW_SKIPPED_ITEM_TYPES = [ + 'block_not_found', + 'invalid_block_type', + 'block_not_allowed', + 'block_locked', + 'tool_not_allowed', + 'invalid_edge_target', + 'invalid_edge_source', + 'invalid_edge_scope', + 'invalid_source_handle', + 'invalid_target_handle', + 'invalid_subblock_field', + 'missing_required_params', + 'invalid_subflow_parent', + 'nested_subflow_not_allowed', + 'duplicate_block_name', + 'reserved_block_name', + 'retry_not_supported', + 'duplicate_trigger', + 'duplicate_single_instance_block', + /** + * A block was left disabled because a container above it is disabled. The + * engine will not enable a block its container would keep from running; the + * caller has to enable the container first. + */ + 'disabled_ancestor', +] as const + /** * Types of items that can be skipped during operation application */ -export type SkippedItemType = - | 'block_not_found' - | 'invalid_block_type' - | 'block_not_allowed' - | 'block_locked' - | 'tool_not_allowed' - | 'invalid_edge_target' - | 'invalid_edge_source' - | 'invalid_edge_scope' - | 'invalid_source_handle' - | 'invalid_target_handle' - | 'invalid_subblock_field' - | 'missing_required_params' - | 'invalid_subflow_parent' - | 'nested_subflow_not_allowed' - | 'duplicate_block_name' - | 'reserved_block_name' - | 'retry_not_supported' - | 'duplicate_trigger' - | 'duplicate_single_instance_block' +export type SkippedItemType = (typeof WORKFLOW_SKIPPED_ITEM_TYPES)[number] /** * Represents an item that was skipped during operation application @@ -138,6 +153,15 @@ export interface ApplyOperationsResult { state: any validationErrors: ValidationError[] skippedItems: SkippedItem[] + /** + * Requested `block_id` -> the id the block was actually given, for every + * `add`/`insert_into_subflow` whose requested id was not already a UUID. + * + * The engine mints a UUID for those so the graph holds one id shape, and + * without handing the mapping back a caller cannot reference what it just + * created except by re-reading the graph and matching on name. + */ + mintedBlockIds: Record } export interface OperationContext { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts rename to apps/sim/lib/workflows/editing/validation.test.ts index abc89f9cf4d..f3255a20200 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -256,7 +256,7 @@ vi.mock('@/tools/utils', () => ({ getTool: mockGetTool, })) -vi.mock('@/lib/copilot/validation/selector-validator', () => ({ +vi.mock('@/lib/workflows/editing/selector-validator', () => ({ validateSelectorIds: mockValidateSelectorIds, })) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/workflows/editing/validation.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts rename to apps/sim/lib/workflows/editing/validation.ts index 7d87fbdba63..f94496fb66b 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -1,12 +1,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' -import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' +import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' import { getSkillById } from '@/lib/workflows/skills/operations' import { buildCanonicalIndex, diff --git a/apps/sim/lib/workflows/executor/execution-run-files.test.ts b/apps/sim/lib/workflows/executor/execution-run-files.test.ts new file mode 100644 index 00000000000..e1d325c250b --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-run-files.test.ts @@ -0,0 +1,237 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + downloadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFile: mocks.downloadFile, +})) + +import { + describeWorkflowRunFiles, + workflowRunFileDownloadPath, +} from '@/lib/workflows/executor/execution-run-files' +import type { UserFile } from '@/executor/types' + +const WORKFLOW_ID = 'workflow-1' +const RUN_ID = 'run-1' + +function runFile(overrides: Partial = {}): UserFile { + return { + id: 'file_report', + name: 'report.pdf', + url: '/api/files/serve/s3/execution%2Fws%2Fwf%2Frun%2Freport.pdf', + size: 3, + type: 'application/pdf', + key: 'execution/ws/wf/run/report.pdf', + ...overrides, + } +} + +function filesMap(files: UserFile[]): Map { + return new Map(files.map((file) => [file.id, file])) +} + +describe('describeWorkflowRunFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.downloadFile.mockResolvedValue(Buffer.from('pdf')) + }) + + it('describes files without their storage key', async () => { + const [descriptor] = await describeWorkflowRunFiles(filesMap([runFile()]), { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: false, + }) + + expect(descriptor).toEqual({ + id: 'file_report', + name: 'report.pdf', + size: 3, + type: 'application/pdf', + downloadPath: `/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/files/file_report`, + base64: null, + }) + expect(descriptor).not.toHaveProperty('key') + }) + + it('does not read storage when base64 was not requested', async () => { + await describeWorkflowRunFiles(filesMap([runFile()]), { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: false, + }) + + expect(mocks.downloadFile).not.toHaveBeenCalled() + }) + + it('inlines bytes when requested', async () => { + const [descriptor] = await describeWorkflowRunFiles(filesMap([runFile()]), { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + }) + + expect(descriptor.base64).toBe(Buffer.from('pdf').toString('base64')) + expect(mocks.downloadFile).toHaveBeenCalledWith({ + key: 'execution/ws/wf/run/report.pdf', + context: 'execution', + maxBytes: 16 * 1024 * 1024, + }) + }) + + /** + * The 413 must name the download path, so a caller that hits the ceiling is + * told exactly how to get the bytes instead of being left stuck. + */ + it('rejects a file above the inline ceiling and names its download path', async () => { + await expect( + describeWorkflowRunFiles(filesMap([runFile({ size: 17 * 1024 * 1024 })]), { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + }) + ).rejects.toMatchObject({ + code: 'payload_too_large', + message: expect.stringContaining( + `/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/files/file_report` + ), + }) + expect(mocks.downloadFile).not.toHaveBeenCalled() + }) + + it('clamps a caller ceiling above the server limit', async () => { + await expect( + describeWorkflowRunFiles(filesMap([runFile({ size: 17 * 1024 * 1024 })]), { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + base64MaxBytes: 500 * 1024 * 1024, + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + }) + + it('honours a caller ceiling below the server limit', async () => { + await expect( + describeWorkflowRunFiles(filesMap([runFile({ size: 2048 })]), { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + base64MaxBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + }) + + /** + * The per-file ceiling bounds one file; without an aggregate ceiling a run with + * many files multiplied it by the file count and the response was unbounded. + */ + it('rejects an inline set whose total exceeds the response ceiling', async () => { + const files = filesMap([ + runFile({ id: 'file_a', name: 'a.pdf', size: 9 * 1024 * 1024 }), + runFile({ id: 'file_b', name: 'b.pdf', size: 9 * 1024 * 1024 }), + ]) + + await expect( + describeWorkflowRunFiles(files, { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + }) + ).rejects.toMatchObject({ + code: 'payload_too_large', + message: expect.stringContaining( + `/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/files/file_b` + ), + }) + expect(mocks.downloadFile).not.toHaveBeenCalled() + }) + + /** A recorded size that understates the object must not slip past the ceiling. */ + it('rejects when the bytes actually read exceed the response ceiling', async () => { + mocks.downloadFile.mockResolvedValue(Buffer.alloc(9 * 1024 * 1024)) + const files = filesMap([ + runFile({ id: 'file_a', name: 'a.pdf', size: 1 }), + runFile({ id: 'file_b', name: 'b.pdf', size: 1 }), + ]) + + await expect( + describeWorkflowRunFiles(files, { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + }) + + /** + * Retention sweeps a run's objects while its log row remains, so asking a + * settled run for its inline files can reach an object that is gone. That is + * an absent object, not a server fault — propagating the provider error would + * render a 500 for a well-formed request. + */ + it.each(['NoSuchKey', 'BlobNotFound', 'NotFound'])( + 'reports a swept object (%s) as not found rather than a fault', + async (name) => { + mocks.downloadFile.mockRejectedValueOnce(Object.assign(new Error('gone'), { name })) + + await expect( + describeWorkflowRunFiles(filesMap([runFile({ name: 'report.pdf' })]), { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + /** A missing bucket is a misconfiguration worth alerting on, not an absent file. */ + it('propagates a storage outage rather than reporting it as not found', async () => { + mocks.downloadFile.mockRejectedValueOnce(new Error('s3 unavailable')) + + await expect( + describeWorkflowRunFiles(filesMap([runFile()]), { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + }) + ).rejects.toThrow('s3 unavailable') + }) + + it('bounds how many inline reads are in flight at once', async () => { + let inFlight = 0 + let peak = 0 + mocks.downloadFile.mockImplementation(async () => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await Promise.resolve() + inFlight -= 1 + return Buffer.from('pdf') + }) + const files = filesMap( + Array.from({ length: 20 }, (_, index) => + runFile({ id: `file_${index}`, name: `${index}.pdf`, size: 3 }) + ) + ) + + await describeWorkflowRunFiles(files, { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + includeBase64: true, + }) + + expect(mocks.downloadFile).toHaveBeenCalledTimes(20) + expect(peak).toBeLessThanOrEqual(4) + }) + + it('builds the download path from the run identifiers', () => { + expect(workflowRunFileDownloadPath('wf-9', 'run-9', 'file-9')).toBe( + '/api/v2/workflows/wf-9/runs/run-9/files/file-9' + ) + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-run-files.ts b/apps/sim/lib/workflows/executor/execution-run-files.ts new file mode 100644 index 00000000000..8b1ed18ec9c --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-run-files.ts @@ -0,0 +1,233 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { collectUserFilesById } from '@/lib/core/utils/user-file' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { formatFileSize, inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { isRunOutputFileKey } from '@/lib/workflows/executor/run-file-scope' +import { classifyRunFileStorageError } from '@/lib/workflows/executor/run-file-storage-error' +import type { UserFile } from '@/executor/types' + +/** Run states whose recorded output is final and therefore safe to address. */ +const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']) + +export interface WorkflowRunFilesInput { + workflowId: string + runId: string +} + +export interface WorkflowRunFiles { + /** Whether the run has finished; a live run's output is still changing. */ + terminal: boolean + workspaceId: string | null + /** Files the run's recorded output references, indexed by their file id. */ + filesById: Map +} + +/** + * Reads the authoritative set of files a run produced. + * + * The set is derived from the run's own recorded execution data, materialized + * but deliberately *not* projected for display: the display projection strips + * `key` and `context` (see `USER_FILE_DISPLAY_FIELDS`), which are exactly the + * fields a byte read needs. + * + * Rebuilding the id→key mapping from the recording is necessary but not + * sufficient: the recording itself contains caller-supplied input echoed + * verbatim by the start block, so it can carry a `UserFile` naming any storage + * key. Every entry is therefore filtered through {@link isRunOutputFileKey}, + * which admits only keys under this run's own execution prefix. Downstream byte + * reads rely on that filter — they perform no per-file authorization of their + * own, because after it there is no key left that is not this run's output. + * + * Returns `null` when no log row exists for the run. + */ +export async function getWorkflowRunFiles( + input: WorkflowRunFilesInput +): Promise { + const [logRow] = await db + .select({ + workspaceId: workflowExecutionLogs.workspaceId, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + status: workflowExecutionLogs.status, + executionData: workflowExecutionLogs.executionData, + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, input.runId), + eq(workflowExecutionLogs.workflowId, input.workflowId) + ) + ) + .limit(1) + + if (!logRow) return null + + const terminal = TERMINAL_RUN_STATUSES.has(logRow.status) + if (!terminal) { + return { terminal: false, workspaceId: logRow.workspaceId, filesById: new Map() } + } + + const materialized = await materializeExecutionData( + logRow.executionData as Record | null, + { + workspaceId: logRow.workspaceId, + workflowId: logRow.workflowId, + executionId: logRow.executionId, + } + ) + + const scope = { + workspaceId: logRow.workspaceId, + workflowId: logRow.workflowId, + executionId: logRow.executionId, + } + const filesById = new Map() + for (const [id, file] of collectUserFilesById(materialized)) { + if (isRunOutputFileKey(file.key, scope)) { + filesById.set(id, file) + } + } + + return { terminal: true, workspaceId: logRow.workspaceId, filesById } +} + +/** Public path a caller uses to fetch one run file's bytes. */ +export function workflowRunFileDownloadPath( + workflowId: string, + runId: string, + fileId: string +): string { + return `/api/v2/workflows/${workflowId}/runs/${runId}/files/${fileId}` +} + +export interface WorkflowRunFileDescriptor { + id: string + name: string + size: number + type: string + downloadPath: string + base64: string | null +} + +/** + * Aggregate ceiling on the bytes one response inlines. The per-file cap bounds a + * single file, but a run can produce many: without this, `includeFileBase64` + * multiplied that cap by the file count and the response was unbounded. + */ +const MAX_INLINE_RUN_FILE_TOTAL_BYTES = MAX_INLINE_MATERIALIZATION_BYTES + +/** + * Bound on concurrent inline byte reads. Each read may pull up to the per-file + * cap, so reading every file at once is a memory spike proportional to the run's + * file count rather than to this pool. + */ +const INLINE_RUN_FILE_CONCURRENCY = 4 + +function inlineTotalExceededError(fileName: string, downloadPath: string): OrchestrationError { + return new OrchestrationError( + 'payload_too_large', + `Inlining this run's files exceeds the ${formatFileSize(MAX_INLINE_RUN_FILE_TOTAL_BYTES)} response limit at "${fileName}"; request the run without file contents and download each file, starting with GET ${downloadPath}` + ) +} + +export interface DescribeWorkflowRunFilesOptions { + workflowId: string + runId: string + includeBase64: boolean + /** Inline ceiling per file. Clamped to the executor's own inline limit. */ + base64MaxBytes?: number +} + +/** + * Projects a run's recorded files into public descriptors. + * + * The storage `key` is deliberately absent from the descriptor: a caller + * addresses a file by `id` at {@link workflowRunFileDownloadPath}, and the key + * is re-derived server side from the recording on every request. + * + * Inline hydration reads the bytes with the key taken from that same recording, + * under the run authorization the caller already passed. It does not re-derive + * an acting user to re-check per-file ownership, because there is no + * user-scoped question left to ask: the bytes are this run's own output and the + * run has already been bound to the caller's workspace. + * + * Hydration is bounded twice over: by {@link MAX_INLINE_RUN_FILE_TOTAL_BYTES} + * across the whole response — checked against declared sizes before any read and + * again against the bytes actually returned, in case a recorded size understates + * the object — and by {@link INLINE_RUN_FILE_CONCURRENCY} on how many reads are + * in flight at once. + */ +export async function describeWorkflowRunFiles( + filesById: Map, + options: DescribeWorkflowRunFilesOptions +): Promise { + const cap = Math.min( + options.base64MaxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES, + MAX_INLINE_MATERIALIZATION_BYTES + ) + const files = Array.from(filesById.values()) + const describe = (file: UserFile, base64: string | null): WorkflowRunFileDescriptor => ({ + id: file.id, + name: file.name, + size: file.size, + type: file.type, + downloadPath: workflowRunFileDownloadPath(options.workflowId, options.runId, file.id), + base64, + }) + + if (!options.includeBase64) { + return files.map((file) => describe(file, null)) + } + + let declaredBytes = 0 + for (const file of files) { + const downloadPath = workflowRunFileDownloadPath(options.workflowId, options.runId, file.id) + if (file.size > cap) { + throw new OrchestrationError( + 'payload_too_large', + `File "${file.name}" (${formatFileSize(file.size)}) exceeds the ${formatFileSize(cap)} inline limit; download it with GET ${downloadPath}` + ) + } + declaredBytes += file.size + if (declaredBytes > MAX_INLINE_RUN_FILE_TOTAL_BYTES) { + throw inlineTotalExceededError(file.name, downloadPath) + } + } + + let inlinedBytes = 0 + return mapWithConcurrency(files, INLINE_RUN_FILE_CONCURRENCY, async (file) => { + let content: Buffer + try { + content = await downloadFile({ + key: file.key, + context: inferContextFromKey(file.key), + maxBytes: cap, + }) + } catch (error) { + /** + * Retention sweeps a run's objects while its log row remains, so a + * recorded file can outlive its bytes. That is an absent object, not a + * server fault, and it is reachable by any caller asking a settled run for + * its inline files. + */ + throw classifyRunFileStorageError( + error, + `File "${file.name}" is no longer available in storage; its bytes have been removed by retention.` + ) + } + inlinedBytes += content.length + if (inlinedBytes > MAX_INLINE_RUN_FILE_TOTAL_BYTES) { + throw inlineTotalExceededError( + file.name, + workflowRunFileDownloadPath(options.workflowId, options.runId, file.id) + ) + } + return describe(file, content.toString('base64')) + }) +} diff --git a/apps/sim/lib/workflows/executor/run-file-scope.ts b/apps/sim/lib/workflows/executor/run-file-scope.ts new file mode 100644 index 00000000000..3bfb5f6ebf9 --- /dev/null +++ b/apps/sim/lib/workflows/executor/run-file-scope.ts @@ -0,0 +1,46 @@ +/** The scope a run's own output files are written under. */ +export interface RunFileScope { + workspaceId: string | null + workflowId: string | null + executionId: string +} + +/** + * Whether a storage key names a file this specific run produced. + * + * A run's recorded output is not a trustworthy source of storage keys. The start + * block copies every caller-supplied input field verbatim into its output + * (`buildUnifiedStartOutput`), and `collectUserFilesById` accepts any object + * carrying the `UserFile` shape — so a caller can put `{id, name, url, size, + * type, key}` under any input field and have it recorded as though the run had + * emitted it. Only the reserved `files` key passes through `normalizeStartFile`, + * and that normalizer derives its key from a caller-supplied URL anyway. + * + * Serving bytes for such a record would be a cross-workspace read: nothing + * downstream re-authorizes, because the run itself is legitimately the caller's. + * So the id→key mapping is filtered to keys that are structurally this run's + * own: the `execution////…` layout the + * executor writes output under. A caller cannot forge one without already + * knowing all three canonical ids, and even then it names only its own run. + * + * Input files a caller attached are deliberately out of scope — they live under + * the workspace prefix, they are not this run's output, and they are already + * addressable through the files API under that resource's own authorization. + */ +export function isRunOutputFileKey(key: string, scope: RunFileScope): boolean { + const parts = key.split('/') + if (parts[0] !== 'execution' || parts.length < 5) { + return false + } + + const [, workspaceId, workflowId, executionId] = parts + if (scope.workspaceId && workspaceId !== scope.workspaceId) { + return false + } + + if (scope.workflowId && workflowId !== scope.workflowId) { + return false + } + + return executionId === scope.executionId +} diff --git a/apps/sim/lib/workflows/executor/run-file-storage-error.ts b/apps/sim/lib/workflows/executor/run-file-storage-error.ts new file mode 100644 index 00000000000..deca02c3d14 --- /dev/null +++ b/apps/sim/lib/workflows/executor/run-file-storage-error.ts @@ -0,0 +1,23 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isObjectNotFoundError } from '@/lib/uploads/core/errors' + +/** + * Classifies a storage failure raised while reading a file a run produced. + * + * A run's log row outlives its bytes: retention sweeps the objects on their own + * schedule, so a run whose files are recorded can be read long after the objects + * are gone. The provider reports that as `NoSuchKey`/`BlobNotFound`/`NotFound`, + * which is an absent object rather than a server fault — propagating it verbatim + * renders a `500` for a well-formed request, the defect class this surface + * treats as most severe. + * + * Only absence is reclassified. A network failure, a permission denial, a + * provider 5xx, and the size-limit errors the callers raise themselves all keep + * propagating, because retrying is the right answer to some of those and none of + * them mean the file is gone. + */ +export function classifyRunFileStorageError(error: unknown, message: string): unknown { + if (error instanceof OrchestrationError) return error + if (isObjectNotFoundError(error)) return new OrchestrationError('not_found', message) + return error +} diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts index 612ae65b3ee..9d2a9e747ed 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -56,6 +56,7 @@ describe('performChatDeploy password guards', () => { ).resolves.toEqual({ success: false, error: 'Password cannot contain only whitespace', + errorCode: 'validation', }) expect(mockGetWorkflowDeploymentSummary).not.toHaveBeenCalled() @@ -67,6 +68,7 @@ describe('performChatDeploy password guards', () => { ).resolves.toEqual({ success: false, error: 'Password is too long', + errorCode: 'validation', }) expect(mockGetWorkflowDeploymentSummary).not.toHaveBeenCalled() @@ -78,18 +80,50 @@ describe('performChatDeploy password guards', () => { ).resolves.toEqual({ success: false, error: 'Password cannot contain only whitespace', + errorCode: 'validation', }) }) + /** + * The replace-shaped `PUT /workflows/{workflowId}/deployments/chat` sends + * `password: null` for every mode that owns no password, so validating it + * rejected the default `public` mode — and `email` and `sso` — on a + * well-formed request. The route test cannot catch this: it mocks this whole + * module and asserts the exact `null` the real guard refused. + */ + it.each(['public', 'email', 'sso'] as const)( + 'takes a null password as the absence of one on a %s chat', + async (authType) => { + const result = await performChatDeploy({ ...basePayload, authType, password: null }) + + expect(result).not.toMatchObject({ errorCode: 'validation' }) + } + ) + it('rejects password protection with no password and no stored one', async () => { queueTableRows(schemaMock.chat, []) await expect(performChatDeploy({ ...basePayload, authType: 'password' })).resolves.toEqual({ success: false, error: 'Password is required when using password protection', + errorCode: 'validation', }) }) + /** + * A request that can never succeed must not cost a deployment version. The + * email and SSO allow-list guards already refuse ahead of the deploy; this + * one sat behind it, so a malformed deploy burned a version and then 400'd. + */ + it('refuses a passwordless password chat before deploying the workflow', async () => { + queueTableRows(schemaMock.chat, []) + mockPerformFullDeploy.mockClear() + + await performChatDeploy({ ...basePayload, authType: 'password' }) + + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + it('does not create a chat from a historical active deployment attempt', async () => { mockGetWorkflowDeploymentSummary.mockResolvedValue({ activeDeployment: null, diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 923b0ee3463..dccef0844f7 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -6,8 +6,9 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { chatDeploymentPasswordSchema } from '@/lib/api/contracts/chats' +import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' -import { getBaseUrl } from '@/lib/core/utils/urls' import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { getWorkflowDeploymentSummary, @@ -54,6 +55,15 @@ export interface PerformChatDeployResult { version?: number isUpdate?: boolean error?: string + /** + * How a failure should be classified by its callers. + * + * Without it every refusal here reached the wire as a `400`, which told a + * caller waiting on an in-flight deployment — a genuine `409` — and a caller + * who tripped an internal invariant that their request was malformed. Mirrors + * `performFullDeploy`, whose own code is propagated rather than flattened. + */ + errorCode?: OrchestrationErrorCode } /** @@ -73,11 +83,22 @@ export async function performChatDeploy( * route contract, so a whitespace-only or over-long password would otherwise * be encrypted and stored — and neither can ever be submitted through the * chat login form, permanently locking visitors out of the deployment. + * + * `null` is not a password to validate, it is the absence of one, which the + * declared `password?: string | null` has always allowed. A replace-shaped + * caller sends it for every mode that owns no password — the default + * `public`, plus `email` and `sso` — and validating it rejected all three on + * a well-formed request. The stored value is cleared by `authType` below + * regardless, so `null` needs no validation of its own. */ - if (password !== undefined) { + if (password !== undefined && password !== null) { const validatedPassword = chatDeploymentPasswordSchema.safeParse(password) if (!validatedPassword.success) { - return { success: false, error: validatedPassword.error.issues[0].message } + return { + success: false, + error: validatedPassword.error.issues[0].message, + errorCode: 'validation', + } } } @@ -137,6 +158,23 @@ export async function performChatDeploy( ...(mergedImageUrl ? { imageUrl: mergedImageUrl } : {}), } + /** + * Refused before anything is deployed. + * + * The same condition is re-checked below once the password has been + * encrypted, but reaching that point costs a real workflow deployment + * version: a request that can never succeed would burn one and then answer + * `400`. Its two sibling gate guards (email and SSO allow-lists) already run + * ahead of the deploy; this one landed behind it. + */ + if (authType === 'password' && !password && !existingDeployment?.password) { + return { + success: false, + error: 'Password is required when using password protection', + errorCode: 'validation', + } + } + /** * Only deploy when the draft drifted from the active version, and never * while another attempt is in flight — a blocked retry must not admit a @@ -149,6 +187,7 @@ export async function performChatDeploy( success: false, error: 'A workflow deployment is still preparing. Retry chat deployment after it becomes active.', + errorCode: 'conflict', } } @@ -169,13 +208,18 @@ export async function performChatDeploy( captureAnalytics: params.captureDeploymentAnalytics, }) if (!deployResult.success) { - return { success: false, error: deployResult.error || 'Failed to deploy workflow' } + return { + success: false, + error: deployResult.error || 'Failed to deploy workflow', + errorCode: deployResult.errorCode ?? 'internal', + } } if (deployResult.latestDeploymentAttempt?.isCurrent === false) { return { success: false, error: 'The workflow deployment attempt is historical and no longer describes production. Retry chat deployment as a new tool call.', + errorCode: 'conflict', } } if (deployResult.latestDeploymentAttempt?.status !== 'active') { @@ -184,12 +228,14 @@ export async function performChatDeploy( error: deployResult.warnings?.[0] ?? 'Workflow deployment is still preparing. Retry chat deployment after it becomes active.', + errorCode: 'conflict', } } if (!deployResult.activeDeployment) { return { success: false, error: 'Workflow deployment reported active without a live deployment version.', + errorCode: 'internal', } } } @@ -207,7 +253,11 @@ export async function performChatDeploy( * login with an opaque "Authentication configuration error". */ if (authType === 'password' && !encryptedPassword && !existingDeployment?.password) { - return { success: false, error: 'Password is required when using password protection' } + return { + success: false, + error: 'Password is required when using password protection', + errorCode: 'validation', + } } let chatId: string @@ -259,18 +309,7 @@ export async function performChatDeploy( }) } - const baseUrl = getBaseUrl() - let chatUrl: string - try { - const url = new URL(baseUrl) - let host = url.host - if (host.startsWith('www.')) { - host = host.substring(4) - } - chatUrl = `${url.protocol}//${host}/chat/${identifier}` - } catch { - chatUrl = `${baseUrl}/chat/${identifier}` - } + const chatUrl = buildChatDeploymentUrl(identifier) logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) @@ -337,6 +376,12 @@ export interface PerformChatUndeployParams { export interface PerformChatUndeployResult { success: boolean error?: string + /** + * How a failure should be classified. Callers must not render an unclassified + * failure as a not-found: an infrastructure fault concealed as `404` tells the + * caller the deployment is gone when it is still serving. + */ + errorCode?: OrchestrationErrorCode } /** @@ -361,7 +406,7 @@ export async function performChatUndeploy( .limit(1) if (!chatRecord) { - return { success: false, error: 'Chat not found' } + return { success: false, error: 'Chat not found', errorCode: 'not_found' } } await db.delete(chat).where(eq(chat.id, chatId)) diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 31a7c342e56..d1ed273d697 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -64,6 +64,9 @@ interface DuplicateWorkflowResult { blocksCount: number edgesCount: number subflowsCount: number + /** Stamped by this function, so a caller can present the copy without re-reading it. */ + createdAt: Date + updatedAt: Date } async function assertTargetFolderMutable( @@ -509,6 +512,8 @@ export async function duplicateWorkflow( blocksCount: sourceBlocks.length, edgesCount: sourceEdges.length, subflowsCount: sourceSubflows.length, + createdAt: now, + updatedAt: now, } } diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts new file mode 100644 index 00000000000..5acceca9fb5 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts @@ -0,0 +1,161 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + prepare: vi.fn(), + save: vi.fn(), + extractCustomTools: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/prepare-state', () => ({ + prepareWorkflowStateForPersistence: mocks.prepare, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: mocks.save, +})) +vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({ + extractAndPersistCustomTools: mocks.extractCustomTools, +})) + +import { + replaceWorkflowNormalizedState, + WorkflowStatePersistenceError, +} from '@/lib/workflows/persistence/replace-normalized-state' + +const BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} + +const PREPARED = { + blocks: { 'block-1': BLOCK }, + edges: [], + loops: {}, + parallels: {}, +} + +function input(overrides: Record = {}) { + return { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + attributedUserId: 'user-1', + state: { blocks: { 'block-1': BLOCK }, edges: [] }, + ...overrides, + } as Parameters[0] +} + +describe('replaceWorkflowNormalizedState', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + // The lock select must find a live row in the caller's workspace; an empty + // result is the archived / cross-workspace refusal, covered separately. + dbChainMockFns.for.mockResolvedValue([{ id: 'workflow-1' }]) + mocks.prepare.mockReturnValue({ state: PREPARED, warnings: [] }) + mocks.save.mockResolvedValue({ success: true }) + mocks.extractCustomTools.mockResolvedValue({ saved: 0, errors: [] }) + }) + + /** + * The two-doors defect: the Copilot edit tool wrote through + * `saveWorkflowToNormalizedTables` directly, so preparation never ran and an + * inline agent-tool secret or a dangling edge reached the tables. + */ + it('prepares the graph before writing it and returns the preparation warnings', async () => { + mocks.prepare.mockReturnValue({ + state: PREPARED, + warnings: ['Dropped edge "edge-9": target block does not exist'], + }) + + const result = await replaceWorkflowNormalizedState(input()) + + expect(mocks.prepare).toHaveBeenCalledWith({ + blocks: { 'block-1': BLOCK }, + edges: [], + }) + expect(mocks.save).toHaveBeenCalledWith( + 'workflow-1', + expect.objectContaining({ blocks: PREPARED.blocks, edges: PREPARED.edges }), + expect.anything() + ) + expect(mocks.prepare).toHaveBeenCalledBefore(mocks.save) + expect(result.warnings).toEqual(['Dropped edge "edge-9": target block does not exist']) + expect(result.state).toBe(PREPARED) + }) + + it('locks the workflow row for update inside the write transaction', async () => { + await replaceWorkflowNormalizedState(input()) + + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + }) + + it('stamps lastSynced and leaves variables untouched when none are supplied', async () => { + await replaceWorkflowNormalizedState(input()) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ lastSynced: expect.any(Date), updatedAt: expect.any(Date) }) + ) + expect(dbChainMockFns.set.mock.calls[0][0]).not.toHaveProperty('variables') + }) + + it('writes variables in the same transaction when they are supplied', async () => { + const variables = { 'var-1': { id: 'var-1', name: 'region', type: 'string', value: 'eu' } } + + await replaceWorkflowNormalizedState(input({ state: { blocks: {}, edges: [], variables } })) + + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ variables })) + }) + + it('extracts custom tools after the transaction commits', async () => { + await replaceWorkflowNormalizedState(input()) + + expect(mocks.extractCustomTools).toHaveBeenCalledWith( + expect.objectContaining({ blocks: PREPARED.blocks }), + 'workspace-1', + 'user-1' + ) + expect(mocks.save).toHaveBeenCalledBefore(mocks.extractCustomTools) + }) + + /** Pre-existing, deliberate: a stale custom tool never fails a committed graph write. */ + it('keeps custom-tool extraction best-effort', async () => { + mocks.extractCustomTools.mockRejectedValue(new Error('tool table unavailable')) + + await expect(replaceWorkflowNormalizedState(input())).resolves.toMatchObject({ warnings: [] }) + }) + + it('skips custom-tool extraction for a workflow with no workspace', async () => { + await replaceWorkflowNormalizedState(input({ workspaceId: null })) + + expect(mocks.extractCustomTools).not.toHaveBeenCalled() + }) + + it('throws and skips custom-tool extraction when the write fails', async () => { + mocks.save.mockResolvedValue({ success: false, error: 'constraint violation' }) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toBeInstanceOf( + WorkflowStatePersistenceError + ) + expect(mocks.extractCustomTools).not.toHaveBeenCalled() + }) + + /** + * The lock predicate is scoped, not just `id`: a workflow archived between the + * caller's authorization check and this write is refused rather than written, + * which is the predicate every pre-consolidation caller used. + */ + it('refuses when the lock finds no live row in the workspace', async () => { + dbChainMockFns.for.mockResolvedValue([]) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toThrow('Workflow not found') + expect(mocks.save).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts new file mode 100644 index 00000000000..138616780c1 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -0,0 +1,170 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' +import { + type PreparedWorkflowState, + prepareWorkflowStateForPersistence, +} from '@/lib/workflows/persistence/prepare-state' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('WorkflowStateReplacement') + +/** A normalized-table write that could not be committed. */ +export class WorkflowStatePersistenceError extends Error { + constructor(readonly detail: string) { + super('Failed to save workflow state') + this.name = 'WorkflowStatePersistenceError' + } +} + +export interface ReplaceWorkflowState { + blocks: Record + edges: WorkflowState['edges'] + variables?: Record + lastSaved?: number + isDeployed?: boolean + deployedAt?: Date | null +} + +export interface ReplaceWorkflowNormalizedStateInput { + workflowId: string + /** Canonical workspace the workflow belongs to; custom-tool extraction is skipped without one. */ + workspaceId: string | null + /** Owner recorded on any custom tool this graph defines. */ + attributedUserId: string + /** + * The graph to write, or a reader that produces it. + * + * A read-modify-write caller must pass the reader form: it runs after the row + * lock is taken, inside the same transaction, so a concurrent write that + * commits between a caller's own read and this one cannot be silently + * overwritten by a stale graph. Passing an already-read value is correct only + * when the caller composed it without reading the stored graph. + */ + state: ReplaceWorkflowState | ((tx: DbOrTx) => Promise) + requestId?: string +} + +export interface ReplaceWorkflowNormalizedStateResult { + /** Non-fatal notes about blocks and edges the preparation step rewrote or dropped. */ + warnings: string[] + /** Exactly what was written, after preparation. */ + state: PreparedWorkflowState +} + +/** + * The single door to replacing a workflow's draft graph. + * + * Owns preparation, the row-locked replace transaction, the `lastSynced` + * stamp, the optional variables write, and best-effort custom-tool extraction. + * It owns nothing else: authorization, the mutability check, semantic audit, + * and the realtime notification belong to the application use case above it, so + * a caller cannot acquire one of those by choosing a different entry point. + * + * Takes canonical identifiers only — never a principal or a credential — and + * throws rather than returning a status union, because statuses are a surface's + * business. + * + * `extractAndPersistCustomTools` runs **after** the transaction commits and is + * deliberately best-effort: a failure there leaves the graph written and the + * workspace's custom tools stale, which is the pre-existing behavior of every + * caller and is preserved on purpose. + */ +export async function replaceWorkflowNormalizedState( + input: ReplaceWorkflowNormalizedStateInput +): Promise { + const { workflowId, workspaceId, attributedUserId, state, requestId } = input + const logPrefix = requestId ? `[${requestId}] ` : '' + + let preparedState!: PreparedWorkflowState + let warnings: string[] = [] + let workflowState!: WorkflowState + + const saveResult = await db.transaction(async (tx) => { + /** + * Scoped to the workspace and to a live row, matching the predicate the + * pre-consolidation callers used: a workflow archived between the caller's + * authorization check and this write is refused rather than written. + */ + const [locked] = await tx + .select({ id: workflow.id }) + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + workspaceId ? eq(workflow.workspaceId, workspaceId) : undefined, + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!locked) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + + const resolved = typeof state === 'function' ? await state(tx) : state + const prepared = prepareWorkflowStateForPersistence({ + blocks: resolved.blocks, + edges: resolved.edges, + }) + preparedState = prepared.state + warnings = prepared.warnings + workflowState = { + ...prepared.state, + lastSaved: resolved.lastSaved || Date.now(), + isDeployed: resolved.isDeployed || false, + deployedAt: resolved.deployedAt, + } as WorkflowState + + const result = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + if (!result.success) return result + + const updateData: Partial = { + lastSynced: new Date(), + updatedAt: new Date(), + } + if (resolved.variables !== undefined) { + updateData.variables = resolved.variables + } + + await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) + + return result + }) + + if (!saveResult.success) { + logger.error(`${logPrefix}Failed to save workflow ${workflowId} state`, { + error: saveResult.error, + }) + throw new WorkflowStatePersistenceError(saveResult.error ?? 'Unknown persistence failure') + } + + if (workspaceId) { + try { + const { saved, errors } = await extractAndPersistCustomTools( + workflowState, + workspaceId, + attributedUserId + ) + if (saved > 0) { + logger.info(`${logPrefix}Persisted ${saved} custom tool(s) to database`, { workflowId }) + } + if (errors.length > 0) { + logger.warn(`${logPrefix}Some custom tools failed to persist`, { errors, workflowId }) + } + } catch (error) { + logger.error(`${logPrefix}Failed to persist custom tools`, { error, workflowId }) + } + } else { + logger.warn(`${logPrefix}Workflow has no workspaceId, skipping custom tools persistence`, { + workflowId, + }) + } + + return { warnings, state: preparedState } +} diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.ts index 6ecfb9cb5c0..4d921ee793f 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.ts @@ -1,5 +1,3 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertWorkflowMutable, @@ -7,16 +5,16 @@ import { WorkflowLockedError, type WorkflowWorkspaceAuthorizationResult, } from '@sim/platform-authz/workflow' -import { eq } from 'drizzle-orm' import type { z } from 'zod' import { type WorkflowStateContractOutput, workflowStateSchema, } from '@/lib/api/contracts/workflows' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' -import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' -import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + replaceWorkflowNormalizedState, + WorkflowStatePersistenceError, +} from '@/lib/workflows/persistence/replace-normalized-state' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowStatePersistence') @@ -36,14 +34,17 @@ export function parseWorkflowStateForPersistence( } /** - * Writes a complete workflow state to the normalized tables: write authorization, - * the lock check, block/edge preparation, the row-locked save transaction, - * `lastSynced`/variables, custom-tool extraction, and the socket notification. - * Every surface that replaces a workflow's state calls this, so no step can be - * skipped by going through a different door. + * The legacy session-authenticated door to a graph replace, kept for the + * internal editor route. + * + * It authorizes by bare `userId`, checks mutability, delegates the write to + * {@link replaceWorkflowNormalizedState} — the one persistence primitive every + * surface shares — and notifies the realtime server. Every refusal, the lock + * included, comes back as a failure result so callers need one branch. * - * Every refusal, the lock included, comes back as a failure result — callers need - * one branch. + * New surfaces do **not** call this: a `userId` cannot express a workspace-key + * or delegated principal. They go through `replaceWorkflowState`, which + * authorizes as an application operation and records semantic audit. * * `authorization` lets a caller that already resolved the same decision hand it in * rather than pay for it twice; it must be the `write` decision for this workflow @@ -88,89 +89,36 @@ export async function saveWorkflowNormalizedState(params: { throw error } - const { state: preparedState, warnings: preparationWarnings } = - prepareWorkflowStateForPersistence({ - blocks: state.blocks as Record, - edges: state.edges as WorkflowState['edges'], - }) - - const workflowState = { - ...preparedState, - lastSaved: state.lastSaved || Date.now(), - isDeployed: state.isDeployed || false, - deployedAt: state.deployedAt, - } - - const saveResult = await db.transaction(async (tx) => { - await tx - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - .for('update') - - const result = await saveWorkflowToNormalizedTables( - workflowId, - workflowState as WorkflowState, - tx - ) - - if (!result.success) return result - - const updateData: { - lastSynced: Date - updatedAt: Date - variables?: typeof state.variables - } = { - lastSynced: new Date(), - updatedAt: new Date(), - } - - if (state.variables !== undefined) { - updateData.variables = state.variables - } - - await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) - - return result - }) - - if (!saveResult.success) { - logger.error(`[${requestId}] Failed to save workflow ${workflowId} state:`, saveResult.error) - return { - success: false, - status: 500, - error: 'Failed to save workflow state', - details: saveResult.error, - } - } - + let warnings: string[] try { - const workspaceId = workflowData.workspaceId - if (workspaceId) { - const { saved, errors } = await extractAndPersistCustomTools( - workflowState, - workspaceId, - userId - ) - - if (saved > 0) { - logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, { workflowId }) - } - - if (errors.length > 0) { - logger.warn(`[${requestId}] Some custom tools failed to persist`, { errors, workflowId }) + const saved = await replaceWorkflowNormalizedState({ + requestId, + workflowId, + workspaceId: workflowData.workspaceId ?? null, + attributedUserId: userId, + state: { + blocks: state.blocks as Record, + edges: state.edges as WorkflowState['edges'], + variables: state.variables, + lastSaved: state.lastSaved, + isDeployed: state.isDeployed, + deployedAt: state.deployedAt, + }, + }) + warnings = saved.warnings + } catch (error) { + if (error instanceof WorkflowStatePersistenceError) { + return { + success: false, + status: 500, + error: 'Failed to save workflow state', + details: error.detail, } - } else { - logger.warn(`[${requestId}] Workflow has no workspaceId, skipping custom tools persistence`, { - workflowId, - }) } - } catch (error) { - logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) + throw error } await notifyWorkflowUpdated(workflowId) - return { success: true, warnings: preparationWarnings } + return { success: true, warnings } } diff --git a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts new file mode 100644 index 00000000000..f648e5fc0ac --- /dev/null +++ b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + * + * Characterization of the legacy internal door's wire behavior. It now delegates + * the write to `replaceWorkflowNormalizedState`, so these assertions are what + * proves the extraction did not move a status or a message. + */ +import { WorkflowLockedError } from '@sim/platform-authz/workflow' +import { workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + replace: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/replace-normalized-state', async () => { + class WorkflowStatePersistenceError extends Error { + constructor(readonly detail: string) { + super('Failed to save workflow state') + this.name = 'WorkflowStatePersistenceError' + } + } + return { + WorkflowStatePersistenceError, + replaceWorkflowNormalizedState: mocks.replace, + } +}) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) + +import { WorkflowStatePersistenceError } from '@/lib/workflows/persistence/replace-normalized-state' +import { saveWorkflowNormalizedState } from '@/lib/workflows/persistence/save-normalized-state' + +const STATE = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], +} as never + +function params(overrides: Record = {}) { + return { + requestId: 'request-1', + workflowId: 'workflow-1', + userId: 'user-1', + state: STATE, + ...overrides, + } as Parameters[0] +} + +describe('saveWorkflowNormalizedState', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + workspacePermission: 'write', + }) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mocks.replace.mockResolvedValue({ warnings: ['dropped an edge'], state: STATE }) + }) + + it('returns success with the preparation warnings and notifies once', async () => { + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: true, + warnings: ['dropped an edge'], + }) + + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'request-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + attributedUserId: 'user-1', + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + }) + + it('reuses an authorization decision the caller already resolved', async () => { + await saveWorkflowNormalizedState( + params({ + authorization: { + allowed: true, + status: 200, + workflow: { id: 'workflow-1', workspaceId: 'workspace-2' }, + workspacePermission: 'admin', + }, + }) + ) + + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-2' }) + ) + }) + + it('reports a missing workflow as 404 without writing', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 404, + workflow: null, + }) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 404, + error: 'Workflow not found', + }) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('passes the authorization status and message straight through on a denial', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Access denied', + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + }) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 403, + error: 'Access denied', + }) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('reports a locked workflow as 423 without writing', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( + new WorkflowLockedError('Workflow is locked') + ) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 423, + error: 'Workflow is locked', + }) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('reports a persistence failure as 500 with its detail and does not notify', async () => { + mocks.replace.mockRejectedValue(new WorkflowStatePersistenceError('constraint violation')) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 500, + error: 'Failed to save workflow state', + details: 'constraint violation', + }) + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('propagates an unclassified fault rather than turning it into a status', async () => { + mocks.replace.mockRejectedValue(new Error('pool exhausted')) + + await expect(saveWorkflowNormalizedState(params())).rejects.toThrow('pool exhausted') + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 8e7fa6f0663..f989b3e4d49 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' -import { and, asc, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import type { WorkflowListItem } from '@/lib/api/contracts/workflows' import { type CursorKey, @@ -65,6 +65,8 @@ const WORKFLOW_SORTS = { export interface ListWorkspaceWorkflowsInput { workspaceId: string folderId?: string | null + /** `active` (default) or `archived`; `all` is deliberately unavailable here. */ + scope?: 'active' | 'archived' deployedOnly: boolean search?: string sortBy: WorkflowSortBy @@ -104,7 +106,7 @@ export async function listWorkspaceWorkflows(input: ListWorkspaceWorkflowsInput) .where( and( eq(workflow.workspaceId, input.workspaceId), - isNull(workflow.archivedAt), + input.scope === 'archived' ? isNotNull(workflow.archivedAt) : isNull(workflow.archivedAt), folderCondition, input.deployedOnly ? eq(workflow.isDeployed, true) : undefined, searchFilter(workflow.name, input.search), diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 9ad431d3516..ec15acf323b 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -170,7 +170,7 @@ export interface SkillSummaryPage { * keyset over `skill` columns therefore cannot express a position inside the * merged sequence — under `createdAt asc` the epoch-stamped built-ins all sort * ahead of every DB row and a SQL cursor would skip or repeat them. So this - * follows the offset-cursor pattern that `GET /api/v2/knowledge/[id]/documents` + * follows the offset-cursor pattern that `GET /api/v2/knowledge/[knowledgeBaseId]/documents` * already uses. * * The consequence of that merge is that the DB half is read whole on every diff --git a/apps/sim/lib/workspace-files/api/internal-analytics.ts b/apps/sim/lib/workspace-files/api/internal-analytics.ts index 840c6d5044d..9f5dd8a5655 100644 --- a/apps/sim/lib/workspace-files/api/internal-analytics.ts +++ b/apps/sim/lib/workspace-files/api/internal-analytics.ts @@ -17,6 +17,7 @@ import type { CreateWorkspaceFileFolderInput, DeleteWorkspaceFileFolderInput, RestoreWorkspaceFileFolderInput, + RestoreWorkspaceFileFolderResult, UpdateWorkspaceFileFolderInput, } from '@/lib/workspace-files/application/workspace-file-folders' @@ -90,11 +91,19 @@ export const internalFileAnalytics = { { groups: { workspace: input.workspaceId } } ) }, - folderRestored({ principal, input }: InternalSuccessArgs) { + /** + * Reports the folder actually restored rather than the requested selector, + * which is a path on surfaces that address folders by path and carries no id. + */ + folderRestored({ + principal, + input, + result, + }: InternalSuccessArgs) { captureServerEvent( principal.userId, 'folder_restored', - { folder_id: input.folderId, workspace_id: input.workspaceId }, + { folder_id: result.folder.id, workspace_id: input.workspaceId }, { groups: { workspace: input.workspaceId } } ) }, diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index 57f270b34f7..d858615398a 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -134,6 +134,13 @@ describe('downloadWorkspaceFileItems', () => { { label: 'multiple files', fileIds: ['f1', 'f2'], folderIds: [] }, { label: 'a folder', fileIds: [], folderIds: ['folder-1'] }, { label: 'a file and folder', fileIds: ['f1'], folderIds: ['folder-1'] }, + { label: 'a folder path', fileIds: [], folderIds: [], folderPaths: ['/Reports'] }, + { + label: 'a file and folder path', + fileIds: ['f1'], + folderIds: [], + folderPaths: ['/Reports'], + }, ])('denies a file-scoped delegated principal selecting $label before listing', async (input) => { await expect( downloadWorkspaceFileItems.execute({ @@ -170,6 +177,63 @@ describe('downloadWorkspaceFileItems', () => { ) }) + /** + * v2 addresses folders by path. Resolution reuses the folder set the + * selection already loads, so it costs no extra query. + */ + it('resolves folder paths to the same expansion as folder ids', async () => { + mockListFolders.mockResolvedValue([ + { id: 'folder-1', name: 'Reports', path: 'Reports', parentId: null }, + { id: 'folder-2', name: 'Drafts', path: 'Reports/Drafts', parentId: 'folder-1' }, + ]) + mockListFiles.mockResolvedValue([file('f1', 'report.txt', 'folder-2')]) + + const result = await downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: [], folderPaths: ['/Reports'] }, + }) + + expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) + expect(mockListFolders).toHaveBeenCalledOnce() + }) + + it('resolves a nested folder path without matching a same-named sibling', async () => { + mockListFolders.mockResolvedValue([ + { id: 'folder-1', name: 'Reports', path: 'Reports', parentId: null }, + { id: 'folder-2', name: 'Drafts', path: 'Reports/Drafts', parentId: 'folder-1' }, + { id: 'folder-3', name: 'Drafts', path: 'Drafts', parentId: null }, + ]) + mockListFiles.mockResolvedValue([ + file('f1', 'nested.txt', 'folder-2'), + file('f2', 'root.txt', 'folder-3'), + ]) + + const result = await downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: [], folderPaths: ['/Reports/Drafts'] }, + }) + + expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) + }) + + /** + * A misspelled folder must not silently yield a zip of whatever else the + * request happened to select. + */ + it('rejects a folder path that matches nothing rather than ignoring it', async () => { + mockListFolders.mockResolvedValue([ + { id: 'folder-1', name: 'Reports', path: 'Reports', parentId: null }, + ]) + mockListFiles.mockResolvedValue([file('f1', 'clip.mp4')]) + + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [], folderPaths: ['/Nope'] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + it('returns typed validation and conflict failures without recording audit', async () => { await expect( downloadWorkspaceFileItems.execute({ diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts index 7291fc7ec5a..69bedcc7bc7 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { parseFolderPath } from '@/lib/folders/paths' import { buildWorkspaceFileFolderPathMap, listWorkspaceFileFolders, @@ -18,8 +19,9 @@ import { import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' +import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' -export const MAX_ZIP_DOWNLOAD_FILES = 100 export const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 const MAX_REQUESTED_FILE_IDS = 1_000 const MAX_REQUESTED_FOLDER_IDS = 1_000 @@ -28,6 +30,12 @@ export interface DownloadWorkspaceFileItemsInput { workspaceId: string fileIds: string[] folderIds: string[] + /** + * Canonical folder paths, for surfaces that address folders by path rather + * than by internal id. Resolved against the same folder set the selection + * already loads, so this costs no additional query. + */ + folderPaths?: string[] } export interface DownloadWorkspaceFileItemsResult { @@ -55,6 +63,33 @@ function collectDescendantFolderIds( return folderIds } +/** + * Maps canonical folder paths onto the ids the selection walk uses. + * + * Resolved against the folder set the download already loads rather than by a + * separate path query, and a path that matches nothing is rejected rather than + * silently dropped — a caller that misspells a folder should not receive a zip + * of whatever else it happened to select. + */ +function resolveFolderIdsFromPaths( + paths: string[], + folders: Array<{ id: string }>, + displayPathById: Map +): string[] { + if (paths.length === 0) return [] + const idByPath = new Map() + for (const folder of folders) { + const displayPath = displayPathById.get(folder.id) + if (!displayPath) continue + idByPath.set(parseWorkspaceFileFolderDisplayPath(displayPath).join('\u0000'), folder.id) + } + return paths.map((path) => { + const id = idByPath.get(parseFolderPath(path).join('\u0000')) + if (!id) validationError(`Folder not found: ${path}`) + return id + }) +} + function validationError(message: string): never { throw new OrchestrationError('validation', message) } @@ -70,15 +105,16 @@ async function executeDownloadWorkspaceFileItems({ >): Promise { const fileIds = [...new Set(input.fileIds)] const folderIds = [...new Set(input.folderIds)] + const requestedFolderPaths = [...new Set(input.folderPaths ?? [])] if (fileIds.length > MAX_REQUESTED_FILE_IDS) { validationError(`Too many file IDs selected. Select ${MAX_REQUESTED_FILE_IDS} or fewer files.`) } - if (folderIds.length > MAX_REQUESTED_FOLDER_IDS) { + if (folderIds.length + requestedFolderPaths.length > MAX_REQUESTED_FOLDER_IDS) { validationError( - `Too many folder IDs selected. Select ${MAX_REQUESTED_FOLDER_IDS} or fewer folders.` + `Too many folders selected. Select ${MAX_REQUESTED_FOLDER_IDS} or fewer folders.` ) } - if (fileIds.length === 0 && folderIds.length === 0) { + if (fileIds.length === 0 && folderIds.length === 0 && requestedFolderPaths.length === 0) { validationError('No files selected for download') } @@ -87,7 +123,10 @@ async function executeDownloadWorkspaceFileItems({ listWorkspaceFileFolders(context.workspaceId), ]) const folderPaths = buildWorkspaceFileFolderPathMap(folders) - const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) + const selectedFolderIds = collectDescendantFolderIds( + [...folderIds, ...resolveFolderIdsFromPaths(requestedFolderPaths, folders, folderPaths)], + folders + ) const requestedFileIds = new Set(fileIds) const filesToZip = files.filter( (file) => @@ -151,9 +190,17 @@ async function resolveDownloadContext({ input }: { input: DownloadWorkspaceFileI if (!context) throw new OrchestrationError('not_found', 'Workspace not found') const fileIds = [...new Set(input.fileIds)] const folderIds = [...new Set(input.folderIds)] + const folderPaths = [...new Set(input.folderPaths ?? [])] + /** + * The authorization resource is the single file only when the request is that + * one file. A folder — addressed by id or by path — pulls in files the caller + * never named, so the request is scoped to the workspace instead. + */ + const addressesOnlyOneFile = + fileIds.length === 1 && folderIds.length === 0 && folderPaths.length === 0 return { ...context, - fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, + fileId: addressesOnlyOneFile ? fileIds[0] : undefined, } } diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts index 0d599f8bd23..eadba577388 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -2,21 +2,15 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { type ActiveWorkspaceFileContext, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/doc-not-ready' -import { - formatFileSize, - MAX_RENDERED_DOCUMENT_BYTES, - needsRenderedArtifact, -} from '@/lib/uploads/utils/file-utils' +import { MAX_RENDERED_DOCUMENT_BYTES, needsRenderedArtifact } from '@/lib/uploads/utils/file-utils' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' -import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' export interface DownloadWorkspaceFileInput { @@ -76,24 +70,7 @@ export const downloadWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ projectAudit: ({ result }) => projectDownloadAudit(result.file), }) -/** - * Resolves a generation-source record to its compiled artifact. - * - * The record's declared size bounds nothing here — a source is text and orders - * of magnitude smaller than what it renders to — so the artifact is checked - * against its own ceiling. Note this rejects an oversized artifact rather than - * preventing it being read: the artifact store fetch is not itself streaming- - * bounded, so the bytes are resident before the check rejects them. - * - * An artifact that is still compiling is retryable rather than a fault, so it - * surfaces as `conflict` — a 500 would give the caller no reason to try again. - * A generation script that failed permanently raises the same error class but - * will never succeed on a retry, so it keeps its own message instead of the - * "still being generated" copy, which would tell the caller to wait for an - * artifact that never appears. It stays a `conflict` only because the v2 - * envelope has no 422; the message is what distinguishes the two. - */ -async function resolveRenderedArtifact( +function resolveRenderedArtifact( file: DownloadWorkspaceFileResult['file'], filePrincipal: AuthorizedWorkspaceUseCaseContext< typeof fileOperations.download, @@ -101,26 +78,9 @@ async function resolveRenderedArtifact( ActiveWorkspaceFileContext >['principal'] ) { - try { - return await fetchAuthorizedServableWorkspaceFileBuffer(file, filePrincipal, { - maxBytes: MAX_RENDERED_DOCUMENT_BYTES, - }) - } catch (error) { - if (isDocNotReadyError(error)) { - if (error.pending) throw new OrchestrationError('conflict', docNotReadyMessage()) - throw new OrchestrationError( - 'conflict', - `"${file.name}" could not be generated: ${error.message}` - ) - } - if (isPayloadSizeLimitError(error)) { - throw new OrchestrationError( - 'payload_too_large', - `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to download.` - ) - } - throw error - } + return resolveRenderedWorkspaceArtifact(file, filePrincipal, { + maxBytes: MAX_RENDERED_DOCUMENT_BYTES, + }) } async function executeDownloadWorkspaceFileStream({ diff --git a/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts index 8fecb8e22f1..967e850fc7c 100644 --- a/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts @@ -135,7 +135,12 @@ describe('extractWorkspaceFile', () => { principal, input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, }) - ).resolves.toEqual({ folderName: 'bundle', extractedCount: 2, skippedCount: 1 }) + ).resolves.toEqual({ + folderName: 'bundle', + folderDisplayPath: 'Projects/Imports/bundle', + extractedCount: 2, + skippedCount: 1, + }) expect(mocks.createFolder).toHaveBeenCalledWith({ workspaceId: 'workspace-1', @@ -258,7 +263,12 @@ describe('extractWorkspaceFile', () => { principal, input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, }) - ).resolves.toEqual({ folderName: 'bundle (3)', extractedCount: 2, skippedCount: 1 }) + ).resolves.toEqual({ + folderName: 'bundle (3)', + folderDisplayPath: 'Projects/Imports/bundle (3)', + extractedCount: 2, + skippedCount: 1, + }) expect(mocks.createFolder).toHaveBeenCalledWith( expect.objectContaining({ name: 'bundle', exactName: false }) @@ -358,10 +368,41 @@ describe('extractWorkspaceFile', () => { expect(mocks.notify).toHaveBeenCalledOnce() }) - it('rejects non-session principals before loading the file', async () => { + /** + * The widening: API-key principals reach extraction because it grants nothing + * `files.create` and `files.upload.create` do not already grant them at the + * same `write` role. It only collapses many calls into one. + */ + it.each([ + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + ] as const)('allows $kind to extract', async (apiKeyPrincipal) => { + await expect( + extractWorkspaceFile.execute({ + principal: apiKeyPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ extractedCount: 2 }) + }) + + /** + * Delegated services stay out. No copilot or executor caller exists today and + * admitting one is a separate decision, so the widening must not quietly + * include them. + */ + it('still rejects a delegated principal before loading the file', async () => { await expect( extractWorkspaceFile.execute({ - principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, }) ).rejects.toMatchObject({ code: 'forbidden' }) diff --git a/apps/sim/lib/workspace-files/application/extract-workspace-file.ts b/apps/sim/lib/workspace-files/application/extract-workspace-file.ts index f0ceac11066..e1fdc964ad7 100644 --- a/apps/sim/lib/workspace-files/application/extract-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/extract-workspace-file.ts @@ -56,6 +56,13 @@ export interface ExtractWorkspaceFileInput { export interface ExtractWorkspaceFileResult { folderName: string + /** + * Internal display path of the destination folder, in the same + * `Parent/Child` form the folder manager stores. Surfaces that address + * folders by path project it themselves; the resolved name can differ from + * the requested one when a sibling folder already claimed it. + */ + folderDisplayPath: string extractedCount: number skippedCount: number } @@ -180,6 +187,7 @@ async function extractWorkspaceFileContents({ return { folderName: rootFolder?.name ?? folderName, + folderDisplayPath: rootFolder?.path ?? folderName, extractedCount: result.extracted.length, skippedCount: result.skipped, } @@ -226,6 +234,7 @@ export const extractWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ description: `Unzipped workspace file ${context.fileId}`, metadata: { destinationFolder: result.folderName, + destinationFolderPath: result.folderDisplayPath, extractedCount: result.extractedCount, skippedCount: result.skippedCount, }, diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts index 7bc8f72f270..26b65ebab15 100644 --- a/apps/sim/lib/workspace-files/application/operations.test.ts +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -75,6 +75,24 @@ describe('file operation registry', () => { } }) + /** + * Extraction was widened from `['session']` to both API-key kinds. Nothing + * else pins that, and the widening is only defensible while extraction grants + * no capability `files.create` does not — so a role increase or a delegated + * service added here has to be a deliberate edit. + */ + it('keeps archive extraction at the write role for credential-bound principals', () => { + expect(fileOperations.extractArchive).toMatchObject({ + id: 'files.extract_archive', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }) + expect(fileOperations.extractArchive.principalKinds).not.toContain('delegated') + expect(fileOperations.extractArchive.delegatedServices).toBeUndefined() + expect(Object.isFrozen(fileOperations.extractArchive)).toBe(true) + }) + it('restricts compiled checks to authenticated sessions', () => { expect(fileOperations.compiledCheck).toMatchObject({ id: 'files.compiled_check', diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index 3c0890ce8f9..1fad2018f4d 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -59,11 +59,23 @@ export const fileOperations = { workspaceApiKey: 'allow', ...ALL_COPILOT_PRINCIPAL_POLICY, }), + /** + * Unzipping an archive into a folder beside it. + * + * Reachable by API keys because extraction grants no capability those keys + * lack: every file it writes could be created one at a time through + * `files.create` and `files.upload.create`, both already `workspaceApiKey: + * 'allow'` at the same `write` role. Extraction only makes it one call, so + * the previous `['session']` restriction read as an artifact of the UI having + * been its only caller rather than a decided policy. Delegated services stay + * out: no copilot or executor caller exists today and admitting one is a + * separate decision. + */ extractArchive: defineWorkspaceOperation({ id: 'files.extract_archive', minimumRole: 'write', - workspaceApiKey: 'deny', - principalKinds: ['session'], + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), updateContent: defineWorkspaceOperation({ id: 'files.update_content', @@ -164,6 +176,18 @@ export const fileOperations = { workspaceApiKey: 'allow', ...UPLOAD_PRINCIPAL_POLICY, }), + /** + * Reading an upload session's current state. Distinct from `uploadCancel`, + * which is the only other resource-id upload control today: cancelling is a + * `write`, and asking whether a session is still alive or already finalized + * must not require permission to destroy it. + */ + uploadRead: defineWorkspaceOperation({ + id: 'files.upload.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + ...UPLOAD_PRINCIPAL_POLICY, + }), uploadParts: defineWorkspaceOperation({ id: 'files.upload.parts', minimumRole: 'write', diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts new file mode 100644 index 00000000000..cfdd5757415 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts @@ -0,0 +1,322 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getFile: vi.fn(), + fetchServable: vi.fn(), + fetchBuffer: vi.fn(), + parseBuffer: vi.fn(), + resolvePermission: vi.fn(), + resolveContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-context', () => ({ + resolveActiveWorkspaceFileContext: mocks.resolveContext, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + getWorkspaceFile: mocks.getFile, +})) + +vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({ + fetchAuthorizedServableWorkspaceFileBuffer: mocks.fetchServable, +})) + +vi.mock('@/lib/file-parsers', () => ({ + isSupportedFileType: (extension: string) => + ['txt', 'pdf', 'doc', 'docx', 'pptx'].includes(extension), + parseBuffer: mocks.parseBuffer, +})) + +import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_doc' + +const fileContext = { + workspaceId: WORKSPACE_ID, + fileId: FILE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principals: Principal[] = [ + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-personal' }, + { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-workspace' }, +] + +function fileRecord(overrides: Record = {}) { + return { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'notes.txt', + type: 'text/plain', + size: 12, + key: 'workspace/ws/notes.txt', + storageContext: 'workspace', + ...overrides, + } +} + +function input(overrides: Record = {}) { + return { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, ...overrides } +} + +describe('readWorkspaceFileText', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveContext.mockResolvedValue(fileContext) + mocks.getFile.mockResolvedValue(fileRecord()) + mocks.fetchBuffer.mockResolvedValue(Buffer.from('hello there!')) + mocks.fetchServable.mockResolvedValue({ + buffer: Buffer.from('%PDF-1.7 rendered'), + contentType: 'application/pdf', + }) + mocks.parseBuffer.mockResolvedValue({ content: 'hello there!', metadata: {} }) + }) + + it.each(principals)('allows $kind at the read role', async (principal) => { + const result = await readWorkspaceFileText.execute({ principal, input: input() }) + + expect(result.text).toBe('hello there!') + expect(result.degraded).toBe(false) + expect(result.truncated).toBe(false) + }) + + /** + * `parseBuffer` signals every failure as a bare `Error`, which no v2 policy + * classifies — so calling it unguarded turned a zero-byte upload or a + * mislabelled archive into a `500` on a well-formed request. + */ + it('answers empty text for a zero-byte file instead of failing', async () => { + mocks.fetchBuffer.mockResolvedValue(Buffer.alloc(0)) + + const result = await readWorkspaceFileText.execute({ principal: principals[0], input: input() }) + + expect(result.text).toBe('') + expect(mocks.parseBuffer).not.toHaveBeenCalled() + }) + + it('classifies unparseable bytes as a conflict rather than an unhandled error', async () => { + mocks.parseBuffer.mockRejectedValue(new Error('Unsupported file type')) + + await expect( + readWorkspaceFileText.execute({ principal: principals[0], input: input() }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + it('denies a principal below the read role', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + readWorkspaceFileText.execute({ principal: principals[0], input: input() }) + ).rejects.toThrow() + expect(mocks.parseBuffer).not.toHaveBeenCalled() + }) + + it('resolves the file canonically before authorizing or reading', async () => { + mocks.resolveContext.mockRejectedValueOnce( + Object.assign(new Error('File not found'), { code: 'not_found' }) + ) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getFile).not.toHaveBeenCalled() + }) + + /** + * The whole hazard this endpoint exists to avoid: the legacy parsers return + * placeholder or scraped content instead of throwing, so `degraded` must + * reach the caller rather than being swallowed or turned into an error. + */ + it('surfaces a degraded legacy extraction with its reason', async () => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ name: 'legacy.doc' })) + mocks.parseBuffer.mockResolvedValueOnce({ + content: 'Unable to extract text from DOC file. Please convert to DOCX format.', + metadata: { + degraded: true, + extractionMethod: 'fallback', + warning: 'Basic text extraction used. For better results, convert to DOCX format.', + }, + }) + + const result = await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + + expect(result.degraded).toBe(true) + expect(result.degradedReason).toBe( + 'Basic text extraction used. For better results, convert to DOCX format.' + ) + }) + + it('surfaces a text-free deck as degraded', async () => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ name: 'deck.pptx' })) + mocks.parseBuffer.mockResolvedValueOnce({ + content: 'Unable to extract text from PowerPoint file.', + metadata: { degraded: true, warning: 'Basic text extraction used' }, + }) + + const result = await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + + expect(result.degraded).toBe(true) + }) + + it('reports no degraded reason for a clean extraction', async () => { + const result = await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + + expect(result.degraded).toBe(false) + expect(result.degradedReason).toBeNull() + }) + + it('reports parser truncation', async () => { + mocks.parseBuffer.mockResolvedValueOnce({ + content: 'partial', + metadata: { truncated: true }, + }) + + const result = await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + + expect(result.truncated).toBe(true) + }) + + it('rejects an unsupported type and names the raw-bytes escape hatch', async () => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ name: 'photo.heic' })) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining(`GET /api/v2/files/${FILE_ID}`), + }) + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + + it('rejects a source above the extraction ceiling before reading bytes', async () => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 26 * 1024 * 1024 })) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + + /** A caller may lower the ceiling but must never raise it. */ + it('clamps a caller maxBytes above the server ceiling', async () => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 26 * 1024 * 1024 })) + + await expect( + readWorkspaceFileText.execute({ + principal: principals[2], + input: input({ maxBytes: 500 * 1024 * 1024 }), + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + }) + + it('honours a caller maxBytes below the server ceiling', async () => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 2048 })) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input({ maxBytes: 1024 }) }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + }) + + it('reports a missing file as not found', async () => { + mocks.getFile.mockResolvedValueOnce(null) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('propagates a storage failure rather than concealing it', async () => { + mocks.fetchBuffer.mockRejectedValueOnce(new Error('s3 unavailable')) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + ).rejects.toThrow('s3 unavailable') + }) + + /** + * A generated document stores its generation SOURCE — pdf-lib JavaScript under + * a `.pdf` name — so parsing `file.key` by extension feeds the PDF parser a + * script. That is a 500 on `.pdf`, and on `.docx` a "successful" extraction of + * the generator source reported as undegraded content. Both are worse than an + * error, because a caller cannot tell the difference. + */ + it.each([ + ['report.pdf', 'text/x-pdflibjs'], + ['report.pdf', 'text/x-python-pdf'], + ['memo.docx', 'text/x-docxjs'], + ['deck.pptx', 'text/x-pptxgenjs'], + ])('extracts %s from its compiled artifact, not its %s source', async (name, type) => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ name, type, size: 900 })) + mocks.parseBuffer.mockResolvedValueOnce({ content: 'Quarterly results', metadata: {} }) + + const result = await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + + expect(mocks.fetchServable).toHaveBeenCalledTimes(1) + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + expect(mocks.parseBuffer.mock.calls[0][0].toString()).toBe('%PDF-1.7 rendered') + expect(result.text).toBe('Quarterly results') + }) + + /** A genuinely uploaded PDF carries its real MIME and must keep reading its own bytes. */ + it('reads an uploaded pdf from storage rather than an artifact', async () => { + mocks.getFile.mockResolvedValueOnce( + fileRecord({ name: 'scan.pdf', type: 'application/pdf', size: 900 }) + ) + + await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + + expect(mocks.fetchBuffer).toHaveBeenCalledTimes(1) + expect(mocks.fetchServable).not.toHaveBeenCalled() + }) + + /** An artifact still compiling is retryable, so it must not read as a fault. */ + it('reports a still-compiling artifact as a conflict', async () => { + mocks.getFile.mockResolvedValueOnce( + fileRecord({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 900 }) + ) + mocks.fetchServable.mockRejectedValueOnce( + new DocCompileUserError('not ready', { pending: true }) + ) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + /** + * The stored size of a generation source bounds nothing — it is text that + * renders to orders of magnitude more — so the source pre-check must not be + * what decides, and the artifact carries its own ceiling. + */ + it('bounds a generated document by its artifact, not its source size', async () => { + mocks.getFile.mockResolvedValueOnce( + fileRecord({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 900 }) + ) + + await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + + expect(mocks.fetchServable.mock.calls[0][2]).toMatchObject({ maxBytes: expect.any(Number) }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts new file mode 100644 index 00000000000..2f3600159b2 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts @@ -0,0 +1,153 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers' +import { + type ActiveWorkspaceFileContext, + fetchWorkspaceFileBuffer, + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + formatFileSize, + getFileExtension, + MAX_TEXT_EXTRACTION_BYTES, + needsRenderedArtifact, +} from '@/lib/uploads/utils/file-utils' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface ReadWorkspaceFileTextInput { + fileId: string + assertedWorkspaceId?: string + maxBytes?: number +} + +export interface ReadWorkspaceFileTextResult { + file: WorkspaceFileRecord + text: string + /** True when a parser limit stopped extraction before the input was exhausted. */ + truncated: boolean + /** + * True when no real extraction happened and `text` is best-effort scraped + * bytes or a placeholder rather than the document's content. Surfaced rather + * than converted into an error because the legacy `doc`/`ppt` parsers + * deliberately never throw, and that behavior is characterization-tested. + */ + degraded: boolean + degradedReason: string | null + byteCount: number +} + +/** + * Reads an ordinary uploaded file's bytes. + * + * The stored size is authoritative for these, so an oversized file is refused + * before any bytes are fetched. It is NOT authoritative for a generation source, + * which is why that path is bounded by the artifact ceiling instead. + */ +async function readSourceBuffer(file: WorkspaceFileRecord, maxBytes: number): Promise { + if (file.size > maxBytes) { + throw new OrchestrationError( + 'payload_too_large', + `"${file.name}" is ${formatFileSize(file.size)}, above the ${formatFileSize(maxBytes)} text-extraction limit; download the raw bytes with GET /api/v2/files/${file.id}` + ) + } + return fetchWorkspaceFileBuffer(file, { maxBytes }) +} + +async function executeReadWorkspaceFileText({ + input, + context, + principal, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readContent, + ReadWorkspaceFileTextInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { throwOnError: true }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + + const extension = getFileExtension(file.name) + if (!isSupportedFileType(extension)) { + throw new OrchestrationError( + 'validation', + `Text extraction is not supported for "${file.name}"; download the raw bytes with GET /api/v2/files/${file.id}` + ) + } + + const maxBytes = Math.min(input.maxBytes ?? MAX_TEXT_EXTRACTION_BYTES, MAX_TEXT_EXTRACTION_BYTES) + + /** + * A generated document stores its generation SOURCE under a document-shaped + * name, so parsing `file.key` by extension alone feeds a PDF parser + * JavaScript — a 500 on `.pdf`, and on `.docx` a "successful" extraction of + * the generator script reported as undegraded content. The compiled artifact + * is what the name promises, so it is what gets parsed. Matches the download + * path, which resolves the same artifact for the same reason. + */ + const content = needsRenderedArtifact(file.type, file.name) + ? ( + await resolveRenderedWorkspaceArtifact(file, principal, { + maxBytes, + tooLargeMessage: (limit) => + `"${file.name}" renders to more than ${limit}, above the text-extraction limit; download the raw bytes with GET /api/v2/files/${file.id}`, + }) + ).buffer + : await readSourceBuffer(file, maxBytes) + const parsed = await parseFileText(content, extension, file.name) + const metadata = parsed.metadata ?? {} + + return { + file, + text: parsed.content, + truncated: metadata.truncated === true, + degraded: metadata.degraded === true, + degradedReason: metadata.degraded === true ? (metadata.warning ?? null) : null, + byteCount: content.byteLength, + } +} + +/** + * Extracts a workspace file's text. + * + * Runs on `files.read_content` unchanged: extracting text reads exactly the + * bytes that operation already authorizes, and turning them into text grants + * no further reach. No audit is projected, matching the existing content read. + */ +/** + * Turns stored bytes into text without ever answering `500`. + * + * `parseBuffer` signals every failure — an empty buffer, an unknown extension, + * a parser that rejects the bytes — as a bare `Error`, which no v2 error policy + * classifies, so calling it directly made a zero-byte upload or a mislabelled + * archive an unhandled `500` on a well-formed request. That is the defect class + * the conventions doc ranks highest. + * + * Empty bytes are not a failure: a zero-length file has no text, and answering + * `''` is both true and what the caller asked for. Anything else becomes a + * `conflict`, matching {@link resolveRenderedWorkspaceArtifact} — the request is + * well formed, it is the stored bytes that cannot become the representation + * being asked for, and the caller needs to know that retrying will not help. + */ +async function parseFileText(content: Buffer, extension: string, fileName: string) { + if (content.byteLength === 0) { + return { content: '', metadata: {} } + } + try { + return await parseBuffer(content, extension) + } catch (error) { + throw new OrchestrationError( + 'conflict', + `"${fileName}" could not be read as text: ${getErrorMessage(error, 'the stored bytes could not be parsed')}` + ) + } +} + +export const readWorkspaceFileText = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeReadWorkspaceFileText, +}) diff --git a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts new file mode 100644 index 00000000000..0cee7dbd69c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts @@ -0,0 +1,58 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/doc-not-ready' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' + +/** + * Resolves a generation-source record to its compiled artifact. + * + * The record's declared size bounds nothing here — a source is text and orders + * of magnitude smaller than what it renders to — so the artifact is checked + * against the ceiling the caller is serving under. Note this rejects an + * oversized artifact rather than preventing it being read: the artifact store + * fetch is not itself streaming-bounded, so the bytes are resident before the + * check rejects them. + * + * An artifact that is still compiling is retryable rather than a fault, so it + * surfaces as `conflict` — a 500 would give the caller no reason to try again. + * A generation script that failed permanently raises the same error class but + * will never succeed on a retry, so it keeps its own message instead of the + * "still being generated" copy, which would tell the caller to wait for an + * artifact that never appears. It stays a `conflict` only because the v2 + * envelope has no 422; the message is what distinguishes the two. + * + * Shared by every surface that reads a workspace file's bytes, because + * dispatching on the stored name alone hands back generator source under a + * document extension — as text, as a download, or as a parse. + */ +export async function resolveRenderedWorkspaceArtifact( + file: WorkspaceFileRecord, + filePrincipal: Principal, + options: { maxBytes: number; tooLargeMessage?: (limit: string) => string } +): Promise<{ buffer: Buffer; contentType: string }> { + try { + return await fetchAuthorizedServableWorkspaceFileBuffer(file, filePrincipal, { + maxBytes: options.maxBytes, + }) + } catch (error) { + if (isDocNotReadyError(error)) { + if (error.pending) throw new OrchestrationError('conflict', docNotReadyMessage()) + throw new OrchestrationError( + 'conflict', + `"${file.name}" could not be generated: ${error.message}` + ) + } + if (isPayloadSizeLimitError(error)) { + const limit = formatFileSize(options.maxBytes) + throw new OrchestrationError( + 'payload_too_large', + options.tooLargeMessage?.(limit) ?? + `"${file.name}" renders to more than ${limit} and is too large to download.` + ) + } + throw error + } +} diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts index 5f9337d19e1..cc0d072f031 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts @@ -236,6 +236,83 @@ describe('workspace file folder operations', () => { expect(mockNotify).not.toHaveBeenCalled() }) + /** + * v2 addresses folders by path, and the folder being restored is archived, so + * the id is resolved from the archived set rather than by walking the live + * tree — which by definition would not contain it. + */ + it('resolves an archived folder by path before restoring it', async () => { + mockList.mockResolvedValueOnce([ + { id: 'folder-other', name: 'Archive', path: 'Marketing/Archive' }, + { id: 'folder-target', name: 'Archive', path: 'Engineering/Archive' }, + ]) + mockRestore.mockResolvedValue({ + folder: { id: 'folder-target', name: 'Archive', path: 'Engineering/Archive' }, + restoredItems: { files: 3, folders: 1 }, + }) + + await restoreWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', path: '/Engineering/Archive' }, + }) + + expect(mockList).toHaveBeenCalledWith('ws-1', { scope: 'archived' }) + expect(mockRestore).toHaveBeenCalledWith('ws-1', 'folder-target') + }) + + it('does not restore a same-named archived folder under a different parent', async () => { + mockList.mockResolvedValueOnce([ + { id: 'folder-other', name: 'Archive', path: 'Marketing/Archive' }, + ]) + + await expect( + restoreWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', path: '/Engineering/Archive' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mockRestore).not.toHaveBeenCalled() + }) + + it('rejects restoring the workspace root', async () => { + await expect( + restoreWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', path: '/' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mockList).not.toHaveBeenCalled() + expect(mockRestore).not.toHaveBeenCalled() + }) + + it('still restores by folder id for the internal surface', async () => { + mockRestore.mockResolvedValue({ + folder: { id: 'folder-1', name: 'Archive', path: 'Engineering/Archive' }, + restoredItems: { files: 1, folders: 1 }, + }) + + await restoreWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', folderId: 'folder-1' }, + }) + + expect(mockList).not.toHaveBeenCalled() + expect(mockRestore).toHaveBeenCalledWith('ws-1', 'folder-1') + }) + + it('lists archived folders when the scope asks for them', async () => { + mockList.mockResolvedValueOnce([]) + + await listWorkspaceFileFoldersOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', scope: 'archived' }, + }) + + expect(mockList).toHaveBeenCalledWith('ws-1', expect.objectContaining({ scope: 'archived' })) + }) + it('does not authorize a folder restore as though its ID were a delegated file scope', async () => { const principal = { kind: 'delegated' as const, diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts index 9667b044589..82b8b7f18e3 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts @@ -99,10 +99,15 @@ export interface DeleteWorkspaceFileFolderResult { path?: string } -export interface RestoreWorkspaceFileFolderInput { +/** + * Addresses exactly one archived folder — by internal id, or by the canonical + * path an archived-scope list reports. The two selectors are mutually exclusive + * by construction: a caller supplying neither, or both, is a compile error rather + * than a `validation` failure raised after the operation has already authorized. + */ +export type RestoreWorkspaceFileFolderInput = { workspaceId: string - folderId: string -} +} & ({ folderId: string; path?: never } | { folderId?: never; path: string }) export interface RestoreWorkspaceFileFolderResult { folder: WorkspaceFileFolderRecord @@ -244,11 +249,41 @@ async function executeDeleteWorkspaceFileFolder(args: { return { deletedItems, path: args.input.path } } +/** + * Resolves an archived folder's id from its canonical path. + * + * Deliberately scans the archived set rather than walking the active tree: + * `findWorkspaceFileFolderIdByPath` resolves live folders, and the folder being + * restored is by definition not one. Folder counts are small — this is the same + * full set the folder list already returns unpaged — so a scan is cheaper than + * a second recursive path query. + */ +async function findArchivedFolderIdByPath(workspaceId: string, path: string): Promise { + const target = parseFolderPath(path) + if (target.length === 0) { + throw new OrchestrationError('validation', 'The workspace root cannot be restored') + } + const archived = await listWorkspaceFileFolders(workspaceId, { scope: 'archived' }) + const match = archived.find((folder) => { + const segments = parseWorkspaceFileFolderDisplayPath(folder.path) + return ( + segments.length === target.length && + segments.every((segment, index) => segment === target[index]) + ) + }) + if (!match) throw new OrchestrationError('not_found', 'Folder not found') + return match.id +} + async function executeRestoreWorkspaceFileFolder(args: { input: RestoreWorkspaceFileFolderInput context: FolderOperationContext }): Promise { - return restoreWorkspaceFileFolder(args.context.workspaceId, args.input.folderId) + const folderId = + args.input.path !== undefined + ? await findArchivedFolderIdByPath(args.context.workspaceId, args.input.path) + : args.input.folderId + return restoreWorkspaceFileFolder(args.context.workspaceId, folderId) } export const listWorkspaceFileFoldersOperation = defineAuthorizedWorkspaceFileUseCase({ @@ -332,11 +367,11 @@ export const restoreWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFile operation: fileOperations.restoreFolder, resolveContext: (args: { input: RestoreWorkspaceFileFolderInput }) => resolveFolderContext(args), execute: executeRestoreWorkspaceFileFolder, - projectAudit({ input, result }) { + projectAudit({ result }) { return { action: AuditAction.FOLDER_RESTORED, resourceType: AuditResourceType.FOLDER, - resourceId: input.folderId, + resourceId: result.folder.id, resourceName: result.folder.name, description: `Restored file folder "${result.folder.name}"`, metadata: { restoredItems: result.restoredItems }, diff --git a/apps/sim/lib/workspace-files/limits.ts b/apps/sim/lib/workspace-files/limits.ts index 5f1f72ff630..c1b80bad120 100644 --- a/apps/sim/lib/workspace-files/limits.ts +++ b/apps/sim/lib/workspace-files/limits.ts @@ -1,2 +1,11 @@ export const MAX_WORKSPACE_FILE_BULK_REQUEST_IDS = 1_000 export const MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS = 5_000 + +/** + * Ceiling on the number of files one zip download may contain. + * + * Lives here rather than beside the download use case so the v2 contract can + * refuse an over-large selection at the request boundary, instead of letting it + * validate, resolve, and only then fail — and so the two ceilings cannot drift. + */ +export const MAX_ZIP_DOWNLOAD_FILES = 100 diff --git a/apps/sim/lib/workspaces/application/workspace-context.test.ts b/apps/sim/lib/workspaces/application/workspace-context.test.ts index db644e1fb84..e2d68d85132 100644 --- a/apps/sim/lib/workspaces/application/workspace-context.test.ts +++ b/apps/sim/lib/workspaces/application/workspace-context.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { loadActiveWorkspaceApplicationContext, loadWorkspaceApplicationContext, + resolveActiveWorkspaceApplicationContext, } from '@/lib/workspaces/application/workspace-context' describe('loadActiveWorkspaceApplicationContext', () => { @@ -65,3 +66,33 @@ describe('loadActiveWorkspaceApplicationContext', () => { await expect(loadActiveWorkspaceApplicationContext('workspace-1')).rejects.toBe(failure) }) }) + +describe('resolveActiveWorkspaceApplicationContext', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('returns the canonical context for an active workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'workspace-1', + organizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }, + ]) + + await expect(resolveActiveWorkspaceApplicationContext('workspace-1')).resolves.toMatchObject({ + workspaceId: 'workspace-1', + }) + }) + + it('conceals an inactive or absent workspace as not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect(resolveActiveWorkspaceApplicationContext('workspace-1')).rejects.toMatchObject({ + code: 'not_found', + message: 'Workspace not found', + }) + }) +}) diff --git a/apps/sim/lib/workspaces/application/workspace-context.ts b/apps/sim/lib/workspaces/application/workspace-context.ts index af4194a992b..0ac9ec2ae04 100644 --- a/apps/sim/lib/workspaces/application/workspace-context.ts +++ b/apps/sim/lib/workspaces/application/workspace-context.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workspace } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' export interface ActiveWorkspaceApplicationContext extends WorkspaceAuthorizationContext { billedAccountUserId: string @@ -43,3 +44,15 @@ export async function loadActiveWorkspaceApplicationContext( ): Promise { return loadWorkspaceApplicationContext(workspaceId) } + +/** + * Active canonical workspace state, or a not-found for a workspace the caller + * cannot be told apart from one that does not exist. + */ +export async function resolveActiveWorkspaceApplicationContext( + workspaceId: string +): Promise { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 20e75b619a9..eaf740fb9cf 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -286,7 +286,7 @@ const nextConfig: NextConfig = { ], }, { - source: '/api/v2/workflows/:id/execute', + source: '/api/v2/workflows/:workflowId/execute', headers: [ { key: 'Cross-Origin-Embedder-Policy', value: 'unsafe-none' }, { key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' }, diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index d366d1e6adb..402555a43ae 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -137,7 +137,7 @@ const CORS_RULES: readonly CorsRule[] = [ * The exposed-header list is applied to every policy, matched rule or fallback, * because the headers it names are set by the same shared route machinery on * every route. A rule opts out by spelling `exposeHeaders: undefined`; carrying - * the list per rule instead is how `/api/v2/workflows/{id}/execute` — the only + * the list per rule instead is how `/api/v2/workflows/{workflowId}/execute` — the only * route that emits `X-Run-Id`, and wildcard-origin precisely so browsers can * call it — ended up unable to hand a browser the run id or a 429's * `Retry-After`. diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index 3b25e74765d..e03db98aeaf 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"affinity_batch_update_entity_fields":{"id":"affinity_batch_update_entity_fields","name":"Affinity Batch Update Entity Fields","description":"Write up to 100 non-list field values on one company or person in a single request.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the fields on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}}},"affinity_batch_update_list_entry_fields":{"id":"affinity_batch_update_list_entry_fields","name":"Affinity Batch Update List Entry Fields","description":"Write up to 100 field values on one list row in a single request. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}}},"affinity_create_list":{"id":"affinity_create_list","name":"Affinity Create List","description":"Create a list. Its type fixes which entities it can hold, and the API key holder becomes its creator and owner.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the new list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Entity kind the list holds: company, opportunity, or person"},"isPublic":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether everyone in the organization can see the list"}}},"affinity_create_list_field_dropdown_option":{"id":"affinity_create_list_field_dropdown_option","name":"Affinity Create List Field Dropdown Option","description":"Add a selectable option to a dropdown field on a list. A ranked or status option also needs a rank and a color.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Kind of option to create, matching the field. dropdown takes only a label; ranked-dropdown also requires rank and color; status-dropdown additionally requires a status category. Sending a field the kind does not accept is rejected"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The option label"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}}},"affinity_create_merge":{"id":"affinity_create_merge","name":"Affinity Create Merge","description":"Fold a duplicate company or person into the record you are keeping. The merge runs asynchronously — poll the returned task to see it finish. Requires the \\"Manage duplicates\\" permission and an admin role.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to merge: companies or persons"},"primaryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to keep"},"duplicateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the duplicate record to fold in"}}},"affinity_create_note":{"id":"affinity_create_note","name":"Affinity Create Note","description":"Write a note — attached to companies, persons, and opportunities, anchored to a meeting, call, or chat message, or posted as a reply to an existing note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Note shape: entities to attach it to records, interaction to anchor it to a meeting, call, or chat message, or user-reply to reply to a note"},"html":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Companies to attach the note to, e.g. [1, 2]. Not used on a reply"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Persons to attach the note to, e.g. [1, 2]. Not used on a reply"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Opportunities to attach the note to, e.g. [1, 2]. Not used on a reply"},"interactionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The interaction to anchor the note to. Required for an interaction note"},"interactionType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Kind of the anchoring interaction: meeting, call, or chat-message. Required for an interaction note"},"parentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The note being replied to. Required for a user-reply note"},"creatorId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Attribute the note to another internal person. Defaults to the API key holder"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdate the note to this ISO 8601 timestamp"}}},"affinity_create_reminder":{"id":"affinity_create_reminder","name":"Affinity Create Reminder","description":"Create a reminder on one company, person, or opportunity. A recurring reminder resets whenever the chosen signal happens instead of firing once.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"one-time to fire once, or recurring to reset on a signal"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What the reminder is about: company, person, or opportunity"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"dueDate":{"type":"string","required":false,"visibility":"user-or-llm","description":"When the reminder is due, as an ISO 8601 timestamp. Required for a one-time reminder; on a recurring one Affinity computes it from the period when omitted"},"content":{"type":"string","required":false,"visibility":"user-or-llm","description":"What the reminder says"},"ownerId":{"type":"string","required":true,"visibility":"user-or-llm","description":"User the reminder is assigned to. Must be an internal user. The API key holder is recorded as the creator, which is a separate field"},"resetTrigger":{"type":"string","required":false,"visibility":"user-or-llm","description":"What restarts a recurring reminder: interaction, email, or event. Required when the type is recurring"},"periodDays":{"type":"number","required":false,"visibility":"user-or-llm","description":"Days between firings of a recurring reminder. Required when the type is recurring"}}},"affinity_delete_list_field_dropdown_option":{"id":"affinity_delete_list_field_dropdown_option","name":"Affinity Delete List Field Dropdown Option","description":"Permanently delete a dropdown option on a list field. Every list entry currently set to it is cleared, and those values cannot be recovered.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to delete"}}},"affinity_delete_note":{"id":"affinity_delete_note","name":"Affinity Delete Note","description":"Delete a note you created. Deleting a root note also deletes its replies; deleting a reply removes only that reply.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to delete"}}},"affinity_get_company":{"id":"affinity_get_company","name":"Affinity Get Company","description":"Look up one company by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"companyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The company ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}}},"affinity_get_current_user":{"id":"affinity_get_current_user","name":"Affinity Get Current User","description":"Verify an Affinity API key and return the tenant, the user behind the key, and the scopes the grant carries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"}}},"affinity_get_entity_field_value":{"id":"affinity_get_entity_field_value","name":"Affinity Get Entity Field Value","description":"Read one non-list field value from a company or person.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read the field from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}}},"affinity_get_list":{"id":"affinity_get_list","name":"Affinity Get List","description":"Read one list — its name, type, owner, and privacy setting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"}}},"affinity_get_list_entry":{"id":"affinity_get_list_entry","name":"Affinity Get List Entry","description":"Read one row of a list with its entity. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"}}},"affinity_get_list_entry_field":{"id":"affinity_get_list_entry_field","name":"Affinity Get List Entry Field","description":"Read one field value on a list row.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}}},"affinity_get_list_field_dropdown_option":{"id":"affinity_get_list_field_dropdown_option","name":"Affinity Get List Field Dropdown Option","description":"Read one dropdown option on a list field.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID"}}},"affinity_get_merge":{"id":"affinity_get_merge","name":"Affinity Get Merge","description":"Read the status of one company or person merge, including why it failed if it did.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge to read: companies or persons"},"mergeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge ID"}}},"affinity_get_merge_task":{"id":"affinity_get_merge_task","name":"Affinity Get Merge Task","description":"Read one merge task and how its merges are progressing. Poll this after starting a merge.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge task to read: companies or persons"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge task ID"}}},"affinity_get_note":{"id":"affinity_get_note","name":"Affinity Get Note","description":"Read one note with its body, author, mentions, and attached records.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"}}},"affinity_get_opportunity":{"id":"affinity_get_opportunity","name":"Affinity Get Opportunity","description":"Read one opportunity and the list it belongs to. Its field data lives on the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"opportunityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The opportunity ID"}}},"affinity_get_person":{"id":"affinity_get_person","name":"Affinity Get Person","description":"Look up one person by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"personId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The person ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}}},"affinity_get_saved_view":{"id":"affinity_get_saved_view","name":"Affinity Get Saved View","description":"Read one saved view — its name, kind, and creation date.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"}}},"affinity_get_transcript":{"id":"affinity_get_transcript","name":"Affinity Get Transcript","description":"Read one transcript with its first 100 fragments. Page the fragments endpoint for a longer meeting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"}}},"affinity_get_user":{"id":"affinity_get_user","name":"Affinity Get User","description":"Read one internal user. A user and their person record share the same numeric ID, so a person ID works here.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"userId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The user ID, which is also their person ID"}}},"affinity_list_calls":{"id":"affinity_list_calls","name":"Affinity List Calls","description":"Page through logged calls and their participants. Only calls the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_chat_messages":{"id":"affinity_list_chat_messages","name":"Affinity List Chat Messages","description":"Page through logged chat messages and their participants. Only messages the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_companies":{"id":"affinity_list_companies","name":"Affinity List Companies","description":"Page through companies. Companies come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these company IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_coworker_connections":{"id":"affinity_list_coworker_connections","name":"Affinity List Coworker Connections","description":"Find warm paths into a company through shared work history: who in your Affinity data once worked alongside the people you want to reach. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_emails":{"id":"affinity_list_emails","name":"Affinity List Emails","description":"Page through email metadata — subject, participants, and timestamps. Affinity never exposes email bodies through the API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_entity_field_values":{"id":"affinity_list_entity_field_values","name":"Affinity List Entity Field Values","description":"Page through a company\'s or person\'s non-list field values. List fields are not returned here — read those through the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read field values from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}}},"affinity_list_entity_list_entries":{"id":"affinity_list_entity_list_entries","name":"Affinity List Entity List Entries","description":"Page through a company\'s or person\'s rows across every list, each carrying that list\'s field values and when the entity was added.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the rows of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_entity_lists":{"id":"affinity_list_entity_lists","name":"Affinity List Entity Lists","description":"List every list a company or person appears on that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the lists of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_entity_notes":{"id":"affinity_list_entity_notes","name":"Affinity List Entity Notes","description":"List the notes relevant to one company, person, or opportunity — directly attached notes plus notes reaching it through its people and meetings.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity the notes hang off: companies, persons, or opportunities"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_entity_relationships":{"id":"affinity_list_entity_relationships","name":"Affinity List Entity Relationships","description":"List who knows a company or person, scored 0.0 to 1.0 by how much the two actually interact. Strongest first by default.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up relationships for: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on interactionScore only, e.g. \\"interactionScore>=0.5\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"interactionScore\\"] for weakest first, [\\"-interactionScore\\"] for strongest first (the default)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_field_dropdown_options":{"id":"affinity_list_field_dropdown_options","name":"Affinity List Field Dropdown Options","description":"List the selectable options on a dropdown or ranked-dropdown company or person field. Writing such a field needs the option ID, not its text.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which field family the field belongs to: companies or persons"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown or ranked-dropdown field ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_field_metadata":{"id":"affinity_list_field_metadata","name":"Affinity List Field Metadata","description":"List the non-list company or person fields, with the value type, filter operators, and sort support of each. Start here to find the Field IDs the read and write tools take.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which fields to describe: companies or persons"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Status\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_field_value_changes":{"id":"affinity_list_field_value_changes","name":"Affinity List Field Value Changes","description":"Page through field value changes across the whole workspace. Built for delta sync: follow nextCursor to the end of a run, then resume from the last cursor next time.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, listEntry.id, changer.id, changedAt, or actionType. Resume a sync with e.g. \\"changedAt>2026-06-01T12:00:00Z\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"changedAt\\"] for oldest first (the default), [\\"-changedAt\\"] for newest first"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_investor_executive_connections":{"id":"affinity_list_investor_executive_connections","name":"Affinity List Investor Executive Connections","description":"Find warm paths into a company through investment history: which investors in your Affinity data backed a company the people you want to reach once led. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_list_entries":{"id":"affinity_list_list_entries","name":"Affinity List List Entries","description":"Page through the rows of a list. Rows come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_list_entry_field_value_changes":{"id":"affinity_list_list_entry_field_value_changes","name":"Affinity List List Entry Field Value Changes","description":"Page through the history of one list row — who changed which field, when, and to what.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, changer.id, changedAt, or actionType, e.g. \\"field.id=field-1234\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_list_entry_fields":{"id":"affinity_list_list_entry_fields","name":"Affinity List List Entry Fields","description":"Page through every field value on one list row, including the list-specific columns. All fields are returned unless narrowed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, list, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}}},"affinity_list_list_field_dropdown_options":{"id":"affinity_list_list_field_dropdown_options","name":"Affinity List List Field Dropdown Options","description":"List the selectable options on a dropdown, ranked-dropdown, or status-dropdown field of a list.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_list_fields":{"id":"affinity_list_list_fields","name":"Affinity List List Fields","description":"List the fields available on one list, including its list-specific columns. Use these Field IDs when reading or writing list entries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Stage\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_lists":{"id":"affinity_list_lists","name":"Affinity List Lists","description":"Page through the lists in the organization that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive substring match on the list name"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_meetings":{"id":"affinity_list_meetings","name":"Affinity List Meetings","description":"Page through past and upcoming meetings with their organizer and attendees.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_merge_tasks":{"id":"affinity_list_merge_tasks","name":"Affinity List Merge Tasks","description":"Page through merge tasks, each summarizing how many of its merges are in progress, succeeded, or failed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge tasks to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on status only, e.g. \\"status=in-progress\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_merges":{"id":"affinity_list_merges","name":"Affinity List Merges","description":"Page through the company or person merges the organization has run, with the status and the records involved in each.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merges to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over status or taskId, e.g. \\"status=failed\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_note_attached_companies":{"id":"affinity_list_note_attached_companies","name":"Affinity List Note Attached Companies","description":"List the companies directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_note_attached_opportunities":{"id":"affinity_list_note_attached_opportunities","name":"Affinity List Note Attached Opportunities","description":"List the opportunities directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_note_attached_persons":{"id":"affinity_list_note_attached_persons","name":"Affinity List Note Attached Persons","description":"List the persons directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_note_replies":{"id":"affinity_list_note_replies","name":"Affinity List Note Replies","description":"Page through the replies on one note, including AI Notetaker replies.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID whose replies to read"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_notes":{"id":"affinity_list_notes","name":"Affinity List Notes","description":"Page through every note the caller can see. Replies are excluded.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_opportunities":{"id":"affinity_list_opportunities","name":"Affinity List Opportunities","description":"Page through opportunities. Field data lives on the list entry, not here — read it through the list or saved view the opportunity belongs to.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these opportunity IDs, e.g. [1, 2, 3]"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_persons":{"id":"affinity_list_persons","name":"Affinity List Persons","description":"Page through persons. Persons come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these person IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_reminders":{"id":"affinity_list_reminders","name":"Affinity List Reminders","description":"Page through the reminders the caller can see. Filter by status to surface what is overdue.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_saved_view_entries":{"id":"affinity_list_saved_view_entries","name":"Affinity List Saved View Entries","description":"Page through the rows of a saved view. The view\'s own filters and columns decide which rows and which field data come back.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_saved_views":{"id":"affinity_list_saved_views","name":"Affinity List Saved Views","description":"List the saved views on a list that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_list_transcript_fragments":{"id":"affinity_list_transcript_fragments","name":"Affinity List Transcript Fragments","description":"Page through everything said in a meeting, segment by segment with the speaker.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_transcripts":{"id":"affinity_list_transcripts","name":"Affinity List Transcripts","description":"Page through meeting transcript metadata. Read one transcript to get what was actually said.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_list_users":{"id":"affinity_list_users","name":"Affinity List Users","description":"Page through the internal users in the organization. Email addresses and roles are returned only to callers with the \\"Manage Users\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive match across first name, last name, and primary email"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over id or status, e.g. \\"status=active\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}}},"affinity_search_companies":{"id":"affinity_search_companies","name":"Affinity Search Companies","description":"Search companies by filters, sorts, and a free-text term. Requires the \\"Export All Organizations directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_search_files":{"id":"affinity_search_files","name":"Affinity Search Files","description":"Search files by keyword, ordered by relevance. Narrow to specific files or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these file IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s files. Cannot be combined with file IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of files to return, 1-100. Defaults to 20"}}},"affinity_search_list_entries":{"id":"affinity_search_list_entries","name":"Affinity Search List Entries","description":"Search the rows of one list by filters, sorts, and a free-text term. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID to search"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_search_notes":{"id":"affinity_search_notes","name":"Affinity Search Notes","description":"Search notes by keyword, ordered by relevance. Narrow to specific notes or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these note IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s notes. Cannot be combined with note IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of notes to return, 1-100. Defaults to 20"}}},"affinity_search_persons":{"id":"affinity_search_persons","name":"Affinity Search Persons","description":"Search persons by filters, sorts, and a free-text term. Requires the \\"Export All People directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}}},"affinity_semantic_search":{"id":"affinity_semantic_search","name":"Affinity Semantic Search","description":"Find companies from a description in plain language — industry, technology, stage, or business model. Currently searches companies only.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to look for, in plain language, e.g. \\"climate tech companies in our pipeline\\". Up to 500 characters"},"listIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to companies on these lists, e.g. [1, 2]"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of companies to return, 1-100. Defaults to 100"}}},"affinity_update_entity_field_value":{"id":"affinity_update_entity_field_value","name":"Affinity Update Entity Field Value","description":"Write one non-list field value on a company or person. The value type must match how the field is defined.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the field on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}}},"affinity_update_list_entry_field":{"id":"affinity_update_list_entry_field","name":"Affinity Update List Entry Field","description":"Write one field value on a list row. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}}},"affinity_update_list_field_dropdown_option":{"id":"affinity_update_list_field_dropdown_option","name":"Affinity Update List Field Dropdown Option","description":"Change a dropdown option on a list field. Every field is optional — supply only what should change, and only fields the option\'s kind actually has.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to update"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement option label. Supply at least one field to change"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}}},"affinity_update_note":{"id":"affinity_update_note","name":"Affinity Update Note","description":"Rewrite a note\'s body or replace which records it is attached to. Each list of IDs replaces that association wholesale, an empty list clears it, and omitting one leaves it untouched. A note\'s type cannot be changed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to update"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached companies, e.g. [1, 2]. Send [] to detach every company; omit to leave them unchanged"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached persons, e.g. [1, 2]. Send [] to detach every person; omit to leave them unchanged"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached opportunities, e.g. [1, 2]. Send [] to detach every opportunity; omit to leave them unchanged"}}},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}}},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}}},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}}},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}}},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}}},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}}},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}}},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}}},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}}},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}}},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}}},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}}},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}}},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}}},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}}},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}}},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}}},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}}},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}}},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}}},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}}},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}}},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}}},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}}},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}}},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}}},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}}},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}}},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}}},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}}},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}}},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}}},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}}},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}}},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}}},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}}},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}}},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}}},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}}},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}}},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}}},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}}},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}}},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}}},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}}},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}}},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}}},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airweave_search":{"id":"airweave_search","name":"Airweave Search","description":"Search your synced data collections using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Airweave API Key for authentication"},"collectionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The readable ID of the collection to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query text"},"limit":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 100)"},"retrievalStrategy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retrieval strategy: hybrid (default), neural, or keyword"},"expandQuery":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate query variations to improve recall"},"rerank":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reorder results for improved relevance using LLM"},"generateAnswer":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate a natural-language answer to the query"}}},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}}},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}}},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}}},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}}},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}}},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}}},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}}},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}}},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}}},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}}},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}}},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}}},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}}},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}}},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}}},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}}},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}}},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Run an APIFY actor asynchronously with polling for long-running tasks","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}]}"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}}},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}}},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}}},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}}},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}}},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}}},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}}},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}}},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}}},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}}},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}}},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}}},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}}},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}}},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}}},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}}},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}}},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}}},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}}},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}}},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}}},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}}},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}}},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}}},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}}},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}}},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}}},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}}},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}}},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}}},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}}},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}}},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}}},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}}},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}}},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}}},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"}},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"}},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"}},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}}},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}}},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}}},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"}}},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in (defaults to first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"}}},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"}}},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,