diff --git a/apps/docs/content/docs/en/cli/billing.mdx b/apps/docs/content/docs/en/cli/billing.mdx index b97e19c8156..979d99ca7fb 100644 --- a/apps/docs/content/docs/en/cli/billing.mdx +++ b/apps/docs/content/docs/en/cli/billing.mdx @@ -31,7 +31,7 @@ Show billing status and current-period credit usage (credits and storage require sim billing logs [options] ``` -List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's) +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) **Options** diff --git a/apps/docs/content/docs/en/cli/credentials.mdx b/apps/docs/content/docs/en/cli/credentials.mdx index ad63a7ba7a3..aec2144c459 100644 --- a/apps/docs/content/docs/en/cli/credentials.mdx +++ b/apps/docs/content/docs/en/cli/credentials.mdx @@ -33,7 +33,7 @@ Disconnect Credential (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/custom-tools.mdx b/apps/docs/content/docs/en/cli/custom-tools.mdx index 3f5af1e743f..097d4fb3fed 100644 --- a/apps/docs/content/docs/en/cli/custom-tools.mdx +++ b/apps/docs/content/docs/en/cli/custom-tools.mdx @@ -49,7 +49,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index 40758bc2999..c4f947fe16a 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -22,7 +22,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -85,11 +85,11 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List folders; returns the whole set, so there is no --limit and no paging ```bash sim files folders list [options] @@ -107,7 +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 the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders soft-deleted by a recursive delete, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -168,7 +168,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -194,7 +194,7 @@ sim files describe [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file soft-deleted by a delete; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -260,7 +260,7 @@ sim files list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | | `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `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`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files soft-deleted by a delete, which a 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`. | | `--search ` | No | Case-insensitive substring match against the file 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`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -377,7 +377,7 @@ sim files unzip [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index a760b5067b1..8291718ac79 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -120,7 +120,7 @@ Delete Tag (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -150,7 +150,7 @@ Remove tag definitions no document still uses (personal API key required) | --- | --- | --- | | `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused 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. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -273,7 +273,7 @@ Enable, disable, or delete many chunks at once (personal API key required) | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | | `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -334,7 +334,7 @@ Delete Chunk (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -477,7 +477,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -606,8 +606,8 @@ sim knowledge documents upload [options] | --- | --- | --- | | `--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. | +| `--recipe ` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. | +| `--lang ` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. | @@ -689,7 +689,7 @@ Delete Knowledge Connector (personal API key required) | --- | --- | --- | | `--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. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -903,11 +903,11 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List folders; returns the whole set, so there is no --limit and no paging ```bash sim knowledge folders list [options] @@ -969,7 +969,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -985,7 +985,7 @@ sim knowledge get | Argument | Required | Description | | --- | --- | --- | -| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Knowledge base to read. Active knowledge bases only: an archived one answers 404 here, is listed by `scope=archived`, and is brought back by the restore endpoint. | @@ -1001,7 +1001,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`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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`. | diff --git a/apps/docs/content/docs/en/cli/logs.mdx b/apps/docs/content/docs/en/cli/logs.mdx index 1b277367c9c..5c68de2a391 100644 --- a/apps/docs/content/docs/en/cli/logs.mdx +++ b/apps/docs/content/docs/en/cli/logs.mdx @@ -21,7 +21,7 @@ sim logs get [options] | Argument | Required | Description | | --- | --- | --- | -| `runId` | Yes | Unique workflow run identifier. | +| `runId` | Yes | Unique workflow run identifier. A run is addressed globally by this id: unlike the list and statistics routes, this route takes no workspace. The run carries its own workspace and the caller is authorized against that one, so a run the caller cannot reach is concealed as a 404 rather than filtered out. | @@ -35,7 +35,7 @@ sim logs get [options] -## Summarize run counts, failures, and cost over a window +## Summarize run counts, failures and latency over a window ```bash sim logs stats [options] diff --git a/apps/docs/content/docs/en/cli/mcp-servers.mdx b/apps/docs/content/docs/en/cli/mcp-servers.mdx index 1eac319876c..db0f65a115a 100644 --- a/apps/docs/content/docs/en/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/mcp-servers.mdx @@ -58,7 +58,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index f93c5009dc6..96e21aac0f5 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -251,7 +251,7 @@ sim billing status [options] ### sim billing logs -List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's) +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) ```bash sim billing logs [options] @@ -389,7 +389,7 @@ sim credentials delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -602,7 +602,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -694,7 +694,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -763,13 +763,13 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim files folders list -List Folders +List folders; returns the whole set, so there is no --limit and no paging ```bash sim files folders list [options] @@ -787,7 +787,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 the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders soft-deleted by a recursive delete, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -854,7 +854,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -882,7 +882,7 @@ sim files describe [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file soft-deleted by a delete; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -952,7 +952,7 @@ sim files list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | | `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `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`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files soft-deleted by a delete, which a 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`. | | `--search ` | No | Case-insensitive substring match against the file 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`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -1079,7 +1079,7 @@ sim files unzip [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1332,7 +1332,7 @@ sim knowledge tags delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1362,7 +1362,7 @@ sim knowledge tags cleanup [options] | --- | --- | --- | | `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused 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. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1487,7 +1487,7 @@ sim knowledge chunks batch-update [options] | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | | `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1548,7 +1548,7 @@ sim knowledge chunks delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1693,7 +1693,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1828,8 +1828,8 @@ sim knowledge documents upload [options] | --- | --- | --- | | `--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. | +| `--recipe ` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. | +| `--lang ` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. | @@ -1913,7 +1913,7 @@ sim knowledge connectors delete [options] | --- | --- | --- | | `--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. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2131,13 +2131,13 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge folders list -List Folders +List folders; returns the whole set, so there is no --limit and no paging ```bash sim knowledge folders list [options] @@ -2203,7 +2203,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2221,7 +2221,7 @@ sim knowledge get | Argument | Required | Description | | --- | --- | --- | -| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Knowledge base to read. Active knowledge bases only: an archived one answers 404 here, is listed by `scope=archived`, and is brought back by the restore endpoint. | @@ -2239,7 +2239,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`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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`. | @@ -2407,7 +2407,7 @@ sim logs get [options] | Argument | Required | Description | | --- | --- | --- | -| `runId` | Yes | Unique workflow run identifier. | +| `runId` | Yes | Unique workflow run identifier. A run is addressed globally by this id: unlike the list and statistics routes, this route takes no workspace. The run carries its own workspace and the caller is authorized against that one, so a run the caller cannot reach is concealed as a 404 rather than filtered out. | @@ -2423,7 +2423,7 @@ sim logs get [options] ### sim logs stats -Summarize run counts, failures, and cost over a window +Summarize run counts, failures and latency over a window ```bash sim logs stats [options] @@ -2565,7 +2565,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2715,7 +2715,7 @@ 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`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2733,7 +2733,7 @@ sim secrets list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Restrict results to one ownership scope. Accepted values: `workspace`, `personal`. | +| `--scope ` | No | Restrict results to one ownership scope. Personal results are not the caller's full personal set: this list reads the per-workspace credential mirrors of a personal secret, and a mirror exists only for workspaces the caller holds an explicit membership or ownership of. A personal secret is therefore omitted here when the caller reaches this workspace through inherited organization access, even though the same secret can be set and deleted from it. Prefer listing from a workspace the caller is an explicit member of until the mirrors are replaced by canonical personal-secret metadata. 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`. | @@ -2821,7 +2821,7 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2926,7 +2926,7 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3038,7 +3038,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3127,7 +3127,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3204,7 +3204,7 @@ sim tables batch-delete [options] | --- | --- | --- | | `--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; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3290,7 +3290,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3321,7 +3321,7 @@ sim tables rows batch-delete [options] | `--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 matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | | `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3522,7 +3522,7 @@ sim tables rows batch-update [options] | `--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 matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3580,7 +3580,7 @@ sim tables dispatches cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3763,7 +3763,7 @@ sim tables imports cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3813,7 +3813,7 @@ sim tables cancel-runs [options] | `--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; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3881,13 +3881,13 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim tables folders list -List Folders +List folders; returns the whole set, so there is no --limit and no paging ```bash sim tables folders list [options] @@ -4001,7 +4001,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4099,7 +4099,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4155,7 +4155,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 a table 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`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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`. | @@ -4455,7 +4455,7 @@ sim workflow-mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4533,7 +4533,7 @@ sim workflow-mcp-servers tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4615,7 +4615,7 @@ Also spelled `sim workflow`. Activate Workflow Version (personal API key required) ```bash -sim workflows activate create +sim workflows activate create [options] ``` **Arguments** @@ -4629,6 +4629,16 @@ sim workflows activate create +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + ### sim workflows operations apply Apply Workflow Operations (personal API key required) @@ -4655,12 +4665,12 @@ sim workflows operations apply [options] | --- | --- | --- | | `--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, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). | +| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also extract_from_subflow, whose params carry {"subflowId":"<loop-id>"}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId (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: [{"block_id":"<uuid>","enabled":false}]. 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` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -4689,7 +4699,7 @@ sim workflows variables update [options] | Option | Required | Description | | --- | --- | --- | | `--operations ` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4900,13 +4910,13 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows folders list -List Workflow Folders +List folders; returns the whole set, so there is no --limit and no paging ```bash sim workflows folders list [options] @@ -4972,7 +4982,7 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5000,7 +5010,7 @@ sim workflows chat unpublish [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5058,7 +5068,7 @@ sim workflows chat publish [options] | `--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. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5294,7 +5304,7 @@ sim workflows state replace [options] | `--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` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -5410,7 +5420,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. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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. | @@ -5483,7 +5493,7 @@ sim workflows revert create [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5512,7 +5522,7 @@ sim workflows rollback [options] | Option | Required | Description | | --- | --- | --- | | `--to-version ` | No | Deployment version to reactivate. Omit to select the previous active version. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5540,7 +5550,7 @@ sim workflows undeploy [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/secrets.mdx b/apps/docs/content/docs/en/cli/secrets.mdx index cf14dda7d5e..e80d8b3c5f4 100644 --- a/apps/docs/content/docs/en/cli/secrets.mdx +++ b/apps/docs/content/docs/en/cli/secrets.mdx @@ -34,7 +34,7 @@ Delete Secret (personal API key required) | 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. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -52,7 +52,7 @@ List Secrets (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Restrict results to one ownership scope. Accepted values: `workspace`, `personal`. | +| `--scope ` | No | Restrict results to one ownership scope. Personal results are not the caller's full personal set: this list reads the per-workspace credential mirrors of a personal secret, and a mirror exists only for workspaces the caller holds an explicit membership or ownership of. A personal secret is therefore omitted here when the caller reaches this workspace through inherited organization access, even though the same secret can be set and deleted from it. Prefer listing from a workspace the caller is an explicit member of until the mirrors are replaced by canonical personal-secret metadata. 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`. | diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index a9f969b869b..77d928a5ead 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -53,7 +53,7 @@ Delete Skill (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -154,7 +154,7 @@ Revoke Skill Editor (personal API key required) | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index 1739a5ef1e8..28bc9993fba 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -58,7 +58,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -141,7 +141,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -212,7 +212,7 @@ sim tables batch-delete [options] | --- | --- | --- | | `--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; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -292,7 +292,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -321,7 +321,7 @@ sim tables rows batch-delete [options] | `--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 matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | | `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -508,7 +508,7 @@ sim tables rows batch-update [options] | `--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 matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -562,7 +562,7 @@ sim tables dispatches cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -729,7 +729,7 @@ sim tables imports cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -775,7 +775,7 @@ sim tables cancel-runs [options] | `--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; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -837,11 +837,11 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List folders; returns the whole set, so there is no --limit and no paging ```bash sim tables folders list [options] @@ -947,7 +947,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1037,7 +1037,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1087,7 +1087,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 a table 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`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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`. | diff --git a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx index af1ff6add1b..ca7ca03a730 100644 --- a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx @@ -53,7 +53,7 @@ Delete Workflow MCP Server (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -131,7 +131,7 @@ Unpublish Workflow MCP Tool (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/workflows.mdx b/apps/docs/content/docs/en/cli/workflows.mdx index 3a82b1878ef..9331fc5bd56 100644 --- a/apps/docs/content/docs/en/cli/workflows.mdx +++ b/apps/docs/content/docs/en/cli/workflows.mdx @@ -12,7 +12,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio ## Activate workflow version ```bash -sim workflows activate create +sim workflows activate create [options] ``` Activate Workflow Version (personal API key required) @@ -28,6 +28,16 @@ Activate Workflow Version (personal API key required) +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + ## Apply workflow operations ```bash @@ -54,12 +64,12 @@ Apply Workflow Operations (personal API key required) | --- | --- | --- | | `--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, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). | +| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also extract_from_subflow, whose params carry {"subflowId":"<loop-id>"}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId (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: [{"block_id":"<uuid>","enabled":false}]. 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` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -86,7 +96,7 @@ sim workflows variables update [options] | Option | Required | Description | | --- | --- | --- | | `--operations ` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -285,11 +295,11 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List workflow folders +## List folders; returns the whole set, so there is no --limit and no paging ```bash sim workflows folders list [options] @@ -351,7 +361,7 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -379,7 +389,7 @@ Take a workflow’s chat deployment offline (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -437,7 +447,7 @@ Publish or replace a workflow’s chat deployment (personal API key required) | `--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. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -661,7 +671,7 @@ Replace Workflow State (personal API key required) | `--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` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -767,7 +777,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. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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. | @@ -836,7 +846,7 @@ Revert Workflow To Version (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -865,7 +875,7 @@ Rollback Workflow (personal API key required) | Option | Required | Description | | --- | --- | --- | | `--to-version ` | No | Deployment version to reactivate. Omit to select the previous active version. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -893,7 +903,7 @@ Take a workflow out of deployment (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 8649896cb0e..f25d1559d21 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. 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.", + "description": "Workspace whose payer should be resolved. Omitting it selects account scope — the payer behind the calling *account*, across every workspace — and only a personal API key has an account to select. A workspace API key that omits it still resolves its own workspace, because omission is the same request as sending that key its own id; it is not a way to widen a workspace key. The response `workspaceId` reports which was resolved and is `null` only on account scope. 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. 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.", + "description": "Workspace whose payer should be resolved. Omitting it selects account scope — the payer behind the calling *account*, across every workspace — and only a personal API key has an account to select. A workspace API key that omits it still resolves its own workspace, because omission is the same request as sending that key its own id; it is not a way to widen a workspace key. The response `workspaceId` reports which was resolved and is `null` only on account scope. 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 @@ -130,9 +130,9 @@ "name": "workspaceId", "in": "query", "required": false, - "description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. 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.", + "description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. Omitting it does not widen a workspace API key: that key has no account behind it, so an omitted id is the same request as its own id and the page still covers exactly one workspace, reported as `scope: workspace`. A ledger spanning every workspace an account touches requires a personal API key. 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": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. 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.", + "description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. Omitting it does not widen a workspace API key: that key has no account behind it, so an omitted id is the same request as its own id and the page still covers exactly one workspace, reported as `scope: workspace`. A ledger spanning every workspace an account touches requires a personal API key. 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 @@ -700,7 +700,7 @@ "required": ["id", "createdAt", "source", "workspaceId", "workflow", "runId", "creditCost"], "additionalProperties": false, "title": "Billing log entry", - "description": "One credit-consuming usage event in the billing ledger." + "description": "One credit-consuming usage event in the billing ledger. No field identifies the member who incurred it: on `workspace` scope a page is every member’s usage in aggregate, not a per-member breakdown, so it reconciles a workspace’s spend without publishing who spent it." }, "V2BillingLogListResponse": { "type": "object", @@ -726,7 +726,7 @@ "scope": { "type": "string", "enum": ["user", "workspace"], - "description": "Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to." + "description": "Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to, in aggregate: no entry names the member it belongs to." } }, "required": ["data", "nextCursor", "scope"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 88dd604ab00..6a11f6957c3 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -93,10 +93,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files soft-deleted by a delete, which a 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 files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files soft-deleted by a delete, which a 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"] } @@ -1384,10 +1384,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file soft-deleted by a delete; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "schema": { "default": "active", - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file soft-deleted by a delete; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "type": "string", "enum": ["active", "archived"] } @@ -2144,10 +2144,10 @@ "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 the folder restore. Authorization is identical for both.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders soft-deleted by a recursive delete, which is how a caller finds a path to hand to the folder 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 the folder restore. Authorization is identical for both.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders soft-deleted by a recursive delete, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", "type": "string", "enum": ["active", "archived"] } diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index d433c0ea73b..a5cdc6d974d 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -55,10 +55,10 @@ "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.", + "description": "Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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.", + "description": "Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -264,11 +264,11 @@ "name": "knowledgeBaseId", "in": "path", "required": true, - "description": "Unique knowledge base identifier.", + "description": "Knowledge base to read. Active knowledge bases only: an archived one answers 404 here, is listed by `scope=archived`, and is brought back by the restore endpoint.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge base identifier." + "description": "Knowledge base to read. Active knowledge bases only: an archived one answers 404 here, is listed by `scope=archived`, and is brought back by the restore endpoint." } }, { @@ -6383,22 +6383,29 @@ "enum": ["enable", "disable"], "description": "Operation that was applied." }, - "updatedCount": { + "processed": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Number of documents the operation changed.", + "description": "Number of documents in this knowledge base the operation matched. Documents already in the requested state are counted too, so this is not a count of changes.", "examples": [42] }, + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Per-document failures, including any identifier that named no updatable document in the knowledge base. A populated array still answers 200, and a `selectAll` request reports an empty one." + }, "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.", + "description": "Identifiers of the documents the operation matched. 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 `processed` instead.", "type": "array", "items": { "type": "string" } } }, - "required": ["operation", "updatedCount"], + "required": ["operation", "processed", "errors"], "additionalProperties": false, "title": "Bulk knowledge document update data", "description": "Outcome of a bulk enable or disable across knowledge documents." diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 3f69f6dd896..0f654baa9d0 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -358,13 +358,13 @@ "name": "runId", "in": "path", "required": true, - "description": "Unique workflow run identifier.", + "description": "Unique workflow run identifier. A run is addressed globally by this id: unlike the list and statistics routes, this route takes no workspace. The run carries its own workspace and the caller is authorized against that one, so a run the caller cannot reach is concealed as a 404 rather than filtered out.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier." + "description": "Unique workflow run identifier. A run is addressed globally by this id: unlike the list and statistics routes, this route takes no workspace. The run carries its own workspace and the caller is authorized against that one, so a run the caller cannot reach is concealed as a 404 rather than filtered out." } } ], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 402e5d1dfdd..a8a8d20a522 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2597,9 +2597,9 @@ "name": "scope", "in": "query", "required": false, - "description": "Restrict results to one ownership scope.", + "description": "Restrict results to one ownership scope. Personal results are not the caller's full personal set: this list reads the per-workspace credential mirrors of a personal secret, and a mirror exists only for workspaces the caller holds an explicit membership or ownership of. A personal secret is therefore omitted here when the caller reaches this workspace through inherited organization access, even though the same secret can be set and deleted from it. Prefer listing from a workspace the caller is an explicit member of until the mirrors are replaced by canonical personal-secret metadata.", "schema": { - "description": "Restrict results to one ownership scope.", + "description": "Restrict results to one ownership scope. Personal results are not the caller's full personal set: this list reads the per-workspace credential mirrors of a personal secret, and a mirror exists only for workspaces the caller holds an explicit membership or ownership of. A personal secret is therefore omitted here when the caller reaches this workspace through inherited organization access, even though the same secret can be set and deleted from it. Prefer listing from a workspace the caller is an explicit member of until the mirrors are replaced by canonical personal-secret metadata.", "type": "string", "enum": ["workspace", "personal"] } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 25250fc7714..cd8051be7f5 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -55,10 +55,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table 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.", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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 a table 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.", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index b80dda73fab..a0a41cf78e2 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -59,10 +59,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `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. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/collection.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/collection.test.ts index fe7cc7e1ec8..0f810e75c5a 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/collection.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/collection.test.ts @@ -209,6 +209,7 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => { mockBulkUpdate.mockResolvedValue({ operation: 'disable', successCount: 2, + errors: [], updatedDocuments: [ { id: 'doc-1', enabled: false }, { id: 'doc-2', enabled: false }, @@ -227,6 +228,7 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => { mockBulkUpdate.mockResolvedValueOnce({ operation: 'disable', successCount: 100_000, + errors: [], updatedDocuments: Array.from({ length: 100_000 }, (_, index) => ({ id: `doc-${index}`, enabled: false, @@ -241,7 +243,7 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ - data: { operation: 'disable', updatedCount: 100_000 }, + data: { operation: 'disable', processed: 100_000, errors: [] }, }) }) @@ -257,7 +259,7 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ - data: { operation: 'disable', updatedCount: 2, documentIds: ['doc-1', 'doc-2'] }, + data: { operation: 'disable', processed: 2, errors: [], documentIds: ['doc-1', 'doc-2'] }, }) expect(mockBulkUpdate).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts index 70d57103e8f..d39c7ab7d7a 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts @@ -156,12 +156,13 @@ export const PATCH = defineV2JsonRoute({ * bounds. A `selectAll` request has no such bound: a knowledge base with * 100k documents would otherwise materialize and element-wise validate a * multi-megabyte identifier array nobody asked for. That caller reads - * `updatedCount` and re-lists if it needs the identifiers. + * `processed` and re-lists if it needs the identifiers. */ return { data: { operation: result.operation, - updatedCount: result.successCount, + processed: result.successCount, + errors: result.errors, documentIds: result.selectAll ? undefined : result.updatedDocuments.map((document) => document.id), diff --git a/apps/sim/lib/api/contracts/knowledge/documents.test.ts b/apps/sim/lib/api/contracts/knowledge/documents.test.ts index 5c0510f5dc4..7d6204ff63b 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.test.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { bulkCreateDocumentsBodySchema, + bulkKnowledgeDocumentsContract, listKnowledgeDocumentsQuerySchema, parseDocumentTagFiltersParam, upsertDocumentBodySchema, @@ -210,3 +211,44 @@ describe('internal document processingOptions', () => { }) }) }) + +/** + * The route presents the use case's result verbatim, and the internal route + * builder runs that object through this schema before it goes on the wire. A + * field the schema does not name is therefore not merely untyped — Zod strips + * it, and the caller receives a response that silently lacks it. + * + * Asserted through the contract rather than the bare data schema so the test + * covers the object the route actually validates, envelope included. + */ +describe('bulkKnowledgeDocumentsContract response', () => { + const responseSchema = + bulkKnowledgeDocumentsContract.response.mode === 'json' + ? bulkKnowledgeDocumentsContract.response.schema + : null + + /** The shape `bulkUpdateKnowledgeDocuments` returns, as the route presents it. */ + const presented = { + success: true as const, + data: { + operation: 'disable', + successCount: 0, + errors: ['No matching documents found to disable: doc_missing'], + updatedDocuments: [], + selectAll: false, + }, + } + + it('keeps the per-document diagnostics instead of stripping them', () => { + const parsed = responseSchema?.parse(presented) as { data: { errors?: unknown } } + expect(parsed.data.errors).toEqual(['No matching documents found to disable: doc_missing']) + }) + + it('requires the field, because both selections always report one', () => { + const withoutErrors = { + ...presented, + data: { ...presented.data, errors: undefined }, + } + expect(() => responseSchema?.parse(withoutErrors)).toThrow() + }) +}) diff --git a/apps/sim/lib/api/contracts/knowledge/documents.ts b/apps/sim/lib/api/contracts/knowledge/documents.ts index bc8ad27d841..e4a6218df79 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.ts @@ -240,6 +240,15 @@ export const bulkDocumentOperationDataSchema = z.object({ operation: z.string().optional(), successCount: z.number(), failedCount: z.number().optional(), + /** + * Identifiers the selection named that matched no updatable document, and any + * other per-document failure. Declared because the response validator strips + * what the schema does not name, so omitting it discarded the diagnostics the + * operation reports rather than merely leaving them untyped. Always present: + * both selections return an array, and a `selectAll` sweep returns an empty + * one. + */ + errors: z.array(z.string()), updatedDocuments: z .array(z.object({ id: z.string(), enabled: z.boolean().optional() })) .optional(), diff --git a/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts index 10a3125753d..d4b550c853e 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts @@ -49,10 +49,6 @@ const ALLOWED = new Map([ '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.', 'not touched here: lives in v2/knowledge.ts', ], - [ - '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.', - 'not touched here: lives in v2/knowledge.ts', - ], [ '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`.', 'not touched here: lives in v2/knowledge.ts', diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index d55e3333f25..a61cef057cc 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -42,7 +42,7 @@ export const v2BillingStatusQuerySchema = z workspaceId: workspaceIdSchema .optional() .describe( - '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.' + 'Workspace whose payer should be resolved. Omitting it selects account scope — the payer behind the calling *account*, across every workspace — and only a personal API key has an account to select. A workspace API key that omits it still resolves its own workspace, because omission is the same request as sending that key its own id; it is not a way to widen a workspace key. The response `workspaceId` reports which was resolved and is `null` only on account scope. 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() @@ -172,7 +172,7 @@ export const v2BillingLogsQuerySchema = z workspaceId: workspaceIdSchema .optional() .describe( - "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. 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." + "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. Omitting it does not widen a workspace API key: that key has no account behind it, so an omitted id is the same request as its own id and the page still covers exactly one workspace, reported as `scope: workspace`. A ledger spanning every workspace an account touches requires a personal API key. 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." ), period: usageLogPeriodSchema .optional() @@ -278,7 +278,8 @@ export const v2BillingLogEntrySchema = z .meta({ id: 'V2BillingLogEntry', title: 'Billing log entry', - description: 'One credit-consuming usage event in the billing ledger.', + description: + 'One credit-consuming usage event in the billing ledger. No field identifies the member who incurred it: on `workspace` scope a page is every member’s usage in aggregate, not a per-member breakdown, so it reconciles a workspace’s spend without publishing who spent it.', }) export type V2BillingLogEntry = z.output @@ -292,7 +293,7 @@ export type V2BillingLogEntry = z.output export const v2BillingLogsScopeSchema = z .enum(['user', 'workspace']) .describe( - "Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to." + "Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to, in aggregate: no entry names the member it belongs to." ) export const v2ListBillingLogsContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index c94c5c10940..0e82519325c 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -344,7 +344,7 @@ export const v2ListFilesQuerySchema = z scope: v2FileScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files soft-deleted by a delete, which a 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.' ), search: v2SearchSchema.describe('Case-insensitive substring match against the file name.'), ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), @@ -396,7 +396,7 @@ export const v2GetFileMetadataQuerySchema = z scope: v2FileScopeSchema .default('active') .describe( - 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.' + 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file soft-deleted by a delete; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.' ), }) .strict() @@ -510,7 +510,7 @@ 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 the folder restore. Authorization is identical for both.' + 'Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders soft-deleted by a recursive delete, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.' ), }) export type V2ListFileFoldersQuery = z.output diff --git a/apps/sim/lib/api/contracts/v2/knowledge-bulk-and-lifecycle.test.ts b/apps/sim/lib/api/contracts/v2/knowledge-bulk-and-lifecycle.test.ts new file mode 100644 index 00000000000..a09d93c2117 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge-bulk-and-lifecycle.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + v2BulkKnowledgeDocumentsDataSchema, + v2GetKnowledgeBaseContract, +} from '@/lib/api/contracts/v2/knowledge' +import { v2BulkKnowledgeChunksDataSchema } from '@/lib/api/contracts/v2/knowledge-chunks' + +/** + * The two bulk surfaces answer the same question — "which of the ids I named + * did nothing?" — so they are pinned against each other rather than against a + * literal, which is what stops one of them being renamed on its own again. + */ +describe('bulk knowledge outcomes', () => { + it('reports the matched count and the unmatched ids under the same names on both', () => { + const documents = Object.keys(v2BulkKnowledgeDocumentsDataSchema.shape) + const chunks = Object.keys(v2BulkKnowledgeChunksDataSchema.shape) + + for (const field of ['operation', 'processed', 'errors']) { + expect(chunks).toContain(field) + expect(documents).toContain(field) + } + expect(documents).not.toContain('updatedCount') + }) + + it('accepts a zero-match document sweep as a success body rather than an error', () => { + const parsed = v2BulkKnowledgeDocumentsDataSchema.safeParse({ + operation: 'disable', + processed: 0, + errors: ['No matching documents found to disable: document-missing'], + documentIds: [], + }) + + expect(parsed.success).toBe(true) + }) +}) + +describe('knowledge base read lifecycle', () => { + it('says which lifecycle the read addresses on the identifier a caller types', () => { + const described = v2GetKnowledgeBaseContract.params?.shape.knowledgeBaseId.description + + expect(described).toBeTypeOf('string') + expect(described).toContain('Active knowledge bases only') + expect(described).toContain('archived') + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index a30fc2bd912..26c6ec898f9 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -627,7 +627,7 @@ export const v2ListKnowledgeBasesQuerySchema = z 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.' + 'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.' ), folderPath: v2FolderPathInputSchema .optional() @@ -755,10 +755,24 @@ export const v2CreateKnowledgeBaseContract = defineRouteContract({ }, }) +/** + * Reads the active lifecycle only, which is why the identifier redescribes + * itself here rather than reusing the shared params schema: an archived + * knowledge base answers `404` from this route the same way a nonexistent one + * does, and nothing else on the read surface says which of the two lifecycles + * it addresses. `GET /api/v2/knowledge?scope=archived` is what resolves an + * archived base, and `POST /knowledge/{knowledgeBaseId}/restore` brings it back. + */ +const v2GetKnowledgeBaseParamsSchema = v2KnowledgeBaseParamsSchema.extend({ + knowledgeBaseId: v2KnowledgeBaseParamsSchema.shape.knowledgeBaseId.describe( + 'Knowledge base to read. Active knowledge bases only: an archived one answers 404 here, is listed by `scope=archived`, and is brought back by the restore endpoint.' + ), +}) + export const v2GetKnowledgeBaseContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge/[knowledgeBaseId]', - params: v2KnowledgeBaseParamsSchema, + params: v2GetKnowledgeBaseParamsSchema, query: v1KnowledgeWorkspaceQuerySchema .extend({ workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( @@ -1482,22 +1496,43 @@ export const v2BulkKnowledgeDocumentsBodySchema = z }) export type V2BulkKnowledgeDocumentsBody = z.input -/** Bulk update outcome — one object, not a page. */ +/** + * Bulk update outcome — one object, not a page. + * + * Best-effort, and reported the same way {@link v2BulkKnowledgeChunksDataSchema} + * reports it: an identifier naming no updatable document in the knowledge base + * lands in `errors` rather than failing the request, and a request whose every + * identifier missed answers `200` with `processed: 0` instead of `404`. The + * selection has always applied to what it matched, so the shared shape describes + * what the operation already did rather than changing it. + * + * `processed`, not `updatedCount`: the count is the documents the selection + * matched, and disabling a document that was already disabled counts it, so it + * is not a count of changes. That is the same quantity `processed` names on the + * chunk sibling, and naming it the same way keeps the two readable side by side. + */ export const v2BulkKnowledgeDocumentsDataSchema = z .object({ operation: z.enum(['enable', 'disable']).describe('Operation that was applied.'), - updatedCount: z + processed: z .number() .int() .nonnegative() - .describe('Number of documents the operation changed.') + .describe( + 'Number of documents in this knowledge base the operation matched. Documents already in the requested state are counted too, so this is not a count of changes.' + ) .meta({ examples: [42] }), + errors: z + .array(z.string()) + .describe( + 'Per-document failures, including any identifier that named no updatable document in the knowledge base. A populated array still answers 200, and a `selectAll` request reports an empty one.' + ), documentIds: z .array(z.string()) .optional() .describe( - 'Identifiers of the documents the operation changed. Present only for an explicit `documentIds` request, which is bounded to ' + - `${MAX_V2_BULK_KNOWLEDGE_DOCUMENTS} documents; a \`selectAll\` request omits it because the selection is unbounded, and reports \`updatedCount\` instead.` + 'Identifiers of the documents the operation matched. Present only for an explicit `documentIds` request, which is bounded to ' + + `${MAX_V2_BULK_KNOWLEDGE_DOCUMENTS} documents; a \`selectAll\` request omits it because the selection is unbounded, and reports \`processed\` instead.` ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/list-scope-folder-filter.test.ts b/apps/sim/lib/api/contracts/v2/list-scope-folder-filter.test.ts new file mode 100644 index 00000000000..eb727bdda06 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/list-scope-folder-filter.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { v2ListKnowledgeBasesQuerySchema } from '@/lib/api/contracts/v2/knowledge' +import { v2ListTablesQuerySchema } from '@/lib/api/contracts/v2/tables' +import { v2ListWorkflowsQuerySchema } from '@/lib/api/contracts/v2/workflows' + +/** + * A v2 description is read on two surfaces at once: the API reference, where a + * filter is `folderPath`, and `sim tables list --help`, where the same filter is + * `--folder`. Naming either spelling is wrong on the other surface, so the + * `scope` prose names the concept — "the folder filter" — the way the workflows + * sibling already does. Asserted on both so the pair cannot drift apart again. + */ +const SCOPE_DESCRIPTIONS = [ + ['tables', v2ListTablesQuerySchema.shape.scope.description], + ['workflows', v2ListWorkflowsQuerySchema.shape.scope.description], + ['knowledge', v2ListKnowledgeBasesQuerySchema.shape.scope.description], +] as const + +describe('v2 list scope descriptions', () => { + it.each(SCOPE_DESCRIPTIONS)( + 'name the folder filter transport-neutrally on %s', + (_, described) => { + expect(described).toBeTypeOf('string') + expect(described).toContain('The folder filter resolves against active folders only') + expect(described).not.toContain('folderPath') + } + ) + + /** + * These are published API descriptions, so the lifecycle clause has to read as + * English. It was written as an object relative clause with the pronoun + * dropped — "knowledge bases a delete archived and the restore operation can + * bring back" — which does not parse on a first read, and the three siblings + * each dropped it differently. Pinned as one passive clause so a reword on one + * cannot quietly reintroduce the other two spellings. + */ + it.each(SCOPE_DESCRIPTIONS)('describe the archived set readably on %s', (_, described) => { + expect(described).toContain('archived by a delete, which a restore can bring back') + expect(described).not.toMatch(/\ba delete archived\b/) + expect(described).not.toMatch(/`DELETE` archived/) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index f2e65ea41ab..0f9538e6d49 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -307,7 +307,9 @@ export const v2LogDetailSchema = z export type V2LogDetail = z.output export const v2LogParamsSchema = z.object({ - runId: runIdSchema.describe('Unique workflow run identifier.'), + runId: runIdSchema.describe( + 'Unique workflow run identifier. A run is addressed globally by this id: unlike the list and statistics routes, this route takes no workspace. The run carries its own workspace and the caller is authorized against that one, so a run the caller cannot reach is concealed as a 404 rather than filtered out.' + ), }) /** @@ -679,6 +681,18 @@ export const v2ListLogsContract = defineRouteContract({ }, }) +/** + * Deliberately without a workspace input, unlike {@link v2ListLogsContract} and + * `v2GetLogStatsContract`, which both require one. + * + * A run id is globally unique, so the run names its own workspace and the caller + * is authorized against that canonical scope. Accepting a workspace here would + * offer a filter that can only ever agree with the scope already derived, or + * disagree with it — and a caller who read the asymmetry as "this route ignores + * the workspace I asked for" is reading a flag that was never part of the + * request. `query: noInputSchema` is what makes that visible: a stray + * `workspaceId` is refused as an unrecognized key rather than dropped. + */ export const v2GetLogContract = defineRouteContract({ method: 'GET', path: '/api/v2/logs/[runId]', diff --git a/apps/sim/lib/api/contracts/v2/secrets.ts b/apps/sim/lib/api/contracts/v2/secrets.ts index bc25ae81d82..349af4e6a0a 100644 --- a/apps/sim/lib/api/contracts/v2/secrets.ts +++ b/apps/sim/lib/api/contracts/v2/secrets.ts @@ -96,7 +96,11 @@ export type V2SecretSortBy = (typeof v2SecretSortFields)[number] export const v2ListSecretsQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace whose secret metadata should be listed.'), - scope: v2SecretScopeSchema.optional().describe('Restrict results to one ownership scope.'), + scope: v2SecretScopeSchema + .optional() + .describe( + "Restrict results to one ownership scope. Personal results are not the caller's full personal set: this list reads the per-workspace credential mirrors of a personal secret, and a mirror exists only for workspaces the caller holds an explicit membership or ownership of. A personal secret is therefore omitted here when the caller reaches this workspace through inherited organization access, even though the same secret can be set and deleted from it. Prefer listing from a workspace the caller is an explicit member of until the mirrors are replaced by canonical personal-secret metadata." + ), search: v2SearchSchema.describe('Case-insensitive substring match against the secret name.'), ...v2SortFields(v2SecretSortFields, { sortBy: 'name', sortOrder: 'asc' }), ...v2PaginationFields({ description: 'Maximum secrets to return per page.' }), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 95f1686b33a..fcde886bea9 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -436,7 +436,7 @@ export const v2ListTablesQuerySchema = z scope: v2TableScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table 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.' + 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.' ), folderPath: v2FolderPathInputSchema .optional() diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 2ac11bf28c2..66b43acd0e1 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -158,7 +158,7 @@ export const v2ListWorkflowsQuerySchema = z scope: v2WorkflowScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.' + 'Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.' ), folderPath: v2FolderPathInputSchema .optional() diff --git a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts index 0d4cc811e62..f8a64b7e9c5 100644 --- a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -47,6 +47,27 @@ function requireBillingReadPrincipal( } } +/** + * Account scope is reachable only by a personal API key, and that is a property + * of what the two kinds of key are rather than a check performed here. + * + * Omitting `workspaceId` is how a caller asks for account scope, and it is also + * the plainest request anyone makes — `GET /api/v2/billing/status` with no query + * at all. The two are byte-identical on the wire, so a workspace API key that + * omits the id cannot be told apart from one that never had an opinion, and this + * function pins it to `principal.workspaceId` in both cases. It deliberately + * does not refuse the omission: a workspace key has no account to widen to, so + * there is no wider answer being withheld, and refusing would `403` every + * unparameterised billing read a workspace key makes. + * + * The consequence is that a caller cannot *assert* account scope over this + * surface, only imply it. A client that means "account-wide" must therefore + * establish that it holds a personal key — `GET /api/v2/meta` reports `keyType` + * — and refuse locally, rather than relying on a refusal that this layer has no + * signal to produce. The answer still says which scope it resolved: + * `workspaceId` is `null` only on the account branch, and the ledger reports + * `scope`. + */ async function resolveBillingReadScope( principal: BillingReadPrincipal, operation: BillingReadOperation, diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index 2fed1fc8d9a..c49e68b9487 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ recordKnowledgeBaseFileOwnership: vi.fn(), recordAudit: vi.fn(), captureServerEvent: vi.fn(), + assertTagSlotsAreDefined: vi.fn(), getDocumentTagDefinitions: vi.fn(), })) @@ -78,6 +79,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ })) vi.mock('@/lib/knowledge/tags/service', () => ({ + assertTagSlotsAreDefined: mocks.assertTagSlotsAreDefined, getDocumentTagDefinitions: mocks.getDocumentTagDefinitions, })) @@ -594,7 +596,8 @@ describe('knowledge document application use cases', () => { expect(mocks.updateDocument).toHaveBeenCalledWith( 'document-1', { filename: undefined, enabled: false }, - expect.any(String) + expect.any(String), + { knowledgeBaseId: 'knowledge-1' } ) expect(mocks.recordAudit).toHaveBeenCalledWith( expect.objectContaining({ @@ -673,7 +676,8 @@ describe('knowledge document application use cases', () => { number1: '2', boolean1: 'false', }, - expect.any(String) + expect.any(String), + { knowledgeBaseId: 'knowledge-1' } ) expect(mocks.recordAudit).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 1cefe262fe2..3eb3822bd57 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -910,7 +910,19 @@ export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ } return { kind: 'updated' as const, - document: await updateDocument(context.documentId, updates, generateRequestId()), + /** + * `knowledgeBaseId` is what asks the write to refuse a value bound for a + * slot this knowledge base has no definition for. Both branches above need + * it: `input.updates` carries the raw `tag1`..`tag7` the API surfaces + * accept and can name an undefined slot outright, and `tagValues` + * addresses definitions by id but resolves them to slots before the write, + * so a definition deleted after that resolution leaves the same stranded + * value. The check runs inside the write's transaction under the knowledge + * base's row lock, which is the lock tag deletion takes. + */ + document: await updateDocument(context.documentId, updates, generateRequestId(), { + knowledgeBaseId: context.knowledgeBaseId, + }), tagDefinitions: await getDocumentTagDefinitions(context.knowledgeBaseId), updatedFields, } @@ -962,6 +974,12 @@ export const bulkUpdateKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ return { operation: input.operation, successCount: result.successCount, + /** + * Only an explicit selection can name an id that matched nothing, so a + * `selectAll` sweep reports an empty array rather than omitting the field: + * one response shape for both selections. + */ + errors: result.errors, updatedDocuments: result.updatedDocuments, /** * Reported so a surface can tell a bounded selection from an unbounded diff --git a/apps/sim/lib/knowledge/documents/bulk-document-operation.test.ts b/apps/sim/lib/knowledge/documents/bulk-document-operation.test.ts new file mode 100644 index 00000000000..7ff0a0dc8fb --- /dev/null +++ b/apps/sim/lib/knowledge/documents/bulk-document-operation.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { document } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/knowledge/documents/document-processor', () => ({ + processDocument: vi.fn(), +})) + +vi.mock('@/lib/knowledge/embedding-models', () => ({ + EMBEDDING_DIMENSIONS: 1536, + getEmbeddingModelInfo: vi.fn(() => ({ tokenizerProvider: 'openai' })), +})) + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateEmbeddings: vi.fn(), +})) + +import { bulkDocumentOperation } from '@/lib/knowledge/documents/service' + +const KNOWLEDGE_BASE_ID = 'knowledge-base-1' + +describe('bulkDocumentOperation unmatched-id reporting', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('reports an id that matched no updatable document without failing the request', async () => { + queueTableRows(document, [{ id: 'document-1', enabled: true }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1', enabled: false }]) + + const result = await bulkDocumentOperation( + KNOWLEDGE_BASE_ID, + 'disable', + ['document-1', 'document-missing'], + 'request-1' + ) + + expect(result.successCount).toBe(1) + expect(result.errors).toEqual(['No matching documents found to disable: document-missing']) + expect(result.success).toBe(false) + }) + + it('answers a zero-match selection the same way, rather than throwing not-found', async () => { + queueTableRows(document, []) + + const result = await bulkDocumentOperation( + KNOWLEDGE_BASE_ID, + 'enable', + ['missing-1', 'missing-2'], + 'request-2' + ) + + expect(result.successCount).toBe(0) + expect(result.updatedDocuments).toEqual([]) + expect(result.errors).toEqual(['No matching documents found to enable: missing-1, missing-2']) + expect(result.success).toBe(false) + }) + + it('reports no errors when every requested document matched', async () => { + queueTableRows(document, [ + { id: 'document-1', enabled: false }, + { id: 'document-2', enabled: false }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { id: 'document-1', enabled: true }, + { id: 'document-2', enabled: true }, + ]) + + const result = await bulkDocumentOperation( + KNOWLEDGE_BASE_ID, + 'enable', + ['document-1', 'document-2'], + 'request-3' + ) + + expect(result.successCount).toBe(2) + expect(result.errors).toEqual([]) + expect(result.success).toBe(true) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 05b0f5af21a..71db7bef7d3 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -122,6 +122,7 @@ import { rebindKnowledgeDocumentSecretProvenance, replaceKnowledgeDocumentSecretProvenanceInTx, } from '@/lib/knowledge/secret-provenance' +import { assertTagSlotsAreDefined, writesTagSlots } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, parseBooleanValue, @@ -2183,6 +2184,14 @@ export async function createDocumentRecords( const documentRecords = [] const documentProvenances: (DurableSecretProvenance | undefined)[] = [] const returnData: DocumentData[] = [] + /** + * One representative value per slot the batch writes into, so the check + * below costs one definition read for the whole batch rather than one per + * document. Which document contributed a slot does not matter: the check + * asks only whether a definition covers the slot, and any document landing a + * value in an uncovered slot fails the batch. + */ + const batchTagSlotValues: Record = {} for (const [documentIndex, docData] of resolvedDocuments.entries()) { const documentId = generateId() @@ -2239,6 +2248,11 @@ export async function createDocumentRecords( boolean2: processedTags.boolean2 ?? null, boolean3: processedTags.boolean3 ?? null, } + for (const [key, value] of Object.entries(baseDocument)) { + if (value !== null && value !== undefined && batchTagSlotValues[key] === undefined) { + batchTagSlotValues[key] = value + } + } const source = createKnowledgeDocumentSourceValue(baseDocument) const binding = storageKey ? (bindingByKey.get(storageKey) ?? sourceBindingByKey.get(storageKey)) @@ -2275,6 +2289,14 @@ export async function createDocumentRecords( } if (documentRecords.length > 0) { + /** + * The same check the single-document insert makes, against the same + * locked snapshot: this batch reaches the identical columns from the + * identical caller-supplied slot values, so leaving it out here left the + * whole bulk upload path as a way around the guard. + */ + await assertTagSlotsAreDefined(knowledgeBaseId, batchTagSlotValues, tx) + await tx.insert(document).values(documentRecords) for (const [documentIndex, record] of documentRecords.entries()) { const provenance = documentProvenances[documentIndex] @@ -2699,6 +2721,19 @@ export async function createSingleDocument( await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + /** + * Checked against exactly the slot values about to be inserted, whatever + * produced them. Conditioning this on how the tags were supplied is what let + * two bypasses through: a `documentTagsData` payload that is truthy but not + * a JSON array leaves the caller's direct slot values in place while + * skipping the name-keyed resolution, and even a payload that does resolve + * was matched against definitions read before this transaction opened, so a + * tag deletion committing in between stranded the value the resolution had + * just approved. Both disappear once the check reads the same locked + * snapshot the insert writes into. + */ + await assertTagSlotsAreDefined(knowledgeBaseId, processedTags, tx) + const kb = await tx .select({ id: knowledgeBase.id, @@ -2916,6 +2951,7 @@ export async function bulkDocumentOperation( ): Promise<{ success: boolean successCount: number + errors: string[] updatedDocuments: Array<{ id: string enabled?: boolean @@ -2943,14 +2979,33 @@ export async function bulkDocumentOperation( ) ) - if (documentsToUpdate.length === 0) { - throw new OrchestrationError('not_found', 'No valid documents found to update') - } + /** + * One rule, matching {@link batchChunkOperation}: an id naming no updatable + * document in this knowledge base is reported in `errors[]` and never fails + * the request, so a caller can tell which ids it named were wrong instead of + * inferring it from the count. + * + * This is a report of what already happened, not a relaxation. The selection + * below has always applied to the documents it matched and dropped the rest — + * an unmatched id only ever produced the `logger.warn` this replaces — so + * there is no atomicity a caller could have been depending on. The one + * all-or-nothing case was an empty match, which answered `404` and discarded + * a request that had nothing to discard; a zero-match sweep now reports + * `successCount: 0` with the ids in `errors[]`, which is the same answer the + * one-match sweep beside it already gave. + */ + const matchedIds = new Set(documentsToUpdate.map(({ id }) => id)) + const unmatchedIds = documentIds.filter((documentId) => !matchedIds.has(documentId)) + const errors = + unmatchedIds.length > 0 + ? [`No matching documents found to ${operation}: ${unmatchedIds.join(', ')}`] + : [] - if (documentsToUpdate.length !== documentIds.length) { - logger.warn( - `[${requestId}] Some documents not found or don't belong to knowledge base. Requested: ${documentIds.length}, Found: ${documentsToUpdate.length}` + if (documentsToUpdate.length === 0) { + logger.info( + `[${requestId}] Bulk ${operation} operation matched no documents in knowledge base ${knowledgeBaseId}` ) + return { success: false, successCount: 0, errors, updatedDocuments: [] } } let updateResult: Array<{ @@ -2987,12 +3042,13 @@ export async function bulkDocumentOperation( const successCount = updateResult.length logger.info( - `[${requestId}] Bulk ${operation} operation completed: ${successCount} documents updated in knowledge base ${knowledgeBaseId}` + `[${requestId}] Bulk ${operation} operation completed: ${successCount} documents updated in knowledge base ${knowledgeBaseId}, ${errors.length} errors` ) return { - success: true, + success: errors.length === 0, successCount, + errors, updatedDocuments: updateResult, } } @@ -3005,6 +3061,12 @@ export async function bulkDocumentOperationByFilter( ): Promise<{ success: boolean successCount: number + /** + * Always empty. A filter selection names no identifier, so it has none that + * could have missed; the field is present so both bulk selections return one + * shape and a surface never has to branch on which produced the result. + */ + errors: string[] updatedDocuments: Array<{ id: string enabled?: boolean @@ -3064,6 +3126,7 @@ export async function bulkDocumentOperationByFilter( return { success: true, successCount, + errors: [], updatedDocuments: updateResult, } } @@ -3262,7 +3325,8 @@ export async function updateDocument( boolean2?: string boolean3?: string }, - requestId: string + requestId: string, + options?: { knowledgeBaseId?: string } ): Promise<{ id: string knowledgeBaseId: string @@ -3391,6 +3455,23 @@ export async function updateDocument( }) const doc = await db.transaction(async (tx) => { + /** + * Taken before anything else this transaction touches, and only when the + * update lands a nonempty tag slot. Tag deletion clears the slot and drops + * its definition under this same knowledge-base row lock, so without it the + * check below is check-then-act and a deletion can commit between the check + * and the write, stranding a value in a slot nothing can name. The lock is + * skipped for tag-free updates (processing status, filename, enabled) so + * they never serialize on the knowledge base, and every other writer in this + * area takes this lock first too, so the order is unchanged. + */ + if (options?.knowledgeBaseId && writesTagSlots(updateData)) { + await tx.execute( + sql`SELECT 1 FROM knowledge_base WHERE id = ${options.knowledgeBaseId} FOR UPDATE` + ) + await assertTagSlotsAreDefined(options.knowledgeBaseId, updateData, tx) + } + const hasTagUpdates = ALL_TAG_SLOTS.some((field) => typedUpdateData[field] !== undefined) if (hasTagUpdates) { diff --git a/apps/sim/lib/knowledge/documents/tag-slot-lock.test.ts b/apps/sim/lib/knowledge/documents/tag-slot-lock.test.ts new file mode 100644 index 00000000000..15321f37cff --- /dev/null +++ b/apps/sim/lib/knowledge/documents/tag-slot-lock.test.ts @@ -0,0 +1,293 @@ +/** + * @vitest-environment node + * + * Structural guard for the tag-slot check in `updateDocument`. + * + * The property under test is not an interleaving — a real race cannot be + * reproduced against a mocked driver — but the shape that makes the + * interleaving impossible: the check must run inside the write's transaction, + * after the knowledge-base row lock tag deletion also takes, and before the + * document row is written. Read outside that lock it is check-then-act, and a + * tag deletion committing in between strands a value in a slot no definition + * covers. + * + * The shared drizzle mock hands the transaction callback the same client as + * `db`, so "ran against the transaction" cannot be asserted by identity; call + * ordering relative to `db.transaction` is what pins it. + */ +import { document, knowledgeBase, knowledgeBaseTagDefinitions } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockApplyStorageUsageDeltasInTx, + mockCheckStorageQuota, + mockCheckStorageQuotaForBillingContext, + mockDecrementStorageUsageForBillingContextInTx, + mockIncrementStorageUsageForBillingContextInTx, + mockMaybeNotifyStorageLimitForBillingContext, + mockResolveStorageBillingContext, + mockGetFileMetadataByKeys, + mockEnqueueKnowledgeDocumentProcessing, +} = vi.hoisted(() => ({ + mockApplyStorageUsageDeltasInTx: vi.fn(), + mockCheckStorageQuota: vi.fn(), + mockCheckStorageQuotaForBillingContext: vi.fn(), + mockDecrementStorageUsageForBillingContextInTx: vi.fn(), + mockIncrementStorageUsageForBillingContextInTx: vi.fn(), + mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), + mockResolveStorageBillingContext: vi.fn(), + mockGetFileMetadataByKeys: vi.fn(), + mockEnqueueKnowledgeDocumentProcessing: vi.fn(), +})) + +vi.mock('@/lib/billing/storage', () => ({ + applyStorageUsageDeltasInTx: mockApplyStorageUsageDeltasInTx, + checkStorageQuota: mockCheckStorageQuota, + checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext, + decrementStorageUsageForBillingContextInTx: mockDecrementStorageUsageForBillingContextInTx, + incrementStorageUsageForBillingContextInTx: mockIncrementStorageUsageForBillingContextInTx, + maybeNotifyStorageLimitForBillingContext: mockMaybeNotifyStorageLimitForBillingContext, + resolveStorageBillingContext: mockResolveStorageBillingContext, +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + deleteFileMetadata: vi.fn(), + getFileMetadataByKeys: mockGetFileMetadataByKeys, +})) + +vi.mock('@/lib/knowledge/documents/processing-outbox-event', () => ({ + enqueueKnowledgeDocumentProcessing: mockEnqueueKnowledgeDocumentProcessing, +})) + +import { + createDocumentRecords, + createSingleDocument, + updateDocument, +} from '@/lib/knowledge/documents/service' + +const KNOWLEDGE_BASE_ID = 'kb-1' +const NOW = new Date('2026-01-01T00:00:00.000Z') + +/** invocationCallOrder of the first call to `spy` whose first argument is `table`. */ +function orderForTable( + spy: { mock: { calls: unknown[][]; invocationCallOrder: number[] } }, + table: unknown +): number { + for (let i = 0; i < spy.mock.calls.length; i++) { + if (spy.mock.calls[i][0] === table) return spy.mock.invocationCallOrder[i] + } + return -1 +} + +function definition(tagSlot: string, displayName: string) { + return { + id: `tag-def-${tagSlot}`, + knowledgeBaseId: KNOWLEDGE_BASE_ID, + tagSlot, + displayName, + fieldType: 'text', + createdAt: NOW, + updatedAt: NOW, + } +} + +describe('updateDocument tag-slot validation', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(document, [ + { id: 'doc-1', knowledgeBaseId: KNOWLEDGE_BASE_ID, secretProvenanceVersion: null }, + ]) + dbChainMockFns.returning.mockResolvedValue([ + { id: 'doc-1', knowledgeBaseId: KNOWLEDGE_BASE_ID, secretProvenanceVersion: null }, + ]) + }) + + it('checks the slot inside the write transaction, under the knowledge-base row lock, before writing', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await updateDocument('doc-1', { tag1: 'priority' }, 'req-1', { + knowledgeBaseId: KNOWLEDGE_BASE_ID, + }) + + const transactionOrder = dbChainMockFns.transaction.mock.invocationCallOrder[0] + const lockOrder = dbChainMockFns.execute.mock.invocationCallOrder[0] ?? -1 + const definitionReadOrder = orderForTable(dbChainMockFns.from, knowledgeBaseTagDefinitions) + const documentWriteOrder = orderForTable(dbChainMockFns.update, document) + + expect(transactionOrder).toBeGreaterThan(0) + expect(lockOrder).toBeGreaterThan(transactionOrder) + expect(definitionReadOrder).toBeGreaterThan(lockOrder) + expect(documentWriteOrder).toBeGreaterThan(definitionReadOrder) + }) + + it('refuses a slot no definition covers, and writes neither the document nor its embeddings', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + updateDocument('doc-1', { tag2: 'purple' }, 'req-1', { + knowledgeBaseId: KNOWLEDGE_BASE_ID, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('takes no knowledge-base lock for an update that lands no tag value', async () => { + await updateDocument('doc-1', { filename: 'renamed.txt' }, 'req-1', { + knowledgeBaseId: KNOWLEDGE_BASE_ID, + }) + + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + expect(orderForTable(dbChainMockFns.from, knowledgeBase)).toBe(-1) + expect(orderForTable(dbChainMockFns.update, document)).toBeGreaterThan(0) + }) + + it('takes no knowledge-base lock when clearing a slot, so a stranded value stays erasable', async () => { + await updateDocument('doc-1', { tag2: '' }, 'req-1', { knowledgeBaseId: KNOWLEDGE_BASE_ID }) + + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + expect(orderForTable(dbChainMockFns.update, document)).toBeGreaterThan(0) + }) + + it('treats a whitespace-only value as a write, because the writer stores it rather than clearing', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + updateDocument('doc-1', { tag2: ' ' }, 'req-1', { knowledgeBaseId: KNOWLEDGE_BASE_ID }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(dbChainMockFns.execute).toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) + +describe('createSingleDocument tag-slot validation', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValue([ + { id: KNOWLEDGE_BASE_ID, workspaceId: 'workspace-1', userId: 'knowledge-owner' }, + ]) + mockResolveStorageBillingContext.mockResolvedValue({ + workspaceId: 'workspace-1', + billedAccountUserId: 'workspace-owner', + billingEntity: { type: 'organization' as const, id: 'workspace-org' }, + plan: 'team_25000', + customStorageLimitGB: null, + }) + mockCheckStorageQuotaForBillingContext.mockResolvedValue({ allowed: true }) + mockIncrementStorageUsageForBillingContextInTx.mockResolvedValue(5) + mockApplyStorageUsageDeltasInTx.mockResolvedValue(undefined) + mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) + mockGetFileMetadataByKeys.mockResolvedValue([]) + mockEnqueueKnowledgeDocumentProcessing.mockResolvedValue('outbox-1') + }) + + const documentData = { + filename: 'note.txt', + fileUrl: 'data:text/plain;base64,SGVsbG8=', + fileSize: 5, + mimeType: 'text/plain', + } + + it('checks the slot inside the insert transaction, under the knowledge-base row lock', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await createSingleDocument({ ...documentData, tag1: 'priority' }, KNOWLEDGE_BASE_ID, 'req-1') + + const transactionOrder = dbChainMockFns.transaction.mock.invocationCallOrder[0] + const lockOrder = dbChainMockFns.execute.mock.invocationCallOrder[0] ?? -1 + const definitionReadOrder = orderForTable(dbChainMockFns.from, knowledgeBaseTagDefinitions) + const documentInsertOrder = orderForTable(dbChainMockFns.insert, document) + + expect(transactionOrder).toBeGreaterThan(0) + expect(lockOrder).toBeGreaterThan(transactionOrder) + expect(definitionReadOrder).toBeGreaterThan(lockOrder) + expect(documentInsertOrder).toBeGreaterThan(definitionReadOrder) + }) + + it('refuses a slot no definition covers, and inserts nothing', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + createSingleDocument({ ...documentData, tag2: 'purple' }, KNOWLEDGE_BASE_ID, 'req-1') + ).rejects.toMatchObject({ code: 'validation' }) + + expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1) + }) + + it('refuses a slot carried alongside a tags payload that is not a JSON array', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + createSingleDocument( + { ...documentData, tag2: 'purple', documentTagsData: '{"tagName":"colour"}' }, + KNOWLEDGE_BASE_ID, + 'req-1' + ) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1) + }) + + it('refuses a slot carried alongside a tags payload that is not valid JSON', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + createSingleDocument( + { ...documentData, tag2: 'purple', documentTagsData: 'not json at all' }, + KNOWLEDGE_BASE_ID, + 'req-1' + ) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1) + }) + + it('refuses a whitespace-only slot value, which the insert would store rather than clear', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + createSingleDocument({ ...documentData, tag2: ' ' }, KNOWLEDGE_BASE_ID, 'req-1') + ).rejects.toMatchObject({ code: 'validation' }) + + expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1) + }) + + describe('createDocumentRecords', () => { + it('checks the batch inside the insert transaction, under the knowledge-base row lock', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await createDocumentRecords( + [{ ...documentData, tag1: 'priority' }], + KNOWLEDGE_BASE_ID, + 'req-1' + ) + + const transactionOrder = dbChainMockFns.transaction.mock.invocationCallOrder[0] + const lockOrder = dbChainMockFns.execute.mock.invocationCallOrder[0] ?? -1 + const definitionReadOrder = orderForTable(dbChainMockFns.from, knowledgeBaseTagDefinitions) + const documentInsertOrder = orderForTable(dbChainMockFns.insert, document) + + expect(lockOrder).toBeGreaterThan(transactionOrder) + expect(definitionReadOrder).toBeGreaterThan(lockOrder) + expect(documentInsertOrder).toBeGreaterThan(definitionReadOrder) + }) + + it('refuses a batch whose slot no definition covers, and inserts nothing', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + createDocumentRecords( + [{ ...documentData }, { ...documentData, tag2: 'purple' }], + KNOWLEDGE_BASE_ID, + 'req-1' + ) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1) + }) + }) +}) diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index 5d94d736c57..ba94b080281 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -18,6 +18,7 @@ import { SUPPORTED_FIELD_TYPES, } from '@/lib/knowledge/constants' import type { BulkTagDefinitionsData, DocumentTagDefinition } from '@/lib/knowledge/tags/types' +import { buildUndefinedTagsError } from '@/lib/knowledge/tags/utils' import type { CreateTagDefinitionData, TagDefinition, @@ -203,6 +204,89 @@ export async function getNextAvailableSlot( /** * Get all tag definitions for a knowledge base */ +/** + * The slots a write would leave a value in. + * + * Only the exact empty value counts as clearing, because that is exactly what + * the writers treat as clearing: they map `''`, `null` and `undefined` to a + * null column and persist everything else as given. A value that merely looks + * empty is still stored — a whitespace-only text value is preserved verbatim, a + * whitespace-only number parses to `0`, and a whitespace-only boolean falls + * back to `false` — so classifying it as a clear would skip both the knowledge + * base's row lock and the definition check while still landing a value in a + * slot no definition covers. Matching the writers' own condition rather than a + * looser one is what keeps the two from drifting apart again. + */ +function collectWrittenTagSlots(slotValues: Record): string[] { + return Object.entries(slotValues) + .filter( + ([slot, value]) => + (VALID_TAG_SLOTS as readonly string[]).includes(slot) && + value !== undefined && + value !== null && + String(value) !== '' + ) + .map(([slot]) => slot) +} + +/** + * Whether a write touches a tag slot at all. + * + * A writer uses this to decide whether it needs the knowledge base's row lock: + * only a write that lands a slot value can race tag deletion, so a + * write that touches no slot must not take — or wait on — that lock. + */ +export function writesTagSlots(slotValues: Record): boolean { + return collectWrittenTagSlots(slotValues).length > 0 +} + +/** + * Refuses a slot-keyed tag write into a slot this knowledge base has not defined. + * + * Tag writes address slots (`tag1`..`tag7`) while tag reads and every filter + * address display names, and `GET /knowledge/{id}/tags` is the mapping between + * them. A value written into a slot with no definition has no name on either + * side of that mapping: the document list projects it under its raw slot name, + * the knowledge UI hides it outright (it renders only slots a definition + * covers), and a `tagFilters` entry naming either spelling is rejected because + * neither is a defined tag. The value is reachable by nothing, which is why + * this refuses the write rather than letting it land. + * + * Rejecting, not auto-creating: the sibling name-keyed write path already + * rejects an undefined name, so does every filter, and a slot write carries no + * display name a definition could be created from — inventing one called + * "tag2" would put a name the caller never chose into the knowledge base's + * vocabulary and into every subsequent read. The knowledge UI auto-creates a + * definition only because the user typed a name for it first. + * + * Clearing is always allowed. An empty value removes whatever a slot holds, + * including a value written before this guard existed, and needing a definition + * to erase state would strand exactly the rows this is meant to prevent. + * + * This is a read, so on its own it is only check-then-act: tag deletion clears + * the slot and drops its definition under the knowledge base's row lock (see + * {@link lockKnowledgeBaseForTagMutation}), and a deletion that commits between + * an unlocked read here and the write it guards lands exactly the stranded + * value this refuses. Callers must therefore pass `txDb` from the transaction + * that already took that row lock and that performs the write, so the check and + * the write are one step against a snapshot deletion cannot move. + */ +export async function assertTagSlotsAreDefined( + knowledgeBaseId: string, + slotValues: Record, + txDb?: DbOrTx +): Promise { + const writtenSlots = collectWrittenTagSlots(slotValues) + if (writtenSlots.length === 0) return + + const definitions = await getDocumentTagDefinitions(knowledgeBaseId, txDb) + const definedSlots = new Set(definitions.map((definition) => definition.tagSlot)) + const undefinedSlots = writtenSlots.filter((slot) => !definedSlots.has(slot)) + if (undefinedSlots.length === 0) return + + throw new OrchestrationError('validation', buildUndefinedTagsError(undefinedSlots)) +} + export async function getDocumentTagDefinitions( knowledgeBaseId: string, txDb?: DbOrTx diff --git a/apps/sim/lib/knowledge/tags/undefined-slot-write.test.ts b/apps/sim/lib/knowledge/tags/undefined-slot-write.test.ts new file mode 100644 index 00000000000..5ba6d8ef1a5 --- /dev/null +++ b/apps/sim/lib/knowledge/tags/undefined-slot-write.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ + +import { knowledgeBaseTagDefinitions } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { assertTagSlotsAreDefined, writesTagSlots } from '@/lib/knowledge/tags/service' + +const KNOWLEDGE_BASE_ID = 'kb-1' +const NOW = new Date('2026-01-01T00:00:00.000Z') + +function definition(tagSlot: string, displayName: string) { + return { + id: `tag-def-${tagSlot}`, + knowledgeBaseId: KNOWLEDGE_BASE_ID, + tagSlot, + displayName, + fieldType: 'text', + createdAt: NOW, + updatedAt: NOW, + } +} + +describe('assertTagSlotsAreDefined', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('refuses a value written into a slot the knowledge base has not defined', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + assertTagSlotsAreDefined(KNOWLEDGE_BASE_ID, { tag1: 'billing', tag2: 'purple' }) + ).rejects.toThrow( + 'The following tags are not defined in this knowledge base: "tag2". Please define them at the knowledge base level first.' + ) + }) + + it('rejects with the same validation code the tag-filter surface uses', async () => { + queueTableRows(knowledgeBaseTagDefinitions, []) + + await expect( + assertTagSlotsAreDefined(KNOWLEDGE_BASE_ID, { tag1: 'unicorn' }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(new OrchestrationError('validation', 'x').code).toBe('validation') + }) + + it('admits a value whose slot is defined', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + assertTagSlotsAreDefined(KNOWLEDGE_BASE_ID, { tag1: 'billing' }) + ).resolves.toBeUndefined() + }) + + it('admits clearing an undefined slot, so state written before the guard can be erased', async () => { + await expect( + assertTagSlotsAreDefined(KNOWLEDGE_BASE_ID, { tag2: '', tag3: null, tag4: undefined }) + ).resolves.toBeUndefined() + }) + + it('ignores non-slot keys carried alongside the tag values', async () => { + await expect( + assertTagSlotsAreDefined(KNOWLEDGE_BASE_ID, { filename: 'renamed.txt', enabled: true }) + ).resolves.toBeUndefined() + }) + + it('refuses a whitespace-only value, which every writer stores rather than clears', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')]) + + await expect( + assertTagSlotsAreDefined(KNOWLEDGE_BASE_ID, { tag2: ' ', number1: ' ', boolean1: '\t' }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) + +describe('writesTagSlots', () => { + it('counts only the exact empty value as clearing', () => { + expect(writesTagSlots({ tag1: '' })).toBe(false) + expect(writesTagSlots({ tag1: null, tag2: undefined })).toBe(false) + expect(writesTagSlots({ filename: 'renamed.txt' })).toBe(false) + }) + + it('counts a value that only looks empty as a write, since the writers preserve it', () => { + expect(writesTagSlots({ tag1: ' ' })).toBe(true) + expect(writesTagSlots({ number1: ' ' })).toBe(true) + expect(writesTagSlots({ boolean1: '\n' })).toBe(true) + }) +}) 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 4b5ba8013c1..6e6113b9060 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 @@ -324,6 +324,60 @@ describe('public log application use cases', () => { ) }) + /** + * The run id is the whole of the request — the route takes no workspace — so + * the only thing standing between a personal key and any run in the system is + * that the permission is resolved against the run's own workspace. Resolving + * it against anything the caller supplied, or skipping it once the key + * authenticates, turns a globally unique id into a global read. + */ + it('resolves the personal-key permission against the run workspace', async () => { + await getPublicLog.execute({ + principal: { kind: 'personal_api_key', userId: 'user-9', keyId: 'key-9' }, + input: { runId: 'run-1' }, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-9', + 'workspace-1', + null, + undefined, + expect.anything() + ) + }) + + it('rejects a personal key with no permission on the run workspace', async () => { + mocks.resolvePermission.mockResolvedValueOnce(null) + + await expect( + getPublicLog.execute({ + principal: { kind: 'personal_api_key', userId: 'user-9', keyId: 'key-9' }, + input: { runId: 'run-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.getLog).not.toHaveBeenCalled() + expect(mocks.materialize).not.toHaveBeenCalled() + }) + + /** + * A workspace that disallows personal keys must refuse them here too: the + * detail read is the one log route a personal key can reach without naming a + * workspace, so a gap in this branch is not covered by the list route's. + */ + it('rejects a personal key in a workspace that disallows them', async () => { + mocks.loadWorkspace.mockResolvedValueOnce({ ...workspaceContext, allowPersonalApiKeys: false }) + + await expect( + getPublicLog.execute({ + principal: { kind: 'personal_api_key', userId: 'user-9', keyId: 'key-9' }, + input: { runId: 'run-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.getLog).not.toHaveBeenCalled() + }) + it('rejects a workspace key outside the run workspace before materialization', async () => { await expect( getPublicLog.execute({ diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts index b2253f9c069..b70f214bf3a 100644 --- a/apps/sim/lib/secrets/application/use-cases.test.ts +++ b/apps/sim/lib/secrets/application/use-cases.test.ts @@ -5,6 +5,7 @@ import type { Principal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DeleteSecretInput, + ListSecretsInput, ListSecretUsageInput, SetSecretInput, } from '@/lib/secrets/application/use-cases' @@ -193,6 +194,64 @@ describe('secret application use cases', () => { expect(result.values).toEqual({}) }) + /** + * The list's cross-user boundary is one SQL predicate — `ownedEnvSecretsOnly` — and nothing + * else keeps another user's `env_personal` row out of the page. Dropping it would leave every + * other assertion in this file green, because the query is mocked and its canned rows prove + * nothing about what was asked for. So the pin is on the composed argument, per scope. + */ + it.each([ + ['personal' as const, ['env_personal']], + ['workspace' as const, ['env_workspace']], + [undefined, ['env_workspace', 'env_personal']], + ])("restricts the list to the caller's own secrets for scope %s", async (scope, types) => { + await listSecretsUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + scope, + sortBy: 'name', + sortOrder: 'asc', + limit: 50, + }, + }) + + expect(mocks.listCredentials).toHaveBeenCalledTimes(1) + const [args] = mocks.listCredentials.mock.calls[0] + expect(args.ownedEnvSecretsOnly).toBe(true) + expect(args.types).toEqual(types) + /** Taken from the principal, never from caller-supplied input. */ + expect(args.userId).toBe(session.userId) + expect(args.workspaceId).toBe(workspace.workspaceId) + }) + + it('never reads secret metadata for a workspace API key, which has no personal identity', async () => { + const execute = listSecretsUseCase.execute as (args: { + principal: Principal + input: ListSecretsInput + }) => Promise + + await expect( + execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: workspace.workspaceId, + keyId: 'workspace-key-1', + }, + input: { + workspaceId: workspace.workspaceId, + scope: 'personal', + sortBy: 'name', + sortOrder: 'asc', + limit: 50, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + it('rejects workspace keys before resolving or reading secret state', async () => { const execute = setSecretUseCase.execute as (args: { principal: Principal diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index f4150e16c87..4b6b1a59145 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -66,6 +66,24 @@ function credentialTypes(scope?: SecretScope) { * The secret's public `name` is stored as the credential `displayName`, so the * caller-facing `name` sort aliases to that column here. The alias must not * escape into the cursor's sort stamp — see {@link listSecretsUseCase}. + * + * KNOWN DIVERGENCE — personal scope is workspace-bound here and nowhere else. + * A personal secret's value is user-global (`environment.variables`, keyed by + * user alone), so {@link setSecretUseCase} and {@link deleteSecretUseCase} both + * act on it without reference to a workspace. This read cannot: `credential` + * carries a NOT NULL `workspaceId`, so a personal secret has no canonical + * metadata row — only per-workspace mirrors, written for the workspaces + * `getUserWorkspaceIds` returns (explicit `permissions` rows plus owned + * workspaces). That set is NARROWER than the set of workspaces a caller can + * authorize into, which also includes inherited organization access. In such a + * workspace there is no mirror, so this returns nothing while `set` and + * `delete` still work — the caller can create a secret they cannot list. + * + * {@link getPersonalSecretMetadata} already works around the same hole for the + * write's response projection. Closing it for reads is a storage change, not a + * scoping change: personal-secret metadata needs one canonical home (a nullable + * `credential.workspaceId`, or a dedicated table) before this query can drop the + * workspace predicate without double-counting mirrors or inventing timestamps. */ async function listSecretMetadata(params: { workspaceId: string diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts index 8b6963f7382..178a24b28b8 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts @@ -116,6 +116,38 @@ describe('workspace file folder paths', () => { ) }) + it('rejects interior control characters that would render differently than they are stored', () => { + expect(() => normalizeWorkspaceFileItemName('a\tb.txt', 'File')).toThrow( + 'File name cannot contain control or line-separator characters' + ) + expect(() => normalizeWorkspaceFileItemName('a\u0000b', 'Folder')).toThrow( + 'Folder name cannot contain control or line-separator characters' + ) + expect(() => normalizeWorkspaceFileItemName('a\u007fb', 'File')).toThrow( + 'File name cannot contain control or line-separator characters' + ) + expect(normalizeWorkspaceFileItemName('\treport.txt\n', 'File')).toBe('report.txt') + expect(normalizeWorkspaceFileItemName('Q3 report — final.txt', 'File')).toBe( + 'Q3 report — final.txt' + ) + }) + + /** + * The two Unicode line separators and the C1 controls sanitize out of the + * storage key exactly like a C0 control does, and a terminal renders + * U+2028/U+2029 as a line break, so a name carrying one is displayed split + * across two rows while the key holds a single `_`. + */ + it.each([ + ['a line separator', 'a\u2028b.txt'], + ['a paragraph separator', 'a\u2029b.txt'], + ['a C1 control', 'a\u0085b.txt'], + ])('rejects %s the storage key would strip', (_label, name) => { + expect(() => normalizeWorkspaceFileItemName(name, 'File')).toThrow( + 'File name cannot contain control or line-separator characters' + ) + }) + it('rejects oversized ensured paths before persisting any folders', async () => { await expect( ensureWorkspaceFileFolderPath({ diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 4ec715d2474..a3314096e8a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -203,6 +203,27 @@ export interface WorkspaceFileFolderRestoreResult { restoredItems: WorkspaceFileArchiveResult } +/** + * Every C0 and C1 control character, plus U+2028 and U+2029. + * + * `trim()` already drops these at the edges, so this catches the interior ones — + * a tab inside a name renders as a space everywhere the name is displayed and is + * sanitized out of the storage key, so the stored name and every rendering of it + * disagree with no way for a caller to tell. + * + * The two Unicode line separators are in the set for exactly that reason and are + * the reason C1 is here too: `sanitizeFileName` maps everything outside + * `[A-Za-z0-9.-]` to `_`, so all of them vanish from the key, while a terminal + * listing renders U+2028/U+2029 as a line break and splits one name across two + * rows. The CLI's config writer forbids the same set (`FORBIDDEN_IN_VALUE` in + * `packages/sim-cli/src/config/ini.ts`) for a different reason — those + * characters end a line in a format with no escape syntax — so the two stay + * separate constants rather than one shared one: a CLI serializer's + * structure-injection rule and this layer's display/storage agreement rule would + * be free to diverge, and neither package should own the other's set. + */ +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/ + export function normalizeWorkspaceFileItemName(name: string, itemLabel: 'File' | 'Folder'): string { const trimmed = name.trim() if (!trimmed) { @@ -211,6 +232,9 @@ export function normalizeWorkspaceFileItemName(name: string, itemLabel: 'File' | if (trimmed === '.' || trimmed === '..' || trimmed.includes('/') || trimmed.includes('\\')) { throw new Error(`${itemLabel} name cannot contain path separators or dot segments`) } + if (CONTROL_CHARACTERS.test(trimmed)) { + throw new Error(`${itemLabel} name cannot contain control or line-separator characters`) + } return trimmed } diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts index 0cba17c8c09..9f7a66fda36 100644 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -100,6 +100,9 @@ function written(spy: WriteSpy): string { return spy.mock.calls.map((call) => String(call[0])).join('') } +/** A conversation id in the shape the route accepts and the command prints. */ +const CONVERSATION_ID = '3f2a1c4e-0000-4000-8000-000000000000' + const FINAL = { type: 'final', data: { content: 'Hello there', conversationId: 'conv-1', model: 'sim' }, @@ -127,17 +130,35 @@ describe('sim chat', () => { expect(written(stderr)).toContain('conversation: conv-1') }) + /** + * The route's own refusals name `message` and `conversationId`, and this + * command builds its request by hand so nothing retypes them into what the + * caller typed. A blank `-c` was worse than misnamed: it is falsy, so it was + * dropped from the body and silently started a NEW conversation. + */ + it('refuses a blank message and a malformed -c before the request', async () => { + await expect(run(' ')).rejects.toThrow(' cannot be empty') + await expect(run('-c', '', 'hello')).rejects.toThrow('-c/--conversation must be a') + await expect(run('-c', 'conv-1', 'hello')).rejects.toThrow('-c/--conversation must be a') + expect(requestRaw).not.toHaveBeenCalled() + }) + + /** + * A conversation id as the command prints it: the route requires a UUID and + * the CLI now says so before the request, so a stand-in like `conv-1` is + * refused rather than sent. + */ it('passes -c through as the conversation to continue', async () => { requestRaw.mockResolvedValue(ndjson([FINAL])) - await run('-c', 'conv-1', 'And which run on a schedule?') + await run('-c', CONVERSATION_ID, 'And which run on a schedule?') expect(requestRaw).toHaveBeenCalledWith('/api/v2/chat', { method: 'POST', body: { workspaceId: 'ws_local', message: 'And which run on a schedule?', - conversationId: 'conv-1', + conversationId: CONVERSATION_ID, }, headers: { accept: 'application/x-ndjson' }, }) diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts index 58878c3fc2f..a6ea56d449b 100644 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -142,6 +142,12 @@ function ignoreBrokenPipe(stream: NodeJS.WriteStream): () => void { * proxies from idling the connection out. The generated `chat` operation is * hidden in the CLI contract in favour of this command. */ +/** + * The shape the route accepts for `conversationId`: a UUID, which is what the + * command prints on stderr at the end of every turn. + */ +const CONVERSATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + export function attachChat(program: Command): void { program .command('chat') @@ -164,6 +170,25 @@ Examples: ` ) .action(async (message: string, options: ChatOptions, command: Command) => { + /** + * Refused here so the refusal names what the caller typed. The route says + * `message cannot be empty` and `conversationId must be a valid + * conversation id` — its own field names, and this command builds its + * request by hand so nothing retypes them into `` and + * `-c/--conversation`. A blank `-c` was worse than misnamed: it is falsy, + * so it was dropped from the body and silently started a NEW conversation + * instead of continuing one. + */ + if (message.trim() === '') { + throw new SimApiError(' cannot be empty', 0) + } + if (options.conversation !== undefined && !CONVERSATION_ID.test(options.conversation)) { + throw new SimApiError( + '-c/--conversation must be a conversation id — the one printed on stderr after each turn', + 0 + ) + } + const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 3204182a2f5..887161cd5ed 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -213,6 +213,41 @@ describe('knowledge documents upload', () => { expect(mockRequest).not.toHaveBeenCalled() }) + /** + * The route enforces both, and neither said so: the help read "Document + * processing recipe" / "Document language code" and the CLI uploaded the file + * before the server refused the value. Every other constrained flag in this + * CLI uses commander `choices`. + */ + it('states what --recipe and --lang accept, and refuses a recipe before uploading', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + + const help = program() + .commands.find((command) => command.name() === 'knowledge') + ?.commands.find((command) => command.name() === 'documents') + ?.commands.find((command) => command.name() === 'upload') + ?.helpInformation() + .replace(/\s+/g, ' ') + expect(help).toContain('choices: "default", "plain", "markdown", "code"') + expect(help).toContain('hyphen-separated letter and digit subtags') + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'kb', + 'documents', + 'upload', + 'kb_1', + path, + '--recipe', + 'super-chunker-9000', + ]) + ).rejects.toThrow(/Allowed choices are default, plain, markdown, code/) + expect(mockRequest).not.toHaveBeenCalled() + }) + it('requires the knowledge-base argument before reading the file', async () => { const path = join(dir, 'notes.txt') writeFileSync(path, 'hello') diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index d9f02e9e85e..eab8f17b665 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -1,4 +1,4 @@ -import type { Command } from 'commander' +import { type Command, Option } from 'commander' import { clientFrom } from '../../context' import type { CompleteKnowledgeDocumentUploadResponse, @@ -35,6 +35,21 @@ function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record', 'Store it under a different name') .option('--tag ', 'Document tags, in tag1 through tag7 order') - .option('--recipe ', 'Document processing recipe') - .option('--lang ', 'Document language code') + .addOption(new Option('--recipe ', 'Document processing recipe').choices(UPLOAD_RECIPES)) + .option('--lang ', LANGUAGE_TAG_HELP) .action( async ( knowledgeBaseId: string, diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts index 871fef9373d..b727c0cd95e 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts @@ -454,7 +454,7 @@ describe('sim logs follow', () => { it('rejects a backlog count that is not a whole number of runs', async () => { respondWith([]) - await expect(follow('-n', '-1')).rejects.toThrow('--lines must be a non-negative integer') + await expect(follow('-n', '-1')).rejects.toThrow('--lines must be a whole number of 0 or more') expect(mockRequest).not.toHaveBeenCalled() }) @@ -517,4 +517,27 @@ describe('sim logs follow', () => { expect(ms).toBeGreaterThan(0) } }) + /** + * Root help states that a `wf_` prefix marks a FILE id and "never names a + * workflow", so the example told the reader to pass a file id to + * `--workflow`. Workflow ids are bare UUIDs. + */ + it('does not illustrate --workflow with a file-id prefix', () => { + const root = new Command('sim').exitOverride() + const logs = new Command('logs').exitOverride() + root.addCommand(logs) + attachLogsFollow(logs) + // `helpInformation()` omits `addHelpText('after')`, which is where the + // examples live, so the help is captured as the command would print it. + let help = '' + logs.commands[0].configureOutput({ + writeOut: (text) => { + help += text + }, + }) + logs.commands[0].outputHelp() + + expect(help).not.toContain('wf_') + expect(help).toMatch(/--workflow [0-9a-f]{8}-[0-9a-f]{4}-/) + }) }) diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.ts b/packages/sim-cli/src/commands/protocol/logs-follow.ts index 330cc86011b..1fddeadcc2e 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.ts @@ -467,7 +467,7 @@ function isTransient(error: unknown): boolean { function nonNegativeInteger(raw: string, flag: string): number { const value = Number(raw) if (!Number.isSafeInteger(value) || value < 0) { - throw new SimApiError(`${flag} must be a non-negative integer`, 0) + throw new SimApiError(`${flag} must be a whole number of 0 or more`, 0) } return value } @@ -531,7 +531,7 @@ follow. Examples: $ sim logs follow --level error - $ sim logs follow --workflow wf_123 -n 0 + $ sim logs follow --workflow 00000000-0000-4000-8000-000000000000 -n 0 $ sim --output json logs follow | jq -r '.runId' ` ) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index eb1bb94f0d2..b0d0332bb94 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -216,6 +216,42 @@ describe('resource directory', () => { expect(entries.find((entry) => entry.kind === 'table')?.ref).toBe('tbl_1') }) + /** + * `files list` announces "showing the first N" off the surviving cursor and + * `ls` printed the same capped answer with nothing on stderr, so one command + * presented an incomplete listing as complete and its neighbour did not. + */ + it('says the combined listing was capped, as the contract-driven list does', async () => { + mockRequest.mockImplementation( + async (path: string, options: { query: { cursor?: string } }) => { + if (path === '/api/v2/files/folders') return { data: [] } + const cursor = Number(options.query.cursor ?? 0) + return { + data: Array.from({ length: 100 }, (_, index) => ({ + id: `file_${cursor}_${index}`, + name: `file-${cursor}-${index}`, + folderPath: '/', + updatedAt: '2026-01-01T00:00:00.000Z', + })), + nextCursor: cursor < 2 ? String(cursor + 1) : null, + } + } + ) + const written: string[] = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + written.push(String(chunk)) + return true + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'files', 'ls', '--limit', '5']) + expect(written.join('')).toContain('showing the first 5') + + written.length = 0 + await program().parseAsync(['node', 'sim', 'files', 'ls', '--limit', '0']) + expect(written.join('')).not.toContain('showing the first') + }) + it('rejects extra directory arguments instead of silently ignoring them', async () => { await expect( program().parseAsync(['node', 'sim', 'file', 'ls', 'Reports', 'ignored']) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index 8fef45070e8..f334ec29e9f 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -12,11 +12,11 @@ import { V2_OPERATIONS, type V2OperationName, } from '../../generated/v2-api' -import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client' +import { requestPages, SimApiError, type SimClient, type V2Page } from '../../http/client' import { type Column, printList, text, timestamp } from '../../output/render' import { DEFAULT_LIMIT } from '../../runtime/options' import { encodeFolderPath } from '../../runtime/request' -import { decodeFolderPath, renderResult } from '../../runtime/result' +import { decodeFolderPath, renderResult, writeCursorTruncation } from '../../runtime/result' type FolderListOperation = | 'listFileFolders' @@ -103,17 +103,17 @@ async function listResources( folderPath: string, search: string | undefined, limit: number -): Promise { +): Promise<{ items: DirectoryResource[]; truncated: boolean }> { const query = { workspaceId, folderPath, search, sortBy: 'name', sortOrder: 'asc' } const path = operationPath(config.resources) const paginated = 'cursor' in V2_OPERATIONS[config.resources].query if (!paginated) { const page = await client.request>(path, { query }) - return page.data.slice(0, limit) + return { items: page.data.slice(0, limit), truncated: page.data.length > limit } } - return requestAllPages(client, path, { + return requestPages(client, path, { query, pageSize: DEFAULT_LIMIT, limit, @@ -177,7 +177,7 @@ export function attachResourceDirectoryCommands( .action(async (path: string | undefined, options: ListOptions, command: Command) => { const rawLimit = Number(options.limit) if (!Number.isSafeInteger(rawLimit) || rawLimit < 0) { - throw new SimApiError('--limit must be a non-negative integer', 0) + throw new SimApiError('--limit must be a whole number of 0 or more (0 for everything)', 0) } const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit @@ -191,8 +191,13 @@ export function attachResourceDirectoryCommands( listFolders(client, config.folders, workspaceId, folderPath, options.search), listResources(client, config, workspaceId, folderPath, options.search, limit), ]) - const entries = entriesFor(config, folders, resources) - printList(profile.output, entries.slice(0, limit), COLUMNS) + const entries = entriesFor(config, folders, resources.items) + const shown = entries.slice(0, limit) + // Said here for the same reason the contract-driven `list` says it: the + // combined listing is capped after the merge, so a full page of folders + // can clip the resources even when the resource walk itself finished. + writeCursorTruncation(shown.length, resources.truncated || entries.length > limit) + printList(profile.output, shown, COLUMNS) }) group diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index 73486d2f0b1..386a861be88 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -419,6 +419,19 @@ describe('list columns', () => { expect(columns[columns.length - 1]).toBe(unredacted) }) + /** + * A custom tool has both a `title` and a `schema.function.name`, and they are + * different fields — a column headed `name` showing the title named the other + * one, while `--search` and `--sort-by title` both speak of the title. + */ + it('heads the custom-tool column with the field it actually shows', () => { + const columns = CLI_CONTRACT.listCustomTools?.columns ?? [] + const titled = columns.find((column) => (column.path ?? column.header) === 'title') + + expect(titled?.header).toBe('title') + expect(columns.some((column) => column.header === 'name')).toBe(false) + }) + it('keeps the workflow-MCP listings scannable', () => { const servers = CLI_CONTRACT.listWorkflowMcpServers?.columns ?? [] const tools = CLI_CONTRACT.listWorkflowMcpTools?.columns ?? [] @@ -559,6 +572,52 @@ describe('help and gates state what is actually true', () => { expect(help).toContain('RUN_ID_CONFLICT') }) + /** + * `logs stats` returns `totalRuns`, `totalErrors`, `avgLatency`, `segmentMs`, + * `timeBounds` and `workflows[]` — and no cost field anywhere. Cost lives in + * the billing ledger, so the describe promised a column the command has never + * been able to print. + */ + it('does not promise a figure logs stats never returns', () => { + expect(CLI_CONTRACT.getLogStats?.describe ?? '').not.toMatch(/cost/i) + }) + + /** + * A workspace API key genuinely sees every member's events, but no row + * carries an actor field, so the broader scope arrives unattributable. + */ + it('says the workspace-key ledger arrives unattributed', () => { + expect(CLI_CONTRACT.listBillingLogs?.describe ?? '').toContain('unattributed') + }) + + /** + * Only `extract_from_subflow` takes `subflowId` alone; `insert_into_subflow` + * creates a block, so it needs an `add`'s `type` and `name` as well. + */ + it('does not lump the two subflow operations into one parameter shape', () => { + const help = flatHelp('workflows', 'operations', 'apply') + + expect(help).toContain('extract_from_subflow, whose params carry') + expect(help).toMatch(/insert_into_subflow, which creates a block/) + }) + + /** + * None of the four folder lists paginates — the route declares no cursor and + * answers with the whole set — and nothing in the terminal said so, next to a + * `--limit` on every other `list`. + */ + it('says the folder lists return the whole set', () => { + for (const operation of [ + 'listFileFolders', + 'listKnowledgeFolders', + 'listTableFolders', + 'listWorkflowFolders', + ] as const) { + expect(CLI_CONTRACT[operation]?.describe ?? '').toContain('whole set') + expect(V2_OPERATIONS[operation].query).not.toHaveProperty('cursor') + } + }) + it('warns about the chunk batch in terms true of every operation it accepts', () => { // `--operation` takes enable, disable, or delete. The first two are // reversible and destroy nothing, so a gate message promising a possible @@ -725,9 +784,7 @@ describe('the import cancel refuses through commander, not just in the contract' }) it('offers --yes in the help of the one it now gates, and not its sibling', () => { - expect(flatHelp('tables', 'imports', 'cancel')).toContain( - 'Confirm this destructive operation (required)' - ) + expect(flatHelp('tables', 'imports', 'cancel')).toContain('Confirm this operation (required)') expect(flatHelp('tables', 'exports', 'cancel')).not.toContain('--yes') }) }) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c423c397926..7e0b8b997f2 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -22,7 +22,7 @@ const DISPATCH_ROW_LIMIT_HELP = * shape guessable, the same way `TABLE_FILTER_HELP` does for the predicate. */ const WORKFLOW_OPERATIONS_HELP = - 'Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":""}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":""}' + 'Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":""}]. Also extract_from_subflow, whose params carry {"subflowId":""}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId' const WORKFLOW_SET_BLOCK_ENABLED_HELP = 'Blocks to enable or disable, applied after --operations: [{"block_id":"","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined' const WORKFLOW_VARIABLE_OPERATIONS_HELP = @@ -37,6 +37,10 @@ const MCP_PARAMETER_DESCRIPTIONS_HELP = * rather than on each of the thirty-odd fields, because one that was missed * would silently be the only place `/Folder 1` is still rejected. */ +/** Shared because the whole-set behaviour is a property of all four routes. */ +const FOLDER_LIST_DESCRIBE = + 'List folders; returns the whole set, so there is no --limit and no paging' + const FOLDER_PATH_INPUT = { describe: 'Folder path as shown in the app; the leading / is optional', folderPath: true, @@ -156,7 +160,7 @@ export const CLI_CONTRACT: CliContract = { // `billing status` says its own caveat. The trailing parenthetical is what // keeps the generated docs heading unchanged. describe: - "List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's)", + "List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed)", flags: { source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, period: { describe: 'Billing period' }, @@ -423,7 +427,7 @@ export const CLI_CONTRACT: CliContract = { }, getLogStats: { command: 'logs stats', - describe: 'Summarize run counts, failures, and cost over a window', + describe: 'Summarize run counts, failures and latency over a window', flags: LOG_LIST_FILTER_FLAGS, // Undeclared, the summary fell through to the generic key dump: the whole // `workflows` series printed as one truncated line of raw JSON, the window @@ -531,6 +535,15 @@ export const CLI_CONTRACT: CliContract = { confirm: 'This changes which deployed version runs in production for every API and chat consumer.', }, + // The same cutover as a rollback, addressed by explicit version instead of by + // "the previous one". `deploy` is left ungated: it publishes the draft as a + // NEW version, which is the forward action the caller just asked for and is + // undone by a rollback. Naming an existing version is the one that silently + // swings live traffic to a graph the caller may not have looked at. + activateWorkflowVersion: { + confirm: + 'This changes which deployed version runs in production for every API and chat consumer.', + }, // POST derives to `... create`, which creates nothing here. Named for the // operation instead, matching the shipped `files move`. moveWorkflows: { @@ -829,7 +842,11 @@ export const CLI_CONTRACT: CliContract = { listCustomTools: { columns: [ { header: 'id' }, - { header: 'name', path: 'title' }, + // `title` and `schema.function.name` are both real and different fields + // on this resource — the flags say `--search` matches the title and + // `--sort-by title` orders by it — so a column headed `name` showing the + // title named the other one. + { header: 'title', path: 'title' }, { header: 'description', path: 'schema.function.description' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], @@ -1132,7 +1149,15 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Resource-scoped, path-addressed folders ────────────────────────────── + /** + * None of the four folder lists paginates: the route declares no `cursor` and + * answers with the whole set. That is deliberate — a folder tree is bounded + * where it loads — but the terminal said nothing about it, and a caller + * reading `--limit` on every other `list` had no way to tell whether the + * answer was the full set or the first page of one. + */ listFileFolders: { + describe: FOLDER_LIST_DESCRIBE, aliases: ['ls'], flags: { parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, @@ -1140,6 +1165,7 @@ export const CLI_CONTRACT: CliContract = { columns: FOLDER_LIST_COLUMNS, }, listKnowledgeFolders: { + describe: FOLDER_LIST_DESCRIBE, aliases: ['ls'], flags: { parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, @@ -1147,6 +1173,7 @@ export const CLI_CONTRACT: CliContract = { columns: FOLDER_LIST_COLUMNS, }, listTableFolders: { + describe: FOLDER_LIST_DESCRIBE, aliases: ['ls'], flags: { parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, @@ -1154,6 +1181,7 @@ export const CLI_CONTRACT: CliContract = { columns: FOLDER_LIST_COLUMNS, }, listWorkflowFolders: { + describe: FOLDER_LIST_DESCRIBE, aliases: ['ls'], flags: { parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index d623c667e6f..9d67a0e4076 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -752,7 +752,8 @@ export type BulkUpdateKnowledgeDocumentsBody = { type BulkUpdateKnowledgeDocumentsResponseRef0 = { operation: 'enable' | 'disable' - updatedCount: number + processed: number + errors: Array documentIds?: Array } @@ -10810,7 +10811,7 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', describe: - '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.', + 'Workspace whose payer should be resolved. Omitting it selects account scope — the payer behind the calling *account*, across every workspace — and only a personal API key has an account to select. A workspace API key that omits it still resolves its own workspace, because omission is the same request as sending that key its own id; it is not a way to widen a workspace key. The response `workspaceId` reports which was resolved and is `null` only on account scope. 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.', }, }, }, @@ -10862,7 +10863,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, default: 'active', describe: - 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.', + 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file soft-deleted by a delete; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.', }, }, }, @@ -10903,7 +10904,10 @@ export const V2_OPERATIONS = { method: 'GET', path: '/api/v2/knowledge/[knowledgeBaseId]', pathParams: ['knowledgeBaseId'] as const, - pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, + pathParamDocs: { + knowledgeBaseId: + 'Knowledge base to read. Active knowledge bases only: an archived one answers 404 here, is listed by `scope=archived`, and is brought back by the restore endpoint.', + }, responseMode: 'json', summary: 'Get Knowledge Base', query: { @@ -10975,7 +10979,10 @@ export const V2_OPERATIONS = { method: 'GET', path: '/api/v2/logs/[runId]', pathParams: ['runId'] as const, - pathParamDocs: { runId: 'Unique workflow run identifier.' }, + pathParamDocs: { + runId: + 'Unique workflow run identifier. A run is addressed globally by this id: unlike the list and statistics routes, this route takes no workspace. The run carries its own workspace and the caller is authorized against that one, so a run the caller cannot reach is concealed as a 404 rather than filtered out.', + }, responseMode: 'json', summary: 'Get Log', }, @@ -11429,7 +11436,7 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', describe: - "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. 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.", + "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. Omitting it does not widen a workspace API key: that key has no account behind it, so an omitted id is the same request as its own id and the page still covers exactly one workspace, reported as `scope: workspace`. A ledger spanning every workspace an account touches requires a personal API key. 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.", }, period: { kind: 'enum', @@ -11726,7 +11733,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, 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 the folder restore. Authorization is identical for both.', + 'Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders soft-deleted by a recursive delete, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.', }, }, }, @@ -11771,7 +11778,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, default: 'active', describe: - 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files soft-deleted by a delete, which a 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.', }, search: { kind: 'string', @@ -11820,7 +11827,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, 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.', + 'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.', }, folderPath: { kind: 'string', @@ -12311,7 +12318,8 @@ export const V2_OPERATIONS = { scope: { kind: 'enum', values: ['workspace', 'personal'] as const, - describe: 'Restrict results to one ownership scope.', + describe: + "Restrict results to one ownership scope. Personal results are not the caller's full personal set: this list reads the per-workspace credential mirrors of a personal secret, and a mirror exists only for workspaces the caller holds an explicit membership or ownership of. A personal secret is therefore omitted here when the caller reaches this workspace through inherited organization access, even though the same secret can be set and deleted from it. Prefer listing from a workspace the caller is an explicit member of until the mirrors are replaced by canonical personal-secret metadata.", }, search: { kind: 'string', @@ -12509,7 +12517,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, default: 'active', describe: - 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table 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.', + 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.', }, folderPath: { kind: 'string', @@ -12764,7 +12772,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, default: 'active', describe: - 'Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.', + 'Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows archived by a delete, which a restore can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.', }, folderPath: { kind: 'string', diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 6268dd379ad..1cbd55885fc 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -359,6 +359,19 @@ function traceRequest(method: string, url: string, status: number | string, star ) } +/** + * Drops a label the message already opens with. + * + * Some routes name the field inside the message as well as in `path`, and the + * two are printed one after the other: `sortBy: only \"startedAt\" can order job + * runs` under `path: ['sortBy']` came out as `--sort-by: --sort-by: only …` + * once the wire name had been retyped as the flag. + */ +function withoutLeadingLabel(message: string, label: string): string { + const prefix = `${label}: ` + return message.startsWith(prefix) ? message.slice(prefix.length) : message +} + /** Formats nested validation issues as readable, path-aware lines. */ export function formatApiErrorDetails(details: unknown): string[] { const issues: DetailIssue[] = [] @@ -402,9 +415,10 @@ export function formatApiErrorDetails(details: unknown): string[] { const visible = kept.slice(0, 8) const lines = [ ' details:', - ...visible.map( - (issue) => ` ${issue.path.length > 0 ? issue.path.join('.') : 'request'}: ${issue.message}` - ), + ...visible.map((issue) => { + const label = issue.path.length > 0 ? issue.path.join('.') : 'request' + return ` ${label}: ${withoutLeadingLabel(issue.message, label)}` + }), ] if (kept.length > visible.length) lines.push(` … ${kept.length - visible.length} more issues`) return lines @@ -644,9 +658,25 @@ export async function requestAllPages( path: string, options: RequestAllPagesOptions ): Promise { + return (await requestPages(client, path, options)).items +} + +/** + * The same walk, also stating whether it stopped short. + * + * A caller that prints the rows itself has to say so — `files list` announces + * "showing the first N" off the surviving cursor and `files ls` did not, so the + * same capped answer looked complete on one command and incomplete on its + * neighbour. + */ +export async function requestPages( + client: Pick, + path: string, + options: RequestAllPagesOptions +): Promise<{ items: T[]; truncated: boolean }> { const { query, pageSize, limit: requestedLimit, ...requestOptions } = options const limit = requestedLimit ?? Number.POSITIVE_INFINITY - if (limit <= 0) return [] + if (limit <= 0) return { items: [], truncated: false } const items: T[] = [] const progress = pageProgress() @@ -673,7 +703,7 @@ export async function requestAllPages( progress.finish() } - return items.slice(0, limit) + return { items: items.slice(0, limit), truncated: cursor !== null || items.length > limit } } /** diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index b0a42c10148..174b791451c 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -113,7 +113,12 @@ async function run(argv: string[], response: unknown = { data: [], nextCursor: n mockRequest.mockResolvedValue(response) vi.spyOn(console, 'log').mockImplementation(() => {}) await program().parseAsync(['node', 'sim', ...argv]) - return mockRequest.mock.calls[0] + // `--all-workspaces` asks `/api/v2/meta` whether the key can make an + // account-wide read before it makes one, so the operation's own call is not + // always the first. + const call = mockRequest.mock.calls.find(([path]) => path !== V2_OPERATIONS.getMeta.path) + if (!call) throw new Error('the command made no request of its own') + return call } describe('commands parsed through commander', () => { @@ -173,14 +178,12 @@ describe('commands parsed through commander', () => { .replace(/\s+/g, ' ') expect(flat('workflows', 'state', 'replace')).toContain( - 'Confirm this destructive operation (required unless --dry-run)' + 'Confirm this operation (required unless --dry-run)' ) expect(flat('workflows', 'operations', 'apply')).toContain( - 'Confirm this destructive operation (required unless --dry-run)' - ) - expect(flat('tables', 'rows', 'delete')).toContain( - 'Confirm this destructive operation (required)' + 'Confirm this operation (required unless --dry-run)' ) + expect(flat('tables', 'rows', 'delete')).toContain('Confirm this operation (required)') }) }) @@ -1676,6 +1679,144 @@ describe('bodies and fields the generator cannot flatten', () => { expect(options.body).toMatchObject({ rowIds: ['row_1', 'row_2'] }) expect(options.body).not.toHaveProperty('limit') }) + + /** + * The help says "0 is not accepted" and the CLI sent it anyway: `0`, `-1` + * and `1.5` all reached the wire to be refused by the route. + */ + it('refuses a row cap the help already documents as invalid', async () => { + for (const [value, message] of [ + ['0', '--limit must be 1 or more'], + ['-1', '--limit must be 1 or more'], + ['1.5', '--limit must be a whole number'], + ] as const) { + for (const command of ['batch-delete', 'batch-update'] as const) { + const argv = ['tables', 'rows', command, 'tbl_1', '--filter', '{"all":[]}'] + if (command === 'batch-update') argv.push('--data', '{"a":1}') + argv.push('--limit', value, '--yes') + await expect(run(argv)).rejects.toThrow(message) + } + } + expect(mockRequest).not.toHaveBeenCalled() + }) + + /** + * The route decides this with a refine whose message describes the opposite + * mistake when neither flag is typed — and half in wire names. + */ + it('requires exactly one of the two ways to choose the rows', async () => { + await expect(run(['tables', 'rows', 'batch-delete', 'tbl_1', '--yes'])).rejects.toThrow( + '--filter or --row is required to choose the rows to delete' + ) + await expect( + run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--filter', + '{"all":[]}', + '--row', + 'row_1', + '--yes', + ]) + ).rejects.toThrow('--filter and --row choose the rows to delete two different ways') + expect(mockRequest).not.toHaveBeenCalled() + }) + }) + + /** + * `--limit` on a cursor-paginated operation is a client-side total, stripped + * from the request while the CLI walks the pages — so `--limit 0` reached the + * whole table with run state attached as many individually-legal pages, which + * is what the route's own `limit: 0` refusal exists to prevent. + */ + it('refuses a full-table walk that carries run state', async () => { + await expect( + run(['tables', 'rows', 'list', 'tbl_1', '--limit', '0', '--include-run-state']) + ).rejects.toThrow('--limit 0 cannot be combined with --include-run-state') + expect(mockRequest).not.toHaveBeenCalled() + + await run(['tables', 'rows', 'list', 'tbl_1', '--limit', '5', '--include-run-state']) + expect(mockRequest).toHaveBeenCalled() + }) + + /** + * An `integer` field said so in the contract, and the refusal was left to the + * server — which answered in library wording naming neither the flag nor the + * value. + */ + it('refuses a fractional or unrepresentable value on an integer flag', async () => { + await expect(run(['files', 'read', 'file_1', '--max-bytes', '5.5'])).rejects.toThrow( + '--max-bytes must be a whole number' + ) + await expect( + run(['files', 'read', 'file_1', '--max-bytes', '999999999999999999999']) + ).rejects.toThrow('--max-bytes is outside the whole-number range the API accepts') + expect(mockRequest).not.toHaveBeenCalled() + }) + + /** + * `--workspace` is a root-program global, so commander accepts it everywhere + * while only an operation declaring `workspaceId` ever uses it. On the rest it + * was parsed and dropped, and three different values produced byte-identical + * requests. + */ + it('says so when --workspace has nothing to act on', async () => { + const written: string[] = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + written.push(String(chunk)) + return true + }) + resetRenameWarnings() + + await run(['--workspace', 'ws_other', 'logs', 'get', 'run_1'], { data: {} }) + expect(written.join('')).toContain('--workspace does not apply to "sim logs get"') + + written.length = 0 + await run(['--workspace', 'ws_other', 'logs', 'list']) + expect(written.join('')).not.toContain('does not apply') + }) + + /** + * `activate create` is the deployed cutover addressed by version, the same + * production change `rollback` and `undeploy` both gate. `deploy` stays + * ungated: it publishes the draft as a NEW version, which is the forward + * action the caller asked for and which a rollback undoes. + */ + it('gates activating a deployed version the way rollback is gated', async () => { + await expect(run(['workflows', 'activate', 'create', 'wf-1', '2'])).rejects.toThrow( + /changes which deployed version runs in production.*Re-run with --yes/s + ) + expect(mockRequest).not.toHaveBeenCalled() + + await run(['workflows', 'activate', 'create', 'wf-1', '2', '--yes']) + expect(mockRequest).toHaveBeenCalled() + + mockRequest.mockClear() + await run(['workflows', 'deploy', 'wf-1']) + expect(mockRequest).toHaveBeenCalled() + }) + + /** + * `--all-workspaces` reaches the wire as the absence of `workspaceId`, so a + * workspace key answered with its own workspace's figures and exit 0. + */ + it('refuses an account-wide read a workspace key cannot make', async () => { + mockRequest.mockReset() + mockRequest.mockImplementation(async (path: string) => + path === V2_OPERATIONS.getMeta.path + ? { data: { v2Enabled: true, keyType: 'workspace', expiresAt: null } } + : { data: {} } + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await expect( + program().parseAsync(['node', 'sim', 'billing', 'status', '--all-workspaces']) + ).rejects.toThrow('--all-workspaces needs a personal API key') + expect( + mockRequest.mock.calls.some(([path]) => path === V2_OPERATIONS.getBillingStatus.path) + ).toBe(false) }) it('still gives paginated lists their numeric --limit', async () => { diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index b387f6420a6..42871622e33 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { CLI_CONTRACT } from '../contract/commands' import type { CommandSpec, CommandVariantSpec } from '../contract/types' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' -import { deriveCommandPath } from './derive' +import { commandPath, deriveCommandPath } from './derive' import { executeOperation } from './execute' import { retypeApiError } from './naming' import { addOperationOptions } from './options' @@ -54,16 +54,6 @@ function argumentSyntax(command: Command): string { .join(' ') } -function commandPath(command: Command): string { - const names: string[] = [] - let current: Command | null = command - while (current) { - names.unshift(current.name()) - current = current.parent - } - return names.join(' ') -} - function addMissingArgumentExample(command: Command): Command { const outputError = command.configureOutput().outputError if (!outputError) throw new Error('Commander output formatter is not configured') diff --git a/packages/sim-cli/src/runtime/derive.ts b/packages/sim-cli/src/runtime/derive.ts index eb0d17a94e8..7684eb4d03a 100644 --- a/packages/sim-cli/src/runtime/derive.ts +++ b/packages/sim-cli/src/runtime/derive.ts @@ -1,5 +1,17 @@ +import type { Command } from 'commander' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +/** The full `sim …` path of a command, for a message that has to name it. */ +export function commandPath(command: Command): string { + const names: string[] = [] + let current: Command | null = command + while (current) { + names.unshift(current.name()) + current = current.parent + } + return names.join(' ') +} + /** * Trailing path segments that read as verbs rather than sub-resources, so * `/tables/[id]/rows/upsert` derives `tables upsert` instead of diff --git a/packages/sim-cli/src/runtime/execute.test.ts b/packages/sim-cli/src/runtime/execute.test.ts index 8750c52ded4..283cc4d7b87 100644 --- a/packages/sim-cli/src/runtime/execute.test.ts +++ b/packages/sim-cli/src/runtime/execute.test.ts @@ -348,6 +348,13 @@ const BULK_UPDATE_CHUNKS: OperationSpec = { body: {}, } +const BULK_UPDATE_DOCUMENTS: OperationSpec = { + method: 'PATCH', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents', + pathParams: ['knowledgeBaseId'], + body: {}, +} + const ADD_WORKSPACE_FILES: OperationSpec = { method: 'POST', path: '/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files', @@ -423,6 +430,52 @@ describe('a bulk call that touched nothing', () => { await expect(indexFiles({ file: ['wf_1', 'wf_2'] })).resolves.toBeUndefined() }) + function updateDocuments(flags: Record) { + const host = new Command('leaf') + return executeOperation('bulkUpdateKnowledgeDocuments', {}, BULK_UPDATE_DOCUMENTS, [ + 'kb_1', + flags, + host, + ]) + } + + it('fails the process when no listed document matched', async () => { + request.mockResolvedValue({ + data: { + operation: 'disable', + processed: 0, + errors: ['No matching documents found to disable: d1, d2'], + }, + }) + + await expect(updateDocuments({ operation: 'disable', document: ['d1', 'd2'] })).rejects.toThrow( + /No matching documents found to disable: d1, d2/ + ) + }) + + /** + * The other selection this endpoint accepts. `--select-all` sends no + * identifiers at all, so a check that measures the request by `documentIds` + * reads the sweep as "nothing was asked for" and exempts it — and a sweep of + * an empty knowledge base is precisely the case a caller needs told about. + * `errors` is empty for this selection, so nothing else would report it. + */ + it('fails the process when a select-all sweep matched nothing', async () => { + request.mockResolvedValue({ data: { operation: 'disable', processed: 0, errors: [] } }) + + await expect(updateDocuments({ operation: 'disable', selectAll: true })).rejects.toThrow( + /--select-all matched no documents in this knowledge base\./ + ) + }) + + it('succeeds on a select-all sweep that matched something', async () => { + request.mockResolvedValue({ data: { operation: 'disable', processed: 3, errors: [] } }) + + await expect( + updateDocuments({ operation: 'disable', selectAll: true }) + ).resolves.toBeUndefined() + }) + /** Nothing asked for is nothing missed — an empty answer is still an answer. */ it('succeeds when nothing was asked for', async () => { request.mockResolvedValue({ data: { knowledgeBaseId: 'kb_1', added: [], failed: [] } }) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index d56bda32502..1713e1539a7 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -1,12 +1,12 @@ import type { Command } from 'commander' import { clientFrom } from '../context' import type { CommandSpec } from '../contract/types' -import type { V2OperationName } from '../generated/v2-api' -import { pageProgress, SimApiError, type V2Page } from '../http/client' +import { type GetMetaResponse, V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { pageProgress, SimApiError, type SimClient, type V2Page } from '../http/client' import { safeOneLine } from '../output/render' -import { camel } from './derive' +import { camel, commandPath } from './derive' import { DEFAULT_LIMIT } from './options' -import { warnRenamedFlag } from './renamed' +import { warnRenamedFlag, warnUnusedWorkspace } from './renamed' import { buildRequest, cursorSlot, @@ -129,6 +129,30 @@ export const BULK_OUTCOME_CHECKS: Readonly { + if (countOf(payload.processed) > 0) return null + const selectAll = body?.selectAll === true + const requested = lengthOf(body?.documentIds) + if (!selectAll && requested === 0) return null + const reported = (payload.errors as unknown[] | undefined)?.[0] + if (typeof reported === 'string' && reported) return safeOneLine(reported) + return selectAll + ? 'Updated nothing: --select-all matched no documents in this knowledge base.' + : `Updated nothing: none of the ${requested} requested ${requested === 1 ? 'document' : 'documents'} matched.` + }, moveTables: (payload) => { if (lengthOf(payload.moved) > 0) return null const missed = lengthOf(payload.notFound) + lengthOf(payload.failed) @@ -181,6 +205,91 @@ const EXCLUSIVE_CAP_FIELDS: Readonly< deleteTableRows: { cap: 'limit', ids: 'rowIds' }, } +/** + * The pager's `--limit`, where `0` means "no ceiling". + * + * Read whole, not up to the first character that stops looking numeric. + * `parseInt` truncated before the guard could see what was typed, so + * `--limit 3.9` quietly fetched 3, `--limit 1e3` fetched 1, and `--limit -0.5` + * parsed as `-0` — which is not less than zero, so it slipped the guard and + * then read as the `0` that means everything. `Number` keeps the value intact + * so each of those is refused instead of reinterpreted, and it reads `0x10` and + * `1e3` as the caller wrote them. + * + * The empty string is refused explicitly because `Number('')` is `0`: without + * this, `--limit ''` would go from today's error to an unbounded walk of a + * shared workspace. + */ +function readPagedLimit(raw: unknown): number { + const text = String(raw ?? DEFAULT_LIMIT).trim() + const value = text === '' ? Number.NaN : Number(text) + if (!Number.isInteger(value) || value < 0) { + throw new SimApiError('--limit must be a whole number of 0 or more (0 for everything)', 0) + } + return value +} + +/** + * Flags whose server-side rule an unbounded client-side walk would defeat. + * + * `--limit` on a cursor-paginated operation is a client-side total: it is + * stripped from the request and the CLI walks the pages itself. So + * `tables rows list --limit 0 --include-run-state` drained the whole table with + * run state attached, as many individually-legal pages — exactly the outcome + * the route's `limit: 0 cannot be combined with includeRunState` refusal + * exists to prevent, reached by a request the route cannot tell apart from a + * bounded one. + * + * `--limit 0` is the only unbounded form the CLI has, so refusing the pair is + * refusing the unbounded walk. + */ +const FULL_WALK_CONFLICTS: Readonly>> = { + listTableRows: 'includeRunState', + queryRows: 'includeRunState', +} + +/** Refuses a full-table walk that carries a flag the route bounds to one page. */ +function assertFullWalkIsAllowed( + operation: V2OperationName, + flags: Record, + pagedLimit: number +): void { + const field = FULL_WALK_CONFLICTS[operation] + if (!field || pagedLimit !== 0) return + + const name = flagNameFor(operation, field) + if (flags[camel(name)] !== true) return + throw new SimApiError( + `--limit 0 cannot be combined with --${name}: it walks every page with run state attached, which is what the API refuses on a single request. Ask for a bounded page or drop --${name}`, + 0 + ) +} + +/** + * Refuses an account-wide read that the active key cannot make. + * + * `--all-workspaces` is not a wire value: the CLI implements it by omitting + * `workspaceId`, so it is byte-identical to a plain call. A workspace API key + * is scoped to its own workspace, so the server answers that request with one + * workspace's figures — correctly, and indistinguishably from the account-wide + * answer the caller asked for. A script reading account-wide totals got one + * workspace's numbers at exit 0. + * + * The server cannot refuse this: with no signal in the request, refusing would + * reject the plainest call in the API for every workspace-key holder. `/meta` + * is the only thing that distinguishes the two key types — both are spelled + * `sk-sim-` — so the check is a round trip, paid only on an invocation that + * actually typed the flag. + */ +async function assertKeyCanReadAllWorkspaces(client: SimClient): Promise { + const meta = await client.request(V2_OPERATIONS.getMeta.path) + if (meta.data.keyType !== 'workspace') return + throw new SimApiError( + '--all-workspaces needs a personal API key: a workspace API key can only report its own workspace, and would answer with that workspace’s figures as if they were the account’s', + 0 + ) +} + /** Refuses a row cap typed alongside the explicit id list that supersedes it. */ function assertCapIsUsable(operation: V2OperationName, flags: Record): void { const exclusive = EXCLUSIVE_CAP_FIELDS[operation] @@ -195,6 +304,40 @@ function assertCapIsUsable(operation: V2OperationName, flags: Record + > +> = { + deleteTableRows: { fields: ['filter', 'rowIds'], noun: 'rows to delete' }, +} + +/** Refuses a selection that names neither of the two ways to make it, or both. */ +function assertSelectorIsUsable(operation: V2OperationName, flags: Record): void { + const selector = REQUIRED_SELECTORS[operation] + if (!selector) return + + const [first, second] = selector.fields.map((field) => flagNameFor(operation, field)) + const given = [first, second].filter((name) => flags[camel(name)] !== undefined) + if (given.length === 1) return + throw new SimApiError( + given.length === 0 + ? `--${first} or --${second} is required to choose the ${selector.noun}` + : `--${first} and --${second} choose the ${selector.noun} two different ways; pass one, not both`, + 0 + ) +} + /** * Moves a value supplied under a flag's former name onto its current one. * @@ -257,6 +400,7 @@ export async function executeOperation( foldRenamedFlags(operation, commandSpec, requestFlags) assertCapIsUsable(operation, requestFlags) + assertSelectorIsUsable(operation, requestFlags) /** * A dry run writes nothing, so it never needs the destructive confirmation. @@ -282,6 +426,14 @@ export async function executeOperation( (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) ) const omitsWorkspace = commandSpec.allWorkspaces && requestFlags.allWorkspaces === true + if (omitsWorkspace) await assertKeyCanReadAllWorkspaces(client) + if ( + requestFlags.workspace !== undefined && + !hasWorkspaceField && + commandSpec.profileWorkspacePath !== true + ) { + warnUnusedWorkspace(commandPath(host)) + } /** * A workspace carried in the path is resolved exactly like one carried in a * field. `workspaces get` and `workspaces members` take theirs as a path @@ -292,36 +444,24 @@ export async function executeOperation( */ const needsWorkspace = (hasWorkspaceField || commandSpec.profileWorkspacePath === true) && !omitsWorkspace + const paging = cursorSlot(operationSpec) + /** + * Checked before the request is built, because `buildRequest` also validates + * `limit` and would otherwise answer a paginated `--limit 1.5` with the + * generic integer refusal — losing the `0 for everything` this pager depends + * on the caller knowing. + */ + const pagedLimit = paging ? readPagedLimit(requestFlags.limit) : 0 + assertFullWalkIsAllowed(operation, requestFlags, paging ? pagedLimit : -1) const request = buildRequest( operation, positional, requestFlags, needsWorkspace ? client.requireWorkspace() : profile.workspaceId ) - const paging = cursorSlot(operationSpec) if (paging) { - /** - * Read whole, not up to the first character that stops looking numeric. - * - * `parseInt` truncated before the guard could see what was typed, so - * `--limit 3.9` quietly fetched 3, `--limit 1e3` fetched 1, and - * `--limit -0.5` parsed as `-0` — which is not less than zero, so it slipped - * the guard and then read as the `0` that means everything. `Number` keeps - * the value intact so each of those is refused instead of reinterpreted, - * and it reads `0x10` and `1e3` as the caller wrote them. - * - * The empty string is refused explicitly because `Number('')` is `0`, and - * `0` here means "no ceiling": without this, `--limit ''` would go from - * today's error to an unbounded walk of a shared workspace. - */ - const limitText = String(requestFlags.limit ?? DEFAULT_LIMIT).trim() - const rawLimit = limitText === '' ? Number.NaN : Number(limitText) - if (!Number.isInteger(rawLimit) || rawLimit < 0) { - throw new SimApiError('--limit must be a whole number of 0 or more (0 for everything)', 0) - } - - const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const limit = pagedLimit === 0 ? Number.POSITIVE_INFINITY : pagedLimit const pageSize = Math.min(Number.isFinite(limit) ? limit : DEFAULT_LIMIT, DEFAULT_LIMIT) const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} const rows: unknown[] = [] diff --git a/packages/sim-cli/src/runtime/naming.test.ts b/packages/sim-cli/src/runtime/naming.test.ts index 05ffb7ec988..d1f82d9f3e6 100644 --- a/packages/sim-cli/src/runtime/naming.test.ts +++ b/packages/sim-cli/src/runtime/naming.test.ts @@ -67,6 +67,38 @@ describe('a validation error restated in the spellings a caller can type', () => expect(retyped.message).not.toContain('--name') }) + /** + * Some routes name the field inside the message as well as in `path`, and the + * details column prints both: `--sort-by: --sort-by: only "startedAt" …`. + */ + it('does not print the field label twice on one detail line', () => { + const message = + 'sortBy: only "startedAt" can order job runs; drop includeJobRuns or sort by "startedAt"' + const line = detailLines('listLogs', [{ path: ['sortBy'], message }])[1] + + expect(line).toContain(' --sort-by: only "startedAt"') + expect(line).not.toContain('--sort-by: --sort-by:') + }) + + /** + * Only multi-segment camelCase is safely rewritable in prose, so a sentence + * enumerating both kinds came out half in flags and half in wire names: + * `At least one of name, description, or --folder is required`. + */ + it('never mixes the two vocabularies in one sentence', () => { + expect( + retype( + 'updateWorkflow', + new SimApiError('At least one of name, description, or folderPath is required', 400) + ).message + ).toBe('At least one of name, description, or folderPath is required') + + // A sentence with nothing ambiguous left in it still gets the translation. + expect( + retype('listWorkflows', new SimApiError('folderPath must be canonical', 400)).message + ).toBe('--folder must be canonical') + }) + it('names the global flag the workspace comes from', () => { expect( detailLines('listLogs', [{ path: ['workspaceId'], message: 'Workspace is required' }])[1] diff --git a/packages/sim-cli/src/runtime/naming.ts b/packages/sim-cli/src/runtime/naming.ts index b0683c0e132..1ccfd3a333f 100644 --- a/packages/sim-cli/src/runtime/naming.ts +++ b/packages/sim-cli/src/runtime/naming.ts @@ -73,13 +73,35 @@ function typeableFields( return spellings } -/** Rewrites wire names a message quotes into the flags the caller typed. */ +/** + * Rewrites wire names a message quotes into the flags the caller typed. + * + * All or nothing. Only multi-segment camelCase is safely rewritable in prose — + * see {@link WIRE_IDENTIFIER} — so a sentence enumerating both kinds came out + * half in one vocabulary and half in the other: `At least one of name, + * description, or --folder is required` reads as three different things, one of + * which is a flag. When a single-word field of the same operation survives the + * pass, the whole message is left as the server wrote it: entirely in wire + * names, which is at least internally consistent and matches the REST + * reference the caller can look the names up in. + * + * The cost is that a message mentioning `folderPath` and the English word + * `name` loses a translation it could have had. That is the deliberate trade — + * telling the two apart is the undecidable problem that produced the mixed + * sentence in the first place, and an unhelpful sentence beats a misleading one. + */ function retypeMessage(message: string, spellings: Map): string { let retyped = message for (const [field, spelling] of spellings) { if (!WIRE_IDENTIFIER.test(field)) continue retyped = retyped.replaceAll(new RegExp(`\\b${field}\\b`, 'g'), spelling) } + if (retyped === message) return message + + for (const field of spellings.keys()) { + if (WIRE_IDENTIFIER.test(field)) continue + if (new RegExp(`\\b${field}\\b`).test(retyped)) return message + } return retyped } diff --git a/packages/sim-cli/src/runtime/options.test.ts b/packages/sim-cli/src/runtime/options.test.ts index a4492463a2e..cf7f179f226 100644 --- a/packages/sim-cli/src/runtime/options.test.ts +++ b/packages/sim-cli/src/runtime/options.test.ts @@ -31,9 +31,11 @@ describe('the --yes flag on a destructive command', () => { */ it('describes itself as the confirmation, not as skipping one', () => { const help = confirmHelp() - expect(help).toMatch(/-y, --yes\s+Confirm this destructive operation \(required\)/) + expect(help).toMatch(/-y, --yes\s+Confirm this operation \(required\)/) expect(help).not.toMatch(/skip/i) expect(help).not.toMatch(/prompt/i) + // Nor "destructive": the same gate covers `files unzip`, which only adds. + expect(help).not.toMatch(/destructive/i) }) }) diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 7c87b2c9733..dc8f48d2425 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -257,11 +257,15 @@ export function addOperationOptions( // outright would send a caller reaching for `--yes` to preview a change. const exemptedByDryRun = operationSpec.query?.dryRun !== undefined || operationSpec.body?.dryRun !== undefined + // Not "destructive": the gate also covers operations that only add — + // `files unzip` writes the archive's contents into the workspace and + // destroys nothing — so the adjective was wrong on the help line while the + // refusal itself, which states the operation's own consequence, was right. command.option( '-y, --yes', exemptedByDryRun - ? 'Confirm this destructive operation (required unless --dry-run)' - : 'Confirm this destructive operation (required)' + ? 'Confirm this operation (required unless --dry-run)' + : 'Confirm this operation (required)' ) } } diff --git a/packages/sim-cli/src/runtime/renamed.ts b/packages/sim-cli/src/runtime/renamed.ts index 6eabc7865b3..309a08041cd 100644 --- a/packages/sim-cli/src/runtime/renamed.ts +++ b/packages/sim-cli/src/runtime/renamed.ts @@ -35,6 +35,30 @@ export function warnRenamedFlag(from: string, to: string): void { warn('flag', `--${from}`, `--${to}`) } +/** + * Announces that `--workspace` had nothing to act on. + * + * `-w` is a root-program global, so commander accepts it on every command, + * while it is only ever substituted into an operation that declares a + * `workspaceId`. On the rest — every workflow-, run- and server-addressed + * route, whose id is global and whose scope the server reads off the record + * itself — the value was parsed and dropped, so three different `-w` values + * produced byte-identical requests and read as a scoping bug. + * + * Said rather than refused: 39 of the API's operations declare no + * `workspaceId`, and a wrapper that appends `-w` to every invocation is + * exactly the shape that would break. Warning removes the silence, which is + * the part that misled, without failing a call that was already correct. + */ +export function warnUnusedWorkspace(command: string): void { + const key = `workspace:${command}` + if (warned.has(key)) return + warned.add(key) + process.stderr.write( + `warning: --workspace does not apply to "${command}"; the id you passed already identifies the workspace, and the flag was ignored.\n` + ) +} + /** Test seam: renames warn once per process, and each test needs a clean slate. */ export function resetRenameWarnings(): void { warned.clear() diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 3d1147a404d..b9301beeae6 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -381,6 +381,24 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: if (NUMERIC_KINDS.has(field.kind)) { const value = Number(raw) if (Number.isNaN(value)) throw new SimApiError(`--${flagName} must be a number`, 0) + /** + * An `integer` field said so in the contract, and every other constraint on + * one is already refused here by hand. Leaving integrality to the server + * answered `--max-bytes 5.5` with `Invalid input: expected int, received + * number` and `--max-bytes 999999999999999999999` with `Too big: expected + * int to be <=9007199254740991` — library wording naming neither the flag + * nor anything the caller typed, on the one flag whose blank, zero and + * non-numeric cases all had a sentence written for them. + */ + if (field.kind === 'integer' && !Number.isInteger(value)) { + throw new SimApiError(`--${flagName} must be a whole number`, 0) + } + if (field.kind === 'integer' && !Number.isSafeInteger(value)) { + throw new SimApiError( + `--${flagName} is outside the whole-number range the API accepts (±${Number.MAX_SAFE_INTEGER})`, + 0 + ) + } return value } @@ -551,6 +569,23 @@ export function buildRequest( const value = coerce(raw ?? undefined, descriptor, flag, flagName) + /** + * A non-paginated `limit` is a row cap the route bounds at `1`, which is + * what `--help` already tells the caller ("note 0 is not accepted") — so + * `--limit 0`, `-1` and `1.5` were shipping a round trip to be told + * something the CLI had documented. The ceiling stays with the server: + * it is per-route policy, and nothing in the terminal states it. + */ + if ( + field === 'limit' && + !paginatedLimit && + NUMERIC_KINDS.has(descriptor.kind) && + typeof value === 'number' && + value < 1 + ) { + throw new SimApiError(`--${flagName} must be 1 or more`, 0) + } + if (value === undefined) { if (descriptor.required) { throw new SimApiError( diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index 1d24a88db81..4754931e91b 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -433,7 +433,7 @@ function writeEnvelopeTruncation(envelope: unknown): void { * the answer is incomplete either way, and the caller who capped it is the one * most likely to reuse the result as if it were whole. */ -function writeCursorTruncation(count: number, truncated: boolean): void { +export function writeCursorTruncation(count: number, truncated: boolean): void { if (!truncated) return process.stderr.write( chalk.dim(`showing the first ${count}; more results exist — re-run with --limit 0 for all\n`)