diff --git a/apps/docs/content/docs/en/cli/audit-logs.mdx b/apps/docs/content/docs/en/cli/audit-logs.mdx index 01104feceb9..1c06a2785ee 100644 --- a/apps/docs/content/docs/en/cli/audit-logs.mdx +++ b/apps/docs/content/docs/en/cli/audit-logs.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim audit-logs get [options] ``` +Get Audit Log (personal API key required) + **Arguments** @@ -41,6 +43,8 @@ sim audit-logs get [options] sim audit-logs list [options] ``` +List Audit Logs (personal API key required) + **Options** diff --git a/apps/docs/content/docs/en/cli/authentication.mdx b/apps/docs/content/docs/en/cli/authentication.mdx index 40cdf96e307..68461afd01f 100644 --- a/apps/docs/content/docs/en/cli/authentication.mdx +++ b/apps/docs/content/docs/en/cli/authentication.mdx @@ -24,7 +24,7 @@ https://www.sim.ai/cli/auth?request=…&scope=platform Waiting for approval… ✓ Logged in. Key stored in /Users/you/.sim/credentials - Personal key, defaulting to ws_abc123. Override per command with --workspace. + Personal key, defaulting to 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67. Override per command with --workspace. ``` There is no loopback listener, so this works over SSH and inside containers. @@ -47,7 +47,7 @@ profile's default `workspace`; it does **not** restrict the key to that workspace. Target another workspace the key can reach with `--workspace`: ```bash -sim workflows list --workspace ws_other +sim workflows list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a ``` `sim login --workspace ` preselects a workspace in the picker, and @@ -58,7 +58,7 @@ a workspace profile: ```bash sim workspaces list -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme whoami ``` @@ -108,9 +108,9 @@ config file: ```bash export SIM_API_KEY="sim_…" -export SIM_WORKSPACE="ws_abc123" +export SIM_WORKSPACE="2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67" -sim workflows run wf_7Yb2 --input '{"source":"nightly"}' --output json +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' --output json ``` Create the key in Sim under **Settings → API keys**. Store it as a secret in your @@ -130,7 +130,7 @@ jobs: with: node-version: '20' - run: npm install -g sim - - run: sim workflows run wf_7Yb2 --output json + - run: sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json env: SIM_API_KEY: ${{ secrets.SIM_API_KEY }} SIM_WORKSPACE: ${{ vars.SIM_WORKSPACE }} @@ -151,8 +151,8 @@ sim workflows list --profile prod Use workspace profiles when one personal key should target several workspaces: ```bash -sim profile add marketing --workspace ws_marketing -sim profile add support --workspace ws_support +sim profile add marketing --workspace c3a70e58-9f21-4d6b-b842-05e7f19c6a3d +sim profile add support --workspace e0d94b17-3c62-45af-9718-b6a2c8035f4e sim workflows list --profile marketing sim workflows list --profile support diff --git a/apps/docs/content/docs/en/cli/billing.mdx b/apps/docs/content/docs/en/cli/billing.mdx index c93a0552c2e..b97e19c8156 100644 --- a/apps/docs/content/docs/en/cli/billing.mdx +++ b/apps/docs/content/docs/en/cli/billing.mdx @@ -31,6 +31,8 @@ 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) + **Options** diff --git a/apps/docs/content/docs/en/cli/configuration.mdx b/apps/docs/content/docs/en/cli/configuration.mdx index b0b177f90f9..b5ba49ac7d4 100644 --- a/apps/docs/content/docs/en/cli/configuration.mdx +++ b/apps/docs/content/docs/en/cli/configuration.mdx @@ -28,14 +28,14 @@ sim profiles # list them; * marks the active one Add a profile for another workspace without creating or copying an API key: ```bash -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 ``` ## Setting defaults ```bash sim configure --set-endpoint http://localhost:3000 --profile dev -sim configure --set-workspace ws_local --profile dev +sim configure --set-workspace 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 --profile dev sim configure --set-output json ``` @@ -76,16 +76,16 @@ repo: ```ini title="~/.sim/config" [default] endpoint = https://www.sim.ai -workspace = ws_abc123 +workspace = 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 output = table [profile dev] endpoint = http://localhost:3000 -workspace = ws_local +workspace = 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 [profile acme] auth_profile = default -workspace = ws_acme +workspace = 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 ``` Keys live in `~/.sim/credentials`, written `0600`: @@ -132,9 +132,9 @@ filesystem at all. Workspace-scoped commands need a workspace: ```bash -sim tables list --workspace ws_other -sim configure --set-workspace ws_abc123 -export SIM_WORKSPACE=ws_abc123 +sim tables list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a +sim configure --set-workspace 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 +export SIM_WORKSPACE=2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 ``` For a reusable selection, create a workspace profile backed by the current @@ -142,7 +142,7 @@ stored login: ```bash sim workspaces list -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme tables list ``` diff --git a/apps/docs/content/docs/en/cli/credentials.mdx b/apps/docs/content/docs/en/cli/credentials.mdx index de77e662048..6aa4143baed 100644 --- a/apps/docs/content/docs/en/cli/credentials.mdx +++ b/apps/docs/content/docs/en/cli/credentials.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim credentials delete [options] ``` +Disconnect Credential (personal API key required) + **Arguments** @@ -78,6 +80,8 @@ sim credentials list [options] sim credentials update [options] ``` +Update Credential (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index f910224e817..40758bc2999 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -21,7 +21,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). | +| `--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. | @@ -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 `POST /api/v2/files/folders/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 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`. | @@ -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 `POST /files/{fileId}/restore`. 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 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`. | @@ -220,6 +220,8 @@ sim files share get sim files share set [options] ``` +Enable or disable sharing for a file (personal API key required) + **Arguments** @@ -239,7 +241,7 @@ sim files share set [options] | `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | | `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | | `--password ` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. | -| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line). | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -258,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 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`. | | `--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`. | @@ -280,7 +282,7 @@ Also available as `sim files mv`. | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | diff --git a/apps/docs/content/docs/en/cli/index.mdx b/apps/docs/content/docs/en/cli/index.mdx index ffe240c7b16..a9927e7d9e3 100644 --- a/apps/docs/content/docs/en/cli/index.mdx +++ b/apps/docs/content/docs/en/cli/index.mdx @@ -77,9 +77,9 @@ sim workflows list ``` ``` -ID NAME FOLDER DEPLOYED RUNS LAST RUN -wf_7Yb2 Refund triage /Support yes 412 2026-08-15 14:02:11 -wf_9Kd4 Weekly digest /Reporting no 18 2026-08-11 09:00:04 +ID NAME FOLDER DEPLOYED RUNS LAST RUN +3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 Refund triage /Support yes 412 2026-08-15 14:02:11 +b8c0d247-9e13-4a86-97f5-2ad4e1638c09 Weekly digest /Reporting no 18 2026-08-11 09:00:04 ``` @@ -87,7 +87,7 @@ wf_9Kd4 Weekly digest /Reporting no 18 2026-08-11 09:00:04 ### Run one ```bash -sim workflows run wf_7Yb2 --input '{"ticketId":"T-4821"}' +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"ticketId":"T-4821"}' ``` A workflow must be deployed before it can be run. Deploy from the editor, or @@ -106,8 +106,8 @@ sim [sub-resource] [arguments] [options] ```bash sim workflows list -sim tables rows query tbl_123 --limit 50 -sim knowledge documents upload kb_123 ./handbook.pdf +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 50 +sim knowledge documents upload 4c1b7f60-2d55-4a3e-9c18-70b6ea2f9d31 ./handbook.pdf ``` Resource groups are plural, and each also accepts its singular spelling — @@ -146,8 +146,10 @@ sim tables rows query --help | [`workflow-mcp-servers`](/cli/workflow-mcp-servers) | Publish workflows as MCP tools for outside agents | | [`meta`](/cli/meta) | Check what this API supports and which limits apply | -The [command reference](/cli/commands) documents every subcommand, argument, and -flag, and is generated from the CLI itself. +The [command overview](/cli/commands) has the global options and the commands +that take no resource; the [complete reference](/cli/reference) documents every +subcommand, argument, and flag on one page. Both are generated from the CLI +itself. ## Where to go next @@ -156,4 +158,5 @@ flag, and is generated from the CLI itself. - [Output formats](/cli/output) — `table`, `json`, `yaml`, and `text`, and when to use each - [Scripting](/cli/scripting) — piping, file inputs, exit codes, and automation recipes - [Troubleshooting](/cli/troubleshooting) — what each error means, and how to resolve it -- [Command reference](/cli/commands) — every command, argument, and flag +- [Command overview](/cli/commands) — global options, the command groups, and the commands that take no resource +- [Complete reference](/cli/reference) — every command, argument, and flag on a single page diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index 9981ebce825..a760b5067b1 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim knowledge from-workspace-files create [options] ``` +Index files the workspace already stores (personal API key required) + **Arguments** @@ -31,7 +33,7 @@ sim knowledge from-workspace-files create [options] | Option | Required | Description | | --- | --- | --- | -| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -41,6 +43,8 @@ sim knowledge from-workspace-files create [options] sim knowledge tags save [options] ``` +Declare the tag definitions a knowledge base needs (personal API key required) + **Arguments** @@ -67,6 +71,8 @@ sim knowledge tags save [options] sim knowledge tags create [options] ``` +Create Tag (personal API key required) + **Arguments** @@ -95,6 +101,8 @@ sim knowledge tags create [options] sim knowledge tags delete [options] ``` +Delete Tag (personal API key required) + **Arguments** @@ -122,6 +130,8 @@ sim knowledge tags delete [options] sim knowledge tags cleanup [options] ``` +Remove tag definitions no document still uses (personal API key required) + **Arguments** @@ -150,6 +160,8 @@ sim knowledge tags cleanup [options] sim knowledge tags next-slot [options] ``` +Show which tag slot a create would take for a field type (personal API key required) + **Arguments** @@ -192,6 +204,8 @@ sim knowledge tags list sim knowledge tags usage ``` +Show how many documents and chunks carry each tag (personal API key required) + **Arguments** @@ -208,6 +222,8 @@ sim knowledge tags usage sim knowledge tags update [options] ``` +Update Tag (personal API key required) + **Arguments** @@ -236,6 +252,8 @@ sim knowledge tags update [options] sim knowledge chunks batch-update [options] ``` +Enable, disable, or delete many chunks at once (personal API key required) + **Arguments** @@ -254,7 +272,7 @@ sim knowledge chunks batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | -| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | +| `--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. | @@ -265,6 +283,8 @@ sim knowledge chunks batch-update [options] sim knowledge chunks create [options] ``` +Create Chunk (personal API key required) + **Arguments** @@ -294,6 +314,8 @@ sim knowledge chunks create [options] sim knowledge chunks delete [options] ``` +Delete Chunk (personal API key required) + **Arguments** @@ -322,6 +344,8 @@ sim knowledge chunks delete [options] sim knowledge chunks get ``` +Get Chunk (personal API key required) + **Arguments** @@ -340,6 +364,8 @@ sim knowledge chunks get sim knowledge chunks list [options] ``` +List Chunks (personal API key required) + **Arguments** @@ -371,6 +397,8 @@ sim knowledge chunks list [options] sim knowledge chunks update [options] ``` +Update Chunk (personal API key required) + **Arguments** @@ -401,6 +429,8 @@ sim knowledge chunks update [options] sim knowledge documents batch-update [options] ``` +Enable or disable every matching document (personal API key required) + **Arguments** @@ -418,7 +448,7 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--select-all` | No | Apply to every document in the knowledge base. | | `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | @@ -505,6 +535,8 @@ sim knowledge documents list [options] sim knowledge documents update [options] ``` +Update Document (personal API key required) + **Arguments** @@ -604,6 +636,8 @@ sim knowledge create [options] sim knowledge connectors create [options] ``` +Create Knowledge Connector (personal API key required) + **Arguments** @@ -634,6 +668,8 @@ sim knowledge connectors create [options] sim knowledge connectors delete [options] ``` +Delete Knowledge Connector (personal API key required) + **Arguments** @@ -663,6 +699,8 @@ sim knowledge connectors delete [options] sim knowledge connectors get ``` +Get Knowledge Connector (personal API key required) + **Arguments** @@ -680,6 +718,8 @@ sim knowledge connectors get sim knowledge connectors documents list [options] ``` +List Knowledge Connector Documents (personal API key required) + **Arguments** @@ -709,6 +749,8 @@ sim knowledge connectors documents list [options sim knowledge connectors documents update [options] ``` +Update Knowledge Connector Documents (personal API key required) + **Arguments** @@ -727,7 +769,7 @@ sim knowledge connectors documents update [optio | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -737,6 +779,8 @@ sim knowledge connectors documents update [optio sim knowledge connectors list [options] ``` +List Knowledge Connectors (personal API key required) + **Arguments** @@ -765,6 +809,8 @@ sim knowledge connectors list [options] sim knowledge connectors sync [options] ``` +Queue a knowledge connector synchronization (personal API key required) + **Arguments** @@ -793,6 +839,8 @@ sim knowledge connectors sync [options] sim knowledge connectors update [options] ``` +Update Knowledge Connector (personal API key required) + **Arguments** @@ -990,7 +1038,7 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--query ` | No | Text to search for. | | `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | | `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | diff --git a/apps/docs/content/docs/en/cli/logs.mdx b/apps/docs/content/docs/en/cli/logs.mdx index 920792e8bdf..c5b35b9d216 100644 --- a/apps/docs/content/docs/en/cli/logs.mdx +++ b/apps/docs/content/docs/en/cli/logs.mdx @@ -47,9 +47,9 @@ sim logs stats [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--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 @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -69,8 +69,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -90,7 +90,7 @@ sim logs list [options] | `--run-id ` | No | Exact run identifier to match. | | `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--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 @). | @@ -106,9 +106,9 @@ sim logs follow [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). | | `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | | `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | | `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | diff --git a/apps/docs/content/docs/en/cli/mcp-servers.mdx b/apps/docs/content/docs/en/cli/mcp-servers.mdx index c099de5b6a9..1eac319876c 100644 --- a/apps/docs/content/docs/en/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/mcp-servers.mdx @@ -103,6 +103,8 @@ sim mcp-servers list [options] sim mcp-servers tools list [options] ``` +List MCP Server Tools (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/output.mdx b/apps/docs/content/docs/en/cli/output.mdx index 1c431f838d8..b85aa9cb431 100644 --- a/apps/docs/content/docs/en/cli/output.mdx +++ b/apps/docs/content/docs/en/cli/output.mdx @@ -15,7 +15,7 @@ Every command renders through the same four formats. Select one per command, save it to the profile, or set it in the environment: ```bash -sim tables get tbl_123 --output json +sim tables get tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --output json sim configure --set-output json SIM_OUTPUT=yaml sim logs list > logs.yaml ``` @@ -47,14 +47,14 @@ An absent value is an em-dash in `table` and an empty field in `text`. with span inputs, outputs, errors, timing, and cost: ```bash -sim logs get run_123 --trace +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --trace ``` `json` and `yaml` always carry the complete response, so `--trace` is a no-op there: ```bash -sim logs get run_123 --output json | jq '.traceSpans' +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json | jq '.traceSpans' ``` ## Exceptions @@ -66,6 +66,6 @@ configuration, not API data. so that it round-trips through `import`: ```bash -sim workflows export wf_123 > wf.json +sim workflows export 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 > wf.json sim workflows import --workflow @wf.json ``` diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index a2db7d27cbf..a0b890e97e8 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -175,7 +175,7 @@ Also spelled `sim audit-log`. ### sim audit-logs get -Get Audit Log +Get Audit Log (personal API key required) ```bash sim audit-logs get [options] @@ -203,7 +203,7 @@ sim audit-logs get [options] ### sim audit-logs list -List Audit Logs +List Audit Logs (personal API key required) ```bash sim audit-logs list [options] @@ -251,7 +251,7 @@ sim billing status [options] ### sim billing logs -List credit usage events +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's) ```bash sim billing logs [options] @@ -367,7 +367,7 @@ Also spelled `sim credential`. ### sim credentials delete -Disconnect Credential +Disconnect Credential (personal API key required) ```bash sim credentials delete [options] @@ -436,7 +436,7 @@ sim credentials list [options] ### sim credentials update -Update Credential +Update Credential (personal API key required) ```bash sim credentials update [options] @@ -693,7 +693,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). | +| `--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. | @@ -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 `POST /api/v2/files/folders/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 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`. | @@ -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 `POST /files/{fileId}/restore`. 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 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`. | @@ -906,7 +906,7 @@ sim files share get ### sim files share set -Enable or disable sharing for a file +Enable or disable sharing for a file (personal API key required) ```bash sim files share set [options] @@ -931,7 +931,7 @@ sim files share set [options] | `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | | `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | | `--password ` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. | -| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line). | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -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 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`. | | `--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`. | @@ -976,7 +976,7 @@ Also available as `sim files mv`. | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -1223,7 +1223,7 @@ Also spelled `sim kb`. ### sim knowledge from-workspace-files create -Index files the workspace already stores +Index files the workspace already stores (personal API key required) ```bash sim knowledge from-workspace-files create [options] @@ -1245,13 +1245,13 @@ sim knowledge from-workspace-files create [options] | Option | Required | Description | | --- | --- | --- | -| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim knowledge tags save -Declare the tag definitions a knowledge base needs +Declare the tag definitions a knowledge base needs (personal API key required) ```bash sim knowledge tags save [options] @@ -1279,7 +1279,7 @@ sim knowledge tags save [options] ### sim knowledge tags create -Create Tag +Create Tag (personal API key required) ```bash sim knowledge tags create [options] @@ -1309,7 +1309,7 @@ sim knowledge tags create [options] ### sim knowledge tags delete -Delete Tag +Delete Tag (personal API key required) ```bash sim knowledge tags delete [options] @@ -1338,7 +1338,7 @@ sim knowledge tags delete [options] ### sim knowledge tags cleanup -Remove tag definitions no document still uses +Remove tag definitions no document still uses (personal API key required) ```bash sim knowledge tags cleanup [options] @@ -1368,7 +1368,7 @@ sim knowledge tags cleanup [options] ### sim knowledge tags next-slot -Show which tag slot a create would take for a field type +Show which tag slot a create would take for a field type (personal API key required) ```bash sim knowledge tags next-slot [options] @@ -1414,7 +1414,7 @@ sim knowledge tags list ### sim knowledge tags usage -Show how many documents and chunks carry each tag +Show how many documents and chunks carry each tag (personal API key required) ```bash sim knowledge tags usage @@ -1432,7 +1432,7 @@ sim knowledge tags usage ### sim knowledge tags update -Update Tag +Update Tag (personal API key required) ```bash sim knowledge tags update [options] @@ -1462,7 +1462,7 @@ sim knowledge tags update [options] ### sim knowledge chunks batch-update -Enable, disable, or delete many chunks at once +Enable, disable, or delete many chunks at once (personal API key required) ```bash sim knowledge chunks batch-update [options] @@ -1486,14 +1486,14 @@ sim knowledge chunks batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | -| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | +| `--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. | ### sim knowledge chunks create -Create Chunk +Create Chunk (personal API key required) ```bash sim knowledge chunks create [options] @@ -1524,7 +1524,7 @@ sim knowledge chunks create [options] ### sim knowledge chunks delete -Delete Chunk +Delete Chunk (personal API key required) ```bash sim knowledge chunks delete [options] @@ -1554,7 +1554,7 @@ sim knowledge chunks delete [options] ### sim knowledge chunks get -Get Chunk +Get Chunk (personal API key required) ```bash sim knowledge chunks get @@ -1574,7 +1574,7 @@ sim knowledge chunks get ### sim knowledge chunks list -List Chunks +List Chunks (personal API key required) ```bash sim knowledge chunks list [options] @@ -1607,7 +1607,7 @@ sim knowledge chunks list [options] ### sim knowledge chunks update -Update Chunk +Update Chunk (personal API key required) ```bash sim knowledge chunks update [options] @@ -1639,7 +1639,7 @@ sim knowledge chunks update [options] ### sim knowledge documents batch-update -Enable or disable every matching document +Enable or disable every matching document (personal API key required) ```bash sim knowledge documents batch-update [options] @@ -1662,7 +1662,7 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--select-all` | No | Apply to every document in the knowledge base. | | `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | @@ -1751,7 +1751,7 @@ sim knowledge documents list [options] ### sim knowledge documents update -Update Document +Update Document (personal API key required) ```bash sim knowledge documents update [options] @@ -1856,7 +1856,7 @@ sim knowledge create [options] ### sim knowledge connectors create -Create Knowledge Connector +Create Knowledge Connector (personal API key required) ```bash sim knowledge connectors create [options] @@ -1888,7 +1888,7 @@ sim knowledge connectors create [options] ### sim knowledge connectors delete -Delete Knowledge Connector +Delete Knowledge Connector (personal API key required) ```bash sim knowledge connectors delete [options] @@ -1919,7 +1919,7 @@ sim knowledge connectors delete [options] ### sim knowledge connectors get -Get Knowledge Connector +Get Knowledge Connector (personal API key required) ```bash sim knowledge connectors get @@ -1938,7 +1938,7 @@ sim knowledge connectors get ### sim knowledge connectors documents list -List Knowledge Connector Documents +List Knowledge Connector Documents (personal API key required) ```bash sim knowledge connectors documents list [options] @@ -1969,7 +1969,7 @@ sim knowledge connectors documents list [options ### sim knowledge connectors documents update -Update Knowledge Connector Documents +Update Knowledge Connector Documents (personal API key required) ```bash sim knowledge connectors documents update [options] @@ -1993,13 +1993,13 @@ sim knowledge connectors documents update [optio | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim knowledge connectors list -List Knowledge Connectors +List Knowledge Connectors (personal API key required) ```bash sim knowledge connectors list [options] @@ -2029,7 +2029,7 @@ sim knowledge connectors list [options] ### sim knowledge connectors sync -Queue a knowledge connector synchronization +Queue a knowledge connector synchronization (personal API key required) ```bash sim knowledge connectors sync [options] @@ -2059,7 +2059,7 @@ sim knowledge connectors sync [options] ### sim knowledge connectors update -Update Knowledge Connector +Update Knowledge Connector (personal API key required) ```bash sim knowledge connectors update [options] @@ -2280,7 +2280,7 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--query ` | No | Text to search for. | | `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | | `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | @@ -2435,9 +2435,9 @@ sim logs stats [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--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 @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2459,8 +2459,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2480,7 +2480,7 @@ sim logs list [options] | `--run-id ` | No | Exact run identifier to match. | | `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--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 @). | @@ -2498,9 +2498,9 @@ sim logs follow [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). | | `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | | `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | | `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | @@ -2610,7 +2610,7 @@ sim mcp-servers list [options] ### sim mcp-servers tools list -List MCP Server Tools +List MCP Server Tools (personal API key required) ```bash sim mcp-servers tools list [options] @@ -2692,7 +2692,7 @@ Also spelled `sim secret`. ### sim secrets delete -Delete Secret +Delete Secret (personal API key required) ```bash sim secrets delete [options] @@ -2704,7 +2704,7 @@ sim secrets delete [options] | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `name` | Yes | Secret to delete. | @@ -2721,7 +2721,7 @@ sim secrets delete [options] ### sim secrets list -List Secrets +List Secrets (personal API key required) ```bash sim secrets list [options] @@ -2779,7 +2779,7 @@ Also spelled `sim skill`. ### sim skills create -Create Skill +Create Skill (personal API key required) ```bash sim skills create [options] @@ -2799,7 +2799,7 @@ sim skills create [options] ### sim skills delete -Delete Skill +Delete Skill (personal API key required) ```bash sim skills delete [options] @@ -2845,7 +2845,7 @@ sim skills get ### sim skills editors create -Grant Skill Editor +Grant Skill Editor (personal API key required) ```bash sim skills editors create [options] @@ -2903,7 +2903,7 @@ sim skills editors list [options] ### sim skills editors delete -Revoke Skill Editor +Revoke Skill Editor (personal API key required) ```bash sim skills editors delete [options] @@ -2953,7 +2953,7 @@ sim skills list [options] ### sim skills update -Update Skill +Update Skill (personal API key required) ```bash sim skills update [options] @@ -3203,7 +3203,7 @@ sim tables batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--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. | @@ -3319,8 +3319,8 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | +| `--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. | @@ -3521,7 +3521,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 items to return (0 for everything). Defaults to `100`. | +| `--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. | @@ -3608,11 +3608,11 @@ sim tables dispatches create [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--max-rows ` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. | @@ -3656,7 +3656,7 @@ sim tables dispatches list ### sim tables exports cancel -Cancel Table Export +Stop a running export ```bash sim tables exports cancel @@ -3741,10 +3741,10 @@ sim tables exports download ### sim tables imports cancel -Cancel Table Import +Stop a running import ```bash -sim tables imports cancel +sim tables imports cancel [options] ``` **Arguments** @@ -3757,6 +3757,16 @@ sim tables imports cancel +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ### sim tables imports get Get Table Import @@ -3802,7 +3812,7 @@ sim tables cancel-runs [options] | `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | +| `--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. | @@ -4145,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 `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--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`. | | `--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`. | @@ -4169,7 +4179,7 @@ sim tables move [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -4265,7 +4275,7 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges. (JSON, or @path / @- to read a file or stdin). | | `--on ` | No | Unique column to resolve the conflict against. | @@ -4302,6 +4312,7 @@ sim tables import [path] [options] | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | | `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `-y, --yes` | No | Confirm this destructive operation (required with --mode replace). | | `--no-wait` | No | Return once the import is queued instead of watching it. | @@ -4400,7 +4411,7 @@ sim tools list [options] ### sim workflow-mcp-servers create -Create Workflow MCP Server +Create Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers create [options] @@ -4416,13 +4427,13 @@ sim workflow-mcp-servers create [options] | `--description ` | No | Optional server description. | | `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | | `--no-is-public` | No | Send --is-public as false. | -| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim workflow-mcp-servers delete -Delete Workflow MCP Server +Delete Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers delete [options] @@ -4450,7 +4461,7 @@ sim workflow-mcp-servers delete [options] ### sim workflow-mcp-servers tools create -Publish Workflow As MCP Tool +Publish Workflow As MCP Tool (personal API key required) ```bash sim workflow-mcp-servers tools create [options] @@ -4481,7 +4492,7 @@ sim workflow-mcp-servers tools create [options] ### sim workflow-mcp-servers tools list -List Workflow MCP Tools +List Workflow MCP Tools (personal API key required) ```bash sim workflow-mcp-servers tools list @@ -4499,7 +4510,7 @@ sim workflow-mcp-servers tools list ### sim workflow-mcp-servers tools delete -Unpublish Workflow MCP Tool +Unpublish Workflow MCP Tool (personal API key required) ```bash sim workflow-mcp-servers tools delete [options] @@ -4528,7 +4539,7 @@ sim workflow-mcp-servers tools delete [options] ### sim workflow-mcp-servers get -Get Workflow MCP Server +Get Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers get @@ -4546,7 +4557,7 @@ sim workflow-mcp-servers get ### sim workflow-mcp-servers list -List Workflow MCP Servers +List Workflow MCP Servers (personal API key required) ```bash sim workflow-mcp-servers list [options] @@ -4566,7 +4577,7 @@ sim workflow-mcp-servers list [options] ### sim workflow-mcp-servers update -Update Workflow MCP Server +Update Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers update [options] @@ -4601,7 +4612,7 @@ Also spelled `sim workflow`. ### sim workflows activate create -Activate Workflow Version +Activate Workflow Version (personal API key required) ```bash sim workflows activate create @@ -4620,7 +4631,7 @@ sim workflows activate create ### sim workflows operations apply -Apply Workflow Operations +Apply Workflow Operations (personal API key required) ```bash sim workflows operations apply [options] @@ -4736,7 +4747,7 @@ sim workflows runs get [options] | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -4967,7 +4978,7 @@ sim workflows delete [options] ### sim workflows chat unpublish -Take a workflow’s chat deployment offline +Take a workflow’s chat deployment offline (personal API key required) ```bash sim workflows chat unpublish [options] @@ -4995,7 +5006,7 @@ sim workflows chat unpublish [options] ### sim workflows chat status -Show a workflow’s chat deployment +Show a workflow’s chat deployment (personal API key required) ```bash sim workflows chat status @@ -5013,7 +5024,7 @@ sim workflows chat status ### sim workflows chat publish -Publish or replace a workflow’s chat deployment +Publish or replace a workflow’s chat deployment (personal API key required) ```bash sim workflows chat publish [options] @@ -5053,7 +5064,7 @@ sim workflows chat publish [options] ### sim workflows deploy -Deploy Workflow +Deploy Workflow (personal API key required) ```bash sim workflows deploy [options] @@ -5136,7 +5147,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | @@ -5208,7 +5219,7 @@ sim workflows deployment status ### sim workflows deployment update -Update Workflow Public API Access +Update Workflow Public API Access (personal API key required) ```bash sim workflows deployment update [options] @@ -5254,7 +5265,7 @@ sim workflows state get ### sim workflows state replace -Replace Workflow State +Replace Workflow State (personal API key required) ```bash sim workflows state replace [options] @@ -5424,7 +5435,7 @@ sim workflows move [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | Yes | Destination folder path; / moves the workflows to the workspace root. | @@ -5449,7 +5460,7 @@ sim workflows restore ### sim workflows revert create -Revert Workflow To Version +Revert Workflow To Version (personal API key required) ```bash sim workflows revert create [options] @@ -5478,7 +5489,7 @@ sim workflows revert create [options] ### sim workflows rollback -Rollback Workflow +Rollback Workflow (personal API key required) ```bash sim workflows rollback [options] @@ -5507,7 +5518,7 @@ sim workflows rollback [options] ### sim workflows undeploy -Take a workflow out of deployment +Take a workflow out of deployment (personal API key required) ```bash sim workflows undeploy [options] diff --git a/apps/docs/content/docs/en/cli/scripting.mdx b/apps/docs/content/docs/en/cli/scripting.mdx index 32be39572b9..af4dc93cce9 100644 --- a/apps/docs/content/docs/en/cli/scripting.mdx +++ b/apps/docs/content/docs/en/cli/scripting.mdx @@ -14,7 +14,7 @@ to read stdin. ```bash sim workflows import --workflow @wf.json -sim tables rows query tbl_123 --filter @filter.json +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json cat wf.json | sim workflows import --workflow @- ``` @@ -24,20 +24,34 @@ Primitive lists take space-separated values. With `@`, the file supplies one value per line: ```bash -sim files mv --file-ids file_1 file_2 --to Archive +sim files mv --file-ids wf_3Qm8ZtLpR2yVnKd7BsXwC wf_5Hn1JvTqW9xUcMb4RzPgL --to Archive sim files mv --file-ids @file-ids.txt --to Archive -printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to Archive +printf 'wf_3Qm8ZtLpR2yVnKd7BsXwC\nwf_5Hn1JvTqW9xUcMb4RzPgL\n' | sim files mv --file-ids @- --to Archive ``` Arrays of objects stay JSON. +## Passing a literal leading `@` + +Because `@` introduces a file reference, a value that genuinely starts with one +is written `@@`. Only the leading `@` is dropped, and every `@`-aware flag +accepts the escape: + +```bash +sim files share set wf_3Qm8ZtLpR2yVnKd7BsXwC --allowed-emails @@example.org +sim secrets set API_HOST --value @@internal +``` + +Without it, `--allowed-emails @example.org` can only be read as a request to +open a file named `example.org`. + ## Filtering table rows `--filter` takes the same predicate tree the API uses: `all` (AND) or `any` (OR) groups of `{field, op, value}` conditions, nestable. ```bash -sim tables rows query tbl_123 \ +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 \ --filter '{"all":[{"field":"status","op":"eq","value":"open"}, {"field":"score","op":"gt","value":10}]}' \ --limit 50 @@ -50,7 +64,7 @@ Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `--sort` is also JSON, an ordered list of keys: ```bash -sim tables rows query tbl_123 --sort '[{"field":"createdAt","direction":"desc"}]' +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --sort '[{"field":"createdAt","direction":"desc"}]' ``` ## Pagination @@ -68,16 +82,18 @@ Deletions require an explicit selector **and** `--yes`. There is no "delete everything" default: ```bash -sim tables rows batch-delete tbl_123 --row row_1 row_2 --yes -sim files delete file_123 --yes +sim tables rows batch-delete tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --row row_2f81c0a94db54e6f8a13c7e0526bd94a row_6b3e59d0af1c42d7b80e94f3a271c568 --yes +sim files delete wf_8Kd2NpVrY6zTfQa3XwBmS --yes ``` Without `--yes` the command explains what it would have destroyed and stops. -`batch-delete` and `batch-update` carry the default `--limit` of `100`, so a -filter matching more rows than that silently affects only the first 100. Pass -`--limit 0` to affect every matching row. +On `batch-delete` and `batch-update`, `--limit` has no default and is not a page +size — it is a ceiling on how many matching rows the one call may touch. Leave it +off and the command acts on **every** row the filter matches, however many that +is. `--limit 0` is not the unbounded form here and is rejected; pass a whole +number of 1 or more to cap the blast radius, or omit the flag deliberately. ## Exit codes @@ -92,7 +108,7 @@ Errors print one line to stderr, prefixed `Error:`, plus the API's error code an validation details when it supplies them. Failures are safe to branch on: ```bash -if ! sim workflows run wf_7Yb2 --output json > result.json; then +if ! sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json > result.json; then echo "run failed" >&2 exit 1 fi @@ -117,11 +133,23 @@ esac ## Selecting workflow output -`--select-output` takes `blockName.field` selectors. Fields that a run did not -produce are simply omitted: +`--select-output` shapes a streamed result, so it requires `--follow`. It takes +`blockName.field` selectors; fields that a run did not produce are simply +omitted: + +```bash +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --follow --select-output agent_1.content --output json +``` + +Without `--follow` the CLI refuses the pair rather than spending a request on a +response that carries no outputs, and `--async` cannot be combined with it +either — there is no stream to shape. To narrow a run that has already finished, +read it back with `workflows runs get`, which matches block **ids** rather than +the block names `workflows run` takes: ```bash -sim workflows run wf_7Yb2 --select-output agent_1.content --output json +sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 \ + --select-output 1d4c8f02-7b63-4a19-8e52-63f0a7c5d9b1.content --output json ``` ## Polling a long run @@ -129,9 +157,9 @@ sim workflows run wf_7Yb2 --select-output agent_1.content --output json Start the run asynchronously, then poll its status: ```bash -run_id=$(sim workflows run wf_7Yb2 --async --output json | jq -r '.runId') +run_id=$(sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --async --output json | jq -r '.runId') -until sim workflows runs get "$run_id" --workflow wf_7Yb2 --output json \ +until sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json \ | jq -e '.status | IN("completed","failed","cancelled")' > /dev/null; do sleep 5 done @@ -169,9 +197,9 @@ export SIM_API_KEY="${SIM_API_KEY:?missing}" export SIM_WORKSPACE="${SIM_WORKSPACE:?missing}" export SIM_OUTPUT=json -run_id=$(sim workflows run wf_7Yb2 --input '{"source":"nightly"}' | jq -r '.runId') +run_id=$(sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' | jq -r '.runId') -if [ "$(sim workflows runs get "$run_id" --workflow wf_7Yb2 | jq -r '.status')" != "completed" ]; then +if [ "$(sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 | jq -r '.status')" != "completed" ]; then sim logs get "$run_id" >&2 exit 1 fi diff --git a/apps/docs/content/docs/en/cli/secrets.mdx b/apps/docs/content/docs/en/cli/secrets.mdx index 9971d7eb1ab..cde61e9b084 100644 --- a/apps/docs/content/docs/en/cli/secrets.mdx +++ b/apps/docs/content/docs/en/cli/secrets.mdx @@ -15,13 +15,15 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim secrets delete [options] ``` +Delete Secret (personal API key required) + **Arguments** | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `name` | Yes | Secret to delete. | @@ -42,6 +44,8 @@ sim secrets delete [options] sim secrets list [options] ``` +List Secrets (personal API key required) + **Options** diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index e6ffe5dc906..a9f969b869b 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim skills create [options] ``` +Create Skill (personal API key required) + **Options** @@ -33,6 +35,8 @@ sim skills create [options] sim skills delete [options] ``` +Delete Skill (personal API key required) + **Arguments** @@ -75,6 +79,8 @@ sim skills get sim skills editors create [options] ``` +Grant Skill Editor (personal API key required) + **Arguments** @@ -129,6 +135,8 @@ sim skills editors list [options] sim skills editors delete [options] ``` +Revoke Skill Editor (personal API key required) + **Arguments** @@ -175,6 +183,8 @@ sim skills list [options] sim skills update [options] ``` +Update Skill (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index a3804a0beab..1739a5ef1e8 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -211,7 +211,7 @@ sim tables batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--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. | @@ -319,8 +319,8 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | +| `--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. | @@ -507,7 +507,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 items to return (0 for everything). Defaults to `100`. | +| `--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. | @@ -588,11 +588,11 @@ sim tables dispatches create [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--max-rows ` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. | @@ -630,7 +630,7 @@ sim tables dispatches list -## Cancel table export +## Stop a running export ```bash sim tables exports cancel @@ -707,10 +707,10 @@ sim tables exports download -## Cancel table import +## Stop a running import ```bash -sim tables imports cancel +sim tables imports cancel [options] ``` **Arguments** @@ -723,6 +723,16 @@ sim tables imports cancel +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ## Get table import ```bash @@ -764,7 +774,7 @@ sim tables cancel-runs [options] | `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | +| `--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. | @@ -1077,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 `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--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`. | | `--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`. | @@ -1099,7 +1109,7 @@ sim tables move [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -1187,7 +1197,7 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges. (JSON, or @path / @- to read a file or stdin). | | `--on ` | No | Unique column to resolve the conflict against. | @@ -1222,6 +1232,7 @@ sim tables import [path] [options] | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | | `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `-y, --yes` | No | Confirm this destructive operation (required with --mode replace). | | `--no-wait` | No | Return once the import is queued instead of watching it. | diff --git a/apps/docs/content/docs/en/cli/troubleshooting.mdx b/apps/docs/content/docs/en/cli/troubleshooting.mdx index c8ecdcef039..8bf2dfd58c0 100644 --- a/apps/docs/content/docs/en/cli/troubleshooting.mdx +++ b/apps/docs/content/docs/en/cli/troubleshooting.mdx @@ -53,8 +53,8 @@ Your shell consumed the quotes. Wrap the whole value in single quotes, or read i from a file: ```bash -sim tables rows query tbl_123 --filter '{"all":[{"field":"status","op":"eq","value":"open"}]}' -sim tables rows query tbl_123 --filter @filter.json +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter '{"all":[{"field":"status","op":"eq","value":"open"}]}' +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json ``` ## A value looks truncated @@ -63,7 +63,7 @@ sim tables rows query tbl_123 --filter @filter.json switch to a machine format to see it in full: ```bash -sim logs get run_123 --output json +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json ``` ## `sim files get` refuses to print to the terminal @@ -72,8 +72,8 @@ Writing arbitrary binary to an interactive terminal can corrupt it, so non-text content has to go to a file or a pipe: ```bash -sim files get file_123 -o ./image.png -sim files get file_123 | shasum +sim files get wf_8Kd2NpVrY6zTfQa3XwBmS -o ./image.png +sim files get wf_8Kd2NpVrY6zTfQa3XwBmS | shasum ``` ## A stored output format is invalid 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 62a3528f07c..af1ff6add1b 100644 --- a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx @@ -13,6 +13,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflow-mcp-servers create [options] ``` +Create Workflow MCP Server (personal API key required) + **Options** @@ -23,7 +25,7 @@ sim workflow-mcp-servers create [options] | `--description ` | No | Optional server description. | | `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | | `--no-is-public` | No | Send --is-public as false. | -| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -33,6 +35,8 @@ sim workflow-mcp-servers create [options] sim workflow-mcp-servers delete [options] ``` +Delete Workflow MCP Server (personal API key required) + **Arguments** @@ -59,6 +63,8 @@ sim workflow-mcp-servers delete [options] sim workflow-mcp-servers tools create [options] ``` +Publish Workflow As MCP Tool (personal API key required) + **Arguments** @@ -88,6 +94,8 @@ sim workflow-mcp-servers tools create [options] sim workflow-mcp-servers tools list ``` +List Workflow MCP Tools (personal API key required) + **Arguments** @@ -104,6 +112,8 @@ sim workflow-mcp-servers tools list sim workflow-mcp-servers tools delete [options] ``` +Unpublish Workflow MCP Tool (personal API key required) + **Arguments** @@ -131,6 +141,8 @@ sim workflow-mcp-servers tools delete [options] sim workflow-mcp-servers get ``` +Get Workflow MCP Server (personal API key required) + **Arguments** @@ -147,6 +159,8 @@ sim workflow-mcp-servers get sim workflow-mcp-servers list [options] ``` +List Workflow MCP Servers (personal API key required) + **Options** @@ -165,6 +179,8 @@ sim workflow-mcp-servers list [options] sim workflow-mcp-servers update [options] ``` +Update Workflow MCP Server (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/workflows.mdx b/apps/docs/content/docs/en/cli/workflows.mdx index 648f28967e6..f622a8b7669 100644 --- a/apps/docs/content/docs/en/cli/workflows.mdx +++ b/apps/docs/content/docs/en/cli/workflows.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflows activate create ``` +Activate Workflow Version (personal API key required) + **Arguments** @@ -32,6 +34,8 @@ sim workflows activate create sim workflows operations apply [options] ``` +Apply Workflow Operations (personal API key required) + **Arguments** @@ -138,7 +142,7 @@ Show run status (requested outputs are included in JSON or YAML output) | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -357,6 +361,8 @@ sim workflows delete [options] sim workflows chat unpublish [options] ``` +Take a workflow’s chat deployment offline (personal API key required) + **Arguments** @@ -383,6 +389,8 @@ sim workflows chat unpublish [options] sim workflows chat status ``` +Show a workflow’s chat deployment (personal API key required) + **Arguments** @@ -399,6 +407,8 @@ sim workflows chat status sim workflows chat publish [options] ``` +Publish or replace a workflow’s chat deployment (personal API key required) + **Arguments** @@ -437,6 +447,8 @@ sim workflows chat publish [options] sim workflows deploy [options] ``` +Deploy Workflow (personal API key required) + **Arguments** @@ -510,7 +522,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | @@ -580,6 +592,8 @@ sim workflows deployment status sim workflows deployment update [options] ``` +Update Workflow Public API Access (personal API key required) + **Arguments** @@ -622,6 +636,8 @@ sim workflows state get sim workflows state replace [options] ``` +Replace Workflow State (personal API key required) + **Arguments** @@ -774,7 +790,7 @@ sim workflows move [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | Yes | Destination folder path; / moves the workflows to the workspace root. | @@ -801,6 +817,8 @@ sim workflows restore sim workflows revert create [options] ``` +Revert Workflow To Version (personal API key required) + **Arguments** @@ -828,6 +846,8 @@ sim workflows revert create [options] sim workflows rollback [options] ``` +Rollback Workflow (personal API key required) + **Arguments** @@ -855,6 +875,8 @@ sim workflows rollback [options] sim workflows undeploy [options] ``` +Take a workflow out of deployment (personal API key required) + **Arguments** diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 449217a46b5..88dd604ab00 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 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.", "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 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.", "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 `POST /files/{fileId}/restore`. Authorization is identical for both.", + "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.", "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 `POST /files/{fileId}/restore`. Authorization is identical for both.", + "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.", "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 `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "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.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "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.", "type": "string", "enum": ["active", "archived"] } @@ -2841,12 +2841,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", "examples": ["text/csv"] }, "key": { @@ -2888,7 +2888,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] } @@ -3673,12 +3673,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", "examples": ["text/csv"] }, "key": { @@ -3720,7 +3720,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] }, @@ -4430,7 +4430,7 @@ "description": "Workspace that owns the archived folder." }, "path": { - "description": "Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.", + "description": "Path of the archived folder to restore, as reported by an archived-scope folder list.", "$ref": "#/components/schemas/NonRootFolderPathInput" } }, diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 8bb1c1edfaa..d433c0ea73b 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -3481,7 +3481,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeChunks", "summary": "Bulk Update Chunks", - "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is skipped rather than failing the request, so `processed` is the authoritative count. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in `errors` rather than failing the request. `processed` counts the chunks the operation matched, not the chunks it changed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -6838,14 +6838,15 @@ "type": "object", "properties": { "recipe": { - "description": "Optional document processing recipe.", + "description": "Optional document processing recipe. One of: default, plain, markdown, code.", "type": "string", - "maxLength": 255 + "enum": ["default", "plain", "markdown", "code"] }, "lang": { - "description": "Optional document language code.", + "description": "Optional document language: hyphen-separated letter and digit subtags such as `en`, `en-US`, or `zh-Hant-TW`. Only that shape is validated, not full BCP-47 conformance.", "type": "string", - "maxLength": 35 + "maxLength": 35, + "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" } }, "additionalProperties": false @@ -7876,7 +7877,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Number of chunks the operation changed.", + "description": "Number of chunks in this document the operation matched. Chunks already in the requested state are counted too, so this is not a count of changes.", "examples": [12] }, "errors": { @@ -7884,7 +7885,7 @@ "items": { "type": "string" }, - "description": "Per-chunk failures. A populated array still answers 200." + "description": "Per-chunk failures, including any identifier that named no chunk in the document. A populated array still answers 200." } }, "required": ["operation", "processed", "errors"], @@ -7927,7 +7928,7 @@ "type": "string", "minLength": 1 }, - "description": "Chunks to operate on, by identifier. Ids outside the document are ignored." + "description": "Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request." } }, "required": ["workspaceId", "operation", "chunkIds"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 08535cf3ef1..1c723026223 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -421,7 +421,7 @@ "get": { "operationId": "getLogStats", "summary": "Get Log Statistics", - "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans the oldest matching run through the later of the newest matching run and now, divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -1779,7 +1779,7 @@ }, "required": ["start", "end"], "additionalProperties": false, - "description": "The window the buckets span: the oldest matching run through the later of the newest matching run and now. A workspace with no matching runs reports the trailing 24 hours." + "description": "The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width." }, "segmentMs": { "type": "number", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index bbc64e30435..402e5d1dfdd 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2715,26 +2715,26 @@ "put": { "operationId": "setSecret", "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit `value` on a workspace secret to update `description` and `unredacted` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers `404` when the named secret does not exist. A personal secret always requires `value`, having no other writable field. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { "name": "name", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Secret to create or replace.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Secret to create or replace." } } ], "requestBody": { "required": true, - "description": "Ownership scope and write-only value for the secret.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "content": { "application/json": { "schema": { @@ -2745,7 +2745,7 @@ }, "responses": { "200": { - "description": "The existing secret value was replaced.", + "description": "The existing secret value was replaced, or its metadata was updated in place.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2825,13 +2825,13 @@ "name": "name", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Secret to delete.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Secret to delete." } }, { @@ -7164,11 +7164,11 @@ "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." }, "value": { + "description": "Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.", + "writeOnly": true, "type": "string", "minLength": 1, - "maxLength": 65536, - "description": "Write-only secret value. It is never returned.", - "writeOnly": true + "maxLength": 65536 }, "description": { "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", @@ -7187,15 +7187,20 @@ "type": "boolean" } }, - "required": ["workspaceId", "scope", "value"], + "required": ["workspaceId", "scope"], "additionalProperties": false, "title": "Set secret request", - "description": "Ownership scope and write-only value for the secret.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "scope": "workspace", "value": "YOUR_SECRET_VALUE" + }, + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "unredacted": false } ] }, @@ -7393,7 +7398,7 @@ }, "toolNamesTruncated": { "type": "boolean", - "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read `GET /api/v2/workflow-mcp-servers/{serverId}/tools` for one server's inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." + "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read one server's tool inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." } }, "required": ["data", "nextCursor", "toolNamesTruncated"], @@ -8189,14 +8194,14 @@ "items": { "type": "string" }, - "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, "operationIds": { "type": "array", "items": { "type": "string" }, - "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + "description": "Operations this block exposes. Their fields and tools are on the block read." }, "preview": { "type": "boolean", @@ -8877,14 +8882,14 @@ "items": { "type": "string" }, - "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, "operationIds": { "type": "array", "items": { "type": "string" }, - "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + "description": "Operations this block exposes. Their fields and tools are on the block read." }, "preview": { "type": "boolean", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 52a8d00198b..25250fc7714 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 `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "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.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "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.", "type": "string", "enum": ["active", "archived"] } @@ -6400,7 +6400,7 @@ "description": "Unique workspace identifier." }, "data": { - "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.", + "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.", "$ref": "#/components/schemas/V2TableRowData" }, "conflictTarget": { @@ -7628,7 +7628,7 @@ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "group": { - "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Enrich company", "outputs": [ { @@ -9658,7 +9658,7 @@ "description": "Workspace that owns the archived folder." }, "path": { - "description": "Path the folder held when `DELETE /api/v2/tables/folders` archived it.", + "description": "Path the folder held when a folder delete archived it.", "$ref": "#/components/schemas/NonRootFolderPathInput" } }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 13416eb4837..eaee255cf16 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -338,7 +338,7 @@ "put": { "operationId": "replaceWorkflowState", "summary": "Replace Workflow State", - "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", + "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with `409` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", "tags": ["Workflows"], "parameters": [ { @@ -437,7 +437,7 @@ "post": { "operationId": "applyWorkflowOperations", "summary": "Apply Workflow Operations", - "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", + "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", "tags": ["Workflows"], "parameters": [ { diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 35649fa28f0..7231af0bfd7 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -72,7 +72,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter const bounds = await readLogStatsBounds(whereCondition) - const window = resolveLogStatsWindow(bounds, params.segmentCount) + const window = resolveLogStatsWindow(bounds, params.segmentCount, { + requestedStart: params.startDate ? new Date(params.startDate) : undefined, + requestedEnd: params.endDate ? new Date(params.endDate) : undefined, + }) const rows = await readLogStatsSegments( whereCondition, window.startTime.toISOString(), diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 8a9a60ef0fa..acdbfddb2a9 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -9,6 +9,7 @@ import { import { parseRequest } from '@/lib/api/server' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { chatPubSub } from '@/lib/copilot/chat-status' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly, createForbiddenResponse, @@ -78,7 +79,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, type: 'mothership', title: null, - model: 'claude-opus-4-8', + model: MOTHERSHIP_CHAT_DEFAULT_MODEL, updatedAt: now, lastSeenAt: now, }) diff --git a/apps/sim/app/api/v2/chat-deployments/route.test.ts b/apps/sim/app/api/v2/chat-deployments/route.test.ts index afe127de3c1..fa44381d548 100644 --- a/apps/sim/app/api/v2/chat-deployments/route.test.ts +++ b/apps/sim/app/api/v2/chat-deployments/route.test.ts @@ -295,12 +295,21 @@ describe('/api/v2/chat-deployments', () => { expect(response.status).toBe(200) }) - it('conceals a workspace the caller cannot reach as 404', async () => { + /** + * The list addresses a workspace, so the concealed denial must name the + * workspace. Naming a chat deployment reported a resource the caller never + * asked for. Concealment itself is unchanged: still 404, still no signal + * about whether the workspace holds any deployment. + */ + it('conceals a workspace the caller cannot reach as a missing workspace', async () => { mocks.resolvePermission.mockResolvedValue(null) const response = await get() expect(response.status).toBe(404) + const body = await response.json() + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.message).toBe('Workspace not found') expect(mocks.listDeployments).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/chat-deployments/route.ts b/apps/sim/app/api/v2/chat-deployments/route.ts index 660db2aa332..b6e20424d1e 100644 --- a/apps/sim/app/api/v2/chat-deployments/route.ts +++ b/apps/sim/app/api/v2/chat-deployments/route.ts @@ -3,7 +3,7 @@ import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { chatDeploymentOperations, listChatDeployments } from '@/lib/chat-deployments/application' import { - chatDeploymentErrorPolicy, + chatDeploymentWorkspaceErrorPolicy, toV2ChatDeploymentListItem, } from '@/app/api/v2/chat-deployments/utils' import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' @@ -48,7 +48,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: chatDeploymentOperations.list, rateLimit: v2RateLimits.publicApi, - errorPolicy: chatDeploymentErrorPolicy, + errorPolicy: chatDeploymentWorkspaceErrorPolicy, mapInput: ({ query }) => ({ workspaceId: query.workspaceId, workflowId: query.workflowId, diff --git a/apps/sim/app/api/v2/chat-deployments/utils.ts b/apps/sim/app/api/v2/chat-deployments/utils.ts index 160cc4cafef..85226313231 100644 --- a/apps/sim/app/api/v2/chat-deployments/utils.ts +++ b/apps/sim/app/api/v2/chat-deployments/utils.ts @@ -135,3 +135,13 @@ export function toV2ChatDeploymentListItem( export const chatDeploymentErrorPolicy = createV2ResourceConcealmentPolicy({ notFoundMessage: 'Chat deployment not found', }) + +/** + * The list is addressed by workspace, not by deployment, so a concealed + * cross-tenant denial must name the workspace the caller asked for. Concealment + * itself is unchanged — an unreachable workspace still answers 404 whether or + * not it holds any deployment. + */ +export const chatDeploymentWorkspaceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 5319c3e0913..945e2e8d564 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -3,6 +3,8 @@ */ import { createMockRequest } from '@sim/testing' +import { sleep } from '@sim/utils/helpers' +import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -14,8 +16,10 @@ const { mockCheckOperationRate, mockCheckPreAuthRate, mockGenerateId, + mockPersistCopilotChatTurn, mockRequestExplicitStreamAbort, mockResolveBillingAttribution, + mockResolveOrCreateChat, mockRunHeadlessCopilotLifecycle, } = vi.hoisted(() => ({ MockV2ApiKeyUnauthenticatedError: class MockV2ApiKeyUnauthenticatedError extends Error {}, @@ -34,7 +38,9 @@ const { mockCheckOperationRate: vi.fn(), mockCheckPreAuthRate: vi.fn(), mockGenerateId: vi.fn(), + mockPersistCopilotChatTurn: vi.fn(), mockResolveBillingAttribution: vi.fn(), + mockResolveOrCreateChat: vi.fn(), mockRequestExplicitStreamAbort: vi.fn().mockResolvedValue(undefined), mockRunHeadlessCopilotLifecycle: vi.fn(), })) @@ -78,6 +84,14 @@ vi.mock('@/lib/copilot/chat/workspace-context', () => ({ generateWorkspaceContext: vi.fn().mockResolvedValue('workspace context'), })) +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + resolveOrCreateChat: mockResolveOrCreateChat, +})) + +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + persistCopilotChatTurn: mockPersistCopilotChatTurn, +})) + vi.mock('@/lib/copilot/chat/payload', () => ({ buildIntegrationToolSchemas: vi.fn().mockResolvedValue([{ name: 'run_workflow' }]), })) @@ -111,9 +125,24 @@ const personalAuth = { keyType: 'personal', } +/** + * The route never echoes the caller's string back as the conversation id: it + * reports whatever the owner-scoped resolver returns. + */ +const SERVER_ISSUED_CHAT_ID = 'chat-server-1' +const OWNED_CONVERSATION_ID = '11111111-1111-4111-8111-111111111111' + +/** How long a stream read may stay pending before it counts as "nothing more yet". */ +const IDLE_STREAM_MS = 20 + +function chatRow(id: string) { + return { id, userId: 'user-1', workspaceId: 'workspace-1', workflowId: null, type: 'mothership' } +} + const successResult = { success: true, content: 'Hello there', + contentBlocks: [], toolCalls: [{ name: 'run_workflow' }, { name: 'internal_only' }], usage: { prompt: 10, completion: 5 }, cost: { total: 0.01 }, @@ -124,6 +153,79 @@ function callChat(body: Record, headers: Record return POST(req, { params: Promise.resolve({}) }) } +/** + * Same call, but over a request whose signal the test controls — the only way + * to reproduce a caller that hangs up while the turn is still running. + */ +function callChatWithSignal( + body: Record, + signal: AbortSignal, + headers: Record = {} +) { + const req = new NextRequest(new URL('http://localhost:3000/api/v2/chat'), { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/json', + 'X-API-Key': 'test-key', + ...headers, + }), + body: JSON.stringify(body), + signal, + }) + return POST(req, { params: Promise.resolve({}) }) +} + +/** + * Read an NDJSON response incrementally. `drain()` returns the events that have + * already reached the caller and stops as soon as the producer goes quiet; + * `rest()` reads to the end. A pending read is held across calls so no chunk is + * dropped between the two. + */ +function readNdjsonStream(response: Response) { + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffered = '' + let pending: Promise> | null = null + + const parse = (): Array> => { + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + return lines.filter((line) => line.trim().length > 0).map((line) => JSON.parse(line)) + } + + const step = async (): Promise<'idle' | 'done' | 'chunk'> => { + pending ??= reader.read() + const settled = await Promise.race([ + pending.then((result) => ({ result })), + sleep(IDLE_STREAM_MS).then(() => null), + ]) + if (!settled) return 'idle' + pending = null + if (settled.result.done) return 'done' + buffered += decoder.decode(settled.result.value, { stream: true }) + return 'chunk' + } + + return { + async drain() { + const events: Array> = [] + for (;;) { + const state = await step() + events.push(...parse()) + if (state !== 'chunk') return events + } + }, + async rest() { + const events: Array> = [] + for (;;) { + const state = await step() + events.push(...parse()) + if (state === 'done') return events + } + }, + } +} + async function readNdjsonEvents(response: Response): Promise>> { const raw = await response.text() return raw @@ -143,7 +245,14 @@ describe('POST /api/v2/chat', () => { mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) mockResolveBillingAttribution.mockResolvedValue(billingAttributionSnapshot) mockRequestExplicitStreamAbort.mockResolvedValue(undefined) + mockPersistCopilotChatTurn.mockResolvedValue(undefined) mockRunHeadlessCopilotLifecycle.mockResolvedValue(successResult) + mockResolveOrCreateChat.mockResolvedValue({ + chatId: SERVER_ISSUED_CHAT_ID, + chat: chatRow(SERVER_ISSUED_CHAT_ID), + conversationHistory: [], + isNew: true, + }) }) it('rejects a missing or invalid API key', async () => { @@ -187,15 +296,15 @@ describe('POST /api/v2/chat', () => { expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() }) - it('runs one turn and answers the reply with a generated conversation id', async () => { + it('runs one turn and answers the reply with a server-issued conversation id', async () => { const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) expect(response.status).toBe(200) const body = await response.json() expect(body.data).toEqual({ content: 'Hello there', - model: 'mothership', - conversationId: 'generated-1', + model: 'sim', + conversationId: SERVER_ISSUED_CHAT_ID, tokens: { prompt: 10, completion: 5, total: 15 }, cost: { total: 0.01 }, toolCalls: [{ name: 'run_workflow' }], @@ -206,7 +315,7 @@ describe('POST /api/v2/chat', () => { messages: [{ role: 'user', content: 'hi' }], userId: 'user-1', workspaceId: 'workspace-1', - chatId: 'generated-1', + chatId: SERVER_ISSUED_CHAT_ID, mode: 'agent', isHosted: true, workspaceContext: 'workspace context', @@ -216,7 +325,7 @@ describe('POST /api/v2/chat', () => { expect(options).toMatchObject({ userId: 'user-1', workspaceId: 'workspace-1', - chatId: 'generated-1', + chatId: SERVER_ISSUED_CHAT_ID, goRoute: '/api/mothership/execute', autoExecuteTools: true, interactive: false, @@ -230,17 +339,151 @@ describe('POST /api/v2/chat', () => { }) }) - it('continues the conversation the caller names', async () => { + it('mints a server-issued conversation when the caller names none', async () => { + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.conversationId).toBe(SERVER_ISSUED_CHAT_ID) + const resolverInput = mockResolveOrCreateChat.mock.calls[0][0] as Record + expect(Object.hasOwn(resolverInput, 'chatId')).toBe(false) + expect(resolverInput).toMatchObject({ + userId: 'user-1', + workspaceId: 'workspace-1', + type: 'mothership', + }) + }) + + it('resolves a named conversation against the calling user and workspace before continuing it', async () => { + mockResolveOrCreateChat.mockResolvedValue({ + chatId: OWNED_CONVERSATION_ID, + chat: chatRow(OWNED_CONVERSATION_ID), + conversationHistory: [], + isNew: false, + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: OWNED_CONVERSATION_ID, + }) + + expect(response.status).toBe(200) + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: OWNED_CONVERSATION_ID, + userId: 'user-1', + workspaceId: 'workspace-1', + }) + ) + const body = await response.json() + expect(body.data.conversationId).toBe(OWNED_CONVERSATION_ID) + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ + chatId: OWNED_CONVERSATION_ID, + }) + }) + + it('posts only the current turn on a resumed conversation, never the stored transcript', async () => { + mockResolveOrCreateChat.mockResolvedValue({ + chatId: OWNED_CONVERSATION_ID, + chat: chatRow(OWNED_CONVERSATION_ID), + conversationHistory: [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'first reply' }, + ], + isNew: false, + }) + const response = await callChat({ workspaceId: 'workspace-1', message: 'and then?', - conversationId: 'conv-9', + conversationId: OWNED_CONVERSATION_ID, }) expect(response.status).toBe(200) + // Continuity is keyed by chatId downstream, exactly as the web send path + // and the Sim Chat block do. Replaying the transcript here would duplicate + // every prior turn. + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ + messages: [{ role: 'user', content: 'and then?' }], + chatId: OWNED_CONVERSATION_ID, + }) + }) + + it('answers 404 and runs nothing when the resolver refuses the named conversation', async () => { + mockResolveOrCreateChat.mockResolvedValue({ + chatId: OWNED_CONVERSATION_ID, + chat: null, + conversationHistory: [], + isNew: false, + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: OWNED_CONVERSATION_ID, + }) + + expect(response.status).toBe(404) const body = await response.json() - expect(body.data.conversationId).toBe('conv-9') - expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ chatId: 'conv-9' }) + expect(body.error.code).toBe('NOT_FOUND') + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: OWNED_CONVERSATION_ID, + userId: 'user-1', + workspaceId: 'workspace-1', + }) + ) + // No tokens may be billed against an id the caller could not be given. + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('asks the resolver for a mothership conversation, so another type resolves to nothing', async () => { + await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: OWNED_CONVERSATION_ID, + }) + + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ chatId: OWNED_CONVERSATION_ID, type: 'mothership' }) + ) + }) + + it('titles a new conversation by its first message so the web Chat list has no blank row', async () => { + await callChat({ workspaceId: 'workspace-1', message: ' Summarize\n last week ' }) + + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Summarize last week' }) + ) + }) + + it('truncates a long first message into a title instead of storing the whole message', async () => { + const message = 'a'.repeat(500) + + await callChat({ workspaceId: 'workspace-1', message }) + + const title = (mockResolveOrCreateChat.mock.calls[0][0] as { title?: string }).title + expect(title).toBe(`${'a'.repeat(80)}...`) + }) + + it('leaves the title unset for a whitespace-only message rather than stamping an empty one', async () => { + await callChat({ workspaceId: 'workspace-1', message: ' \n ' }) + + const resolverInput = mockResolveOrCreateChat.mock.calls[0][0] as Record + expect(Object.hasOwn(resolverInput, 'title')).toBe(false) + }) + + it('rejects a malformed conversation id before resolving anything', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: 'not-a-conversation-id', + }) + + expect(response.status).toBe(400) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() }) it('answers a failed run as a 500 with the run error', async () => { @@ -282,11 +525,18 @@ describe('POST /api/v2/chat', () => { expect(chunks.map((chunk) => chunk.content)).toEqual(['Hello', ' there']) const final = events.at(-1) as { type: string; data: Record } expect(final.type).toBe('final') - expect(final.data).toMatchObject({ content: 'Hello there', conversationId: 'generated-1' }) + expect(final.data).toMatchObject({ + content: 'Hello there', + conversationId: SERVER_ISSUED_CHAT_ID, + }) }) it('ends the NDJSON stream with an error event when the run fails', async () => { - mockRunHeadlessCopilotLifecycle.mockResolvedValue({ success: false, error: 'model exploded' }) + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: false, + error: 'model exploded', + contentBlocks: [], + }) const response = await callChat( { workspaceId: 'workspace-1', message: 'hi' }, @@ -299,4 +549,134 @@ describe('POST /api/v2/chat', () => { expect(last.type).toBe('error') expect(last.error).toBe('model exploded') }) + + it('persists both sides of the turn so the conversation is not an empty transcript', async () => { + await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) + const [chatId, messages] = mockPersistCopilotChatTurn.mock.calls[0] + expect(chatId).toBe(SERVER_ISSUED_CHAT_ID) + expect(messages.map((m: { role: string; content: string }) => [m.role, m.content])).toEqual([ + ['user', 'hi'], + ['assistant', 'Hello there'], + ]) + }) + + it('persists the turn on the NDJSON path before the final event reaches the caller', async () => { + // Hold the transcript write open and watch the wire: draining the whole + // response first would pass just as happily with the write moved after the + // final event, so the write is gated and the stream read incrementally. + let persistEntered: () => void = () => {} + const persistInFlight = new Promise((resolve) => { + persistEntered = resolve + }) + let releasePersist: () => void = () => {} + mockPersistCopilotChatTurn.mockImplementation(() => { + persistEntered() + return new Promise((resolve) => { + releasePersist = resolve + }) + }) + + const response = await callChat( + { workspaceId: 'workspace-1', message: 'hi' }, + { accept: 'application/x-ndjson' } + ) + const stream = readNdjsonStream(response) + + await persistInFlight + const beforeRelease = await stream.drain() + expect(beforeRelease.map((event) => event.type)).not.toContain('final') + + releasePersist() + const afterRelease = await stream.rest() + expect(afterRelease.at(-1)?.type).toBe('final') + + expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) + expect(mockPersistCopilotChatTurn.mock.calls[0][1]).toHaveLength(2) + }) + + it('persists a completed turn whose caller hung up, and still reports it as client-closed', async () => { + const controller = new AbortController() + mockRunHeadlessCopilotLifecycle.mockImplementation(async () => { + controller.abort() + return successResult + }) + + const response = await callChatWithSignal( + { workspaceId: 'workspace-1', message: 'hi' }, + controller.signal + ) + + // The model already ran and was billed, so the reply is written to the + // conversation it belongs to — but the caller is gone, and the status the + // route reports says exactly that. + expect(response.status).toBe(499) + const body = await response.json() + expect(body.error.code).toBe('CLIENT_CLOSED_REQUEST') + expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) + expect(mockPersistCopilotChatTurn.mock.calls[0][0]).toBe(SERVER_ISSUED_CHAT_ID) + }) + + it('persists a completed turn whose NDJSON caller hung up, and still ends in an abort event', async () => { + const controller = new AbortController() + mockRunHeadlessCopilotLifecycle.mockImplementation(async () => { + controller.abort() + return successResult + }) + + const response = await callChatWithSignal( + { workspaceId: 'workspace-1', message: 'hi' }, + controller.signal, + { accept: 'application/x-ndjson' } + ) + const events = await readNdjsonEvents(response) + + const last = events.at(-1) as { type: string; error?: string } + expect(last.type).toBe('error') + expect(last.error).toBe('Chat request aborted') + expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) + }) + + it('persists nothing for a failed run whose caller hung up', async () => { + const controller = new AbortController() + mockRunHeadlessCopilotLifecycle.mockImplementation(async () => { + controller.abort() + return { success: false, error: 'model exploded' } + }) + + const response = await callChatWithSignal( + { workspaceId: 'workspace-1', message: 'hi' }, + controller.signal + ) + + expect(response.status).toBe(499) + expect(mockPersistCopilotChatTurn).not.toHaveBeenCalled() + }) + + it('persists nothing when the run fails, so no question is stored without its answer', async () => { + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: false, + error: 'model exploded', + contentBlocks: [], + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(500) + expect(mockPersistCopilotChatTurn).not.toHaveBeenCalled() + }) + + it('still answers the caller when persisting the transcript fails', async () => { + mockPersistCopilotChatTurn.mockRejectedValue(new Error('transcript write failed')) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toMatchObject({ + content: 'Hello there', + conversationId: SERVER_ISSUED_CHAT_ID, + }) + }) }) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index a7df7680448..7c5f097f068 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { truncate } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { v2ChatContract } from '@/lib/api/contracts/v2/chat' import { parseRequest } from '@/lib/api/server' @@ -13,8 +14,15 @@ import { } from '@/lib/api/server/routes' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { chatOperations } from '@/lib/copilot/application/operations' +import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' +import { persistCopilotChatTurn } from '@/lib/copilot/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { + buildPersistedAssistantMessage, + buildPersistedUserMessage, +} from '@/lib/copilot/chat/persisted-message' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { type CopilotEnvironmentContext, @@ -26,7 +34,7 @@ import { } from '@/lib/copilot/generated/mothership-stream-v1' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' -import type { StreamEvent } from '@/lib/copilot/request/types' +import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -48,6 +56,25 @@ const CHAT_STREAM_VALUE = 'ndjson' const CHAT_HEARTBEAT_INTERVAL_MS = 15_000 const ndjsonEncoder = new TextEncoder() +/** + * Longest title derived from a first message. Well under the 200-character + * ceiling the rename contract enforces, and short enough to read as one line + * in the web Chat list. + */ +const CHAT_TITLE_MAX_LENGTH = 80 + +/** + * Title a conversation this route creates by its first message, so a `sim chat` + * turn does not leave a blank row at the top of the user's web Chat list. + * Returns undefined for a message that is only whitespace, leaving the title + * unset rather than stamping an empty one. + */ +function deriveConversationTitle(message: string): string | undefined { + const normalized = message.replace(/\s+/g, ' ').trim() + if (!normalized) return undefined + return truncate(normalized, CHAT_TITLE_MAX_LENGTH) +} + function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } @@ -69,7 +96,7 @@ function encodeNdjson(value: unknown): Uint8Array { * tool calls the run surfaced. */ function buildChatResultPayload( - result: Awaited>, + result: OrchestratorResult, conversationId: string, integrationTools: Array<{ name: string }> ) { @@ -80,7 +107,7 @@ function buildChatResultPayload( return { content: result.content ?? '', - model: 'mothership', + model: 'sim', conversationId, tokens: result.usage ? { @@ -130,14 +157,65 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { workspaceId, message, conversationId } = parsed.data.body - const chatId = conversationId || generateId() const messageId = generateId() const requestId = generateId() - const reqLogger = logger.withMetadata({ chatId, messageId, requestId }) + let reqLogger = logger.withMetadata({ messageId, requestId }) try { const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) const userPermission = workspaceAccess.permission + + const conversationTitle = deriveConversationTitle(message) + + // A caller-supplied conversation id is a claim, not an identity: resolve + // it through the same owner- and workspace-scoped loader the web Chat + // surface uses, and refuse every id that does not resolve with the same + // response so the refusal carries no information about the id. Omitting + // the id mints a server-issued conversation instead of trusting one. + // The resolved transcript is deliberately not forwarded: continuity is + // keyed by `chatId` downstream, exactly as the web send path and the Sim + // Chat block do, both of which post a single message with a chat id. + const resolvedChat = await resolveOrCreateChat({ + ...(conversationId ? { chatId: conversationId } : {}), + userId, + workspaceId, + model: MOTHERSHIP_CHAT_DEFAULT_MODEL, + type: 'mothership', + ...(conversationTitle ? { title: conversationTitle } : {}), + }) + if (conversationId && !resolvedChat.chat) { + return v2Error('NOT_FOUND', 'Conversation not found') + } + if (!resolvedChat.chat || !resolvedChat.chatId) { + reqLogger.error('Failed to start a chat conversation', { userId, workspaceId }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + const chatId = resolvedChat.chatId + reqLogger = logger.withMetadata({ chatId, messageId, requestId }) + + /** + * Write this turn's display copy to the conversation the route just + * resolved. Without it a `sim chat` turn leaves a titled conversation + * that opens to an empty transcript in the web Chat list. + * + * By the time this runs the turn has completed and been billed, so a + * write failure is logged and the response the caller gets is unchanged. + * The write is one transaction, so that failure leaves the transcript + * empty rather than showing the question without the answer. + */ + const persistTurn = async (result: OrchestratorResult): Promise => { + try { + await persistCopilotChatTurn(chatId, [ + buildPersistedUserMessage({ id: messageId, content: message }), + buildPersistedAssistantMessage(result, requestId), + ]) + } catch (error) { + reqLogger.error('Failed to persist chat transcript', { + error: getErrorMessage(error, 'Unknown error'), + }) + } + } + const secretMountPolicy = normalizeSecretMountPolicy(undefined) let environmentContext: CopilotEnvironmentContext | undefined @@ -276,6 +354,13 @@ export const POST = withRouteHandler( }) allowExplicitAbort = false + // Persist before the cancellation check: the turn ran and was + // billed, so the reply belongs in the transcript even when the + // caller stopped listening — that is the only place it survives. + if (result.success) { + await persistTurn(result) + } + if (lifecycleAbortController.signal.aborted) { send({ type: 'error', error: 'Chat request aborted' }) return @@ -347,6 +432,14 @@ export const POST = withRouteHandler( const result = await runLifecycle() allowExplicitAbort = false + // Persist before the cancellation check: the turn ran and was billed, + // so the reply belongs in the transcript even when the caller stopped + // listening — that is the only place it survives. The cancellation + // check still decides the status the caller receives. + if (result.success) { + await persistTurn(result) + } + if (lifecycleAbortController.signal.aborted || req.signal.aborted) { reqLogger.info('Chat request aborted after lifecycle completion') return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request aborted') diff --git a/apps/sim/app/api/v2/secrets/[name]/route.test.ts b/apps/sim/app/api/v2/secrets/[name]/route.test.ts index 19a7cd79edf..74197f93dca 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.test.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts @@ -206,6 +206,59 @@ describe('/api/v2/secrets/[name]', () => { expect(response.status).toBe(200) }) + it('sends a value-less workspace write through as a metadata-only update at 200', async () => { + mocks.set.mockResolvedValueOnce({ secret, userId: 'user-1', created: false }) + + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace', unredacted: false }), + context + ) + + expect(response.status).toBe(200) + expect(mocks.set).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: SECRET_NAME, + scope: 'workspace', + unredacted: false, + }, + request: expect.anything(), + }) + expect(mocks.set.mock.calls[0][0].input).not.toHaveProperty('value') + }) + + it('answers 404 rather than creating when a metadata-only write names no secret', async () => { + mocks.set.mockRejectedValueOnce(new OrchestrationError('not_found', 'Secret not found')) + + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace', unredacted: true }), + context + ) + + expect(response.status).toBe(404) + }) + + it('rejects a value-less personal write, which has no metadata field to update', async () => { + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'personal' }), + context + ) + + expect(response.status).toBe(400) + expect(mocks.set).not.toHaveBeenCalled() + }) + + it('rejects a workspace write carrying nothing to write', async () => { + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace' }), + context + ) + + expect(response.status).toBe(400) + expect(mocks.set).not.toHaveBeenCalled() + }) + it('deletes a secret through the semantic delete operation', async () => { const response = await DELETE(request('DELETE'), context) diff --git a/apps/sim/app/api/v2/secrets/[name]/route.ts b/apps/sim/app/api/v2/secrets/[name]/route.ts index 35cfd3d42f4..385274a7ac9 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.ts @@ -12,7 +12,10 @@ import { toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** PUT /api/v2/secrets/[name] — Create or replace a write-only secret value. */ +/** + * PUT /api/v2/secrets/[name] — Create or replace a write-only secret value, or + * update a workspace secret's metadata alone when the body carries no value. + */ export const PUT = defineV2JsonRoute({ contract: v2SetSecretContract, operation: secretOperations.set, diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts index 190d930ca5f..8df0265bdc6 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -3,13 +3,9 @@ import { v2WorkspaceMemberCursorSchema, } from '@/lib/api/contracts/v2/workspaces' import { cursorRoute, cursorScopeKey, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies' import { listPublicWorkspaceMembers } from '@/lib/workspaces/application/list-public-workspace-members' import { workspaceOperations } from '@/lib/workspaces/application/operations' import { @@ -39,7 +35,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workspaceOperations.listPublicMembers, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2WorkspaceErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ params, query }) => { const inner = readScopedCursor(query.cursor, memberCursorScope(params.workspaceId)) const decoded = inner ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(inner)) : undefined diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts new file mode 100644 index 00000000000..a0a0b4f2c93 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getWorkspace: vi.fn(), + listMembers: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) + +vi.mock('@/lib/workspaces/application/get-public-workspace', () => ({ + getPublicWorkspace: { + operation: { id: 'workspaces.read_public_detail' }, + execute: mocks.getWorkspace, + }, +})) + +vi.mock('@/lib/workspaces/application/list-public-workspace-members', () => ({ + listPublicWorkspaceMembers: { + operation: { id: 'workspaces.members.list_public' }, + execute: mocks.listMembers, + }, +})) + +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET as listMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' +import { GET as getWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +/** + * The two reads a workspace id is addressable through. Both must conceal the + * same way, or the pair that still answers `403` is the oracle. + */ +const routes = [ + { + name: 'workspace detail', + spy: mocks.getWorkspace, + call: () => + getWorkspace(new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`), { + params: Promise.resolve({ workspaceId: WORKSPACE_ID }), + }), + }, + { + name: 'member roster', + spy: mocks.listMembers, + call: () => + listMembers( + new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members`), + { params: Promise.resolve({ workspaceId: WORKSPACE_ID }) } + ), + }, +] as const + +describe.each(routes)('v2 $name workspace concealment', ({ spy, call }) => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + }) + + /** + * Asserted as equality between the two responses rather than against a + * literal: the leak is the DIFFERENCE, so a future rewording of either leg + * must not be able to reintroduce it while the test still passes. + */ + it.each([ + ['a workspace key scoped elsewhere', () => new WorkspaceApiKeyScopeAuthorizationError()], + ['a non-member personal key', () => new NoWorkspaceAccessError()], + ])('answers an unreachable workspace exactly as an absent one for %s', async (_label, raise) => { + spy.mockRejectedValueOnce(new OrchestrationError('not_found', 'Workspace not found')) + const absent = await call() + const absentBody = await absent.json() + + spy.mockRejectedValueOnce(raise()) + const unreachable = await call() + + expect(unreachable.status).toBe(absent.status) + expect(await unreachable.json()).toEqual(absentBody) + expect(absent.status).toBe(404) + expect(absentBody).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) + + /** + * The negative leg. A caller already inside the workspace knows it exists, so + * a role refusal stays an actionable `403` — concealing it too would widen the + * policy past what it is for. + */ + it('still refuses an in-workspace role denial with 403', async () => { + spy.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + + const response = await call() + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' } }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts index ecfdd075bde..bdcd9092d8b 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -1,10 +1,6 @@ import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies' import { getPublicWorkspace } from '@/lib/workspaces/application/get-public-workspace' import { workspaceOperations } from '@/lib/workspaces/application/operations' @@ -14,7 +10,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workspaceOperations.readPublicDetail, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2WorkspaceErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ params }) => ({ workspaceId: params.workspaceId }), useCase: getPublicWorkspace, present: ({ workspace }) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index 90ae6736e20..1113e13cbc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import type { V2KnowledgeDocumentSummary } from '@/lib/api/contracts/v2/knowledge' +import type { KnowledgeDocumentUploadRecipe } from '@/lib/knowledge/upload-metadata' import { assertMultiFileUploadAdmission, MultiFileUploadAdmissionError, @@ -49,7 +50,7 @@ export interface UploadError { } export interface ProcessingOptions { - recipe?: string + recipe?: KnowledgeDocumentUploadRecipe } export interface UseKnowledgeUploadOptions { diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx index 8fe37dcfe52..dea6a7c881c 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -58,7 +58,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { const editors = useSkillEditorsController({ skillId, workspaceId, - // Built-ins have no editors; skip the roster fetch (it would 404). + // Built-ins have no editors; skip the roster fetch (it would be refused). enabled: !!skill && !isBuiltin, }) const canEdit = !isBuiltin && !!skill?.canEdit diff --git a/apps/sim/lib/api/contracts/knowledge/documents.test.ts b/apps/sim/lib/api/contracts/knowledge/documents.test.ts index f39274830ce..5c0510f5dc4 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.test.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.test.ts @@ -3,8 +3,10 @@ */ import { describe, expect, it } from 'vitest' import { + bulkCreateDocumentsBodySchema, listKnowledgeDocumentsQuerySchema, parseDocumentTagFiltersParam, + upsertDocumentBodySchema, } from '@/lib/api/contracts/knowledge/documents' describe('listKnowledgeDocumentsQuerySchema.tagFilters', () => { @@ -143,3 +145,68 @@ describe('parseDocumentTagFiltersParam', () => { expect(parseDocumentTagFiltersParam(JSON.stringify(filters))).toEqual(filters) }) }) + +/** + * `recipe` and `lang` reach analytics and nothing else, so an unrecognised value + * was accepted with a 200 and silently discarded. Both internal write bodies + * reuse the upload boundary's validated shape, which is what every first-party + * caller already sends. + */ +describe('internal document processingOptions', () => { + const DOCUMENT = { + filename: 'notes.txt', + fileUrl: 'https://example.com/notes.txt', + fileSize: 12, + mimeType: 'text/plain', + } + + const boundaries = [ + { + name: 'bulk create', + parse: (processingOptions: unknown) => + bulkCreateDocumentsBodySchema.safeParse({ + documents: [DOCUMENT], + bulk: true, + processingOptions, + }), + }, + { + name: 'upsert', + parse: (processingOptions: unknown) => + upsertDocumentBodySchema.safeParse({ ...DOCUMENT, processingOptions }), + }, + ] as const + + describe.each(boundaries)('$name', ({ parse }) => { + it('accepts what the shipped first-party callers send', () => { + expect(parse({ recipe: 'default', lang: 'en' }).success).toBe(true) + }) + + it('rejects an unrecognised recipe instead of discarding it', () => { + const result = parse({ recipe: 'super-chunker-9000', lang: 'en' }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(['processingOptions', 'recipe']) + }) + + it('rejects a lang outside the enforced subtag shape', () => { + const result = parse({ recipe: 'default', lang: 'en_US' }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(['processingOptions', 'lang']) + }) + + /** + * The shape these reuse is strict, so an option neither boundary implements + * is now a 400 rather than a key stripped on the way through — the behaviour + * change that reusing the upload shape brought with it. + */ + it('rejects an option key neither boundary implements rather than stripping it', () => { + const result = parse({ recipe: 'default', chunkSize: 512 }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]).toMatchObject({ + code: 'unrecognized_keys', + path: ['processingOptions'], + keys: ['chunkSize'], + }) + }) + }) +}) diff --git a/apps/sim/lib/api/contracts/knowledge/documents.ts b/apps/sim/lib/api/contracts/knowledge/documents.ts index 028526b7654..bc8ad27d841 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.ts @@ -17,6 +17,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import { getFieldTypeForSlot, MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE } from '@/lib/knowledge/constants' import { getOperatorsForFieldType, isValidFilterValue } from '@/lib/knowledge/filters/types' +import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' export const documentTagFilterSchema = z .object({ @@ -136,12 +137,7 @@ export const bulkCreateDocumentsBodySchema = z.object({ MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE, `At most ${MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE} documents may be created at once` ), - processingOptions: z - .object({ - recipe: z.string().optional(), - lang: z.string().optional(), - }) - .optional(), + processingOptions: knowledgeDocumentUploadMetadataSchema.shape.processingOptions, bulk: z.literal(true), workflowId: z.string().optional(), [PRIVATE_SECRET_PROVENANCE_FIELD]: privateSecretProvenanceBundleSchema.optional(), @@ -173,12 +169,7 @@ export const upsertDocumentBodySchema = z.object({ fileSize: z.number().min(1, 'File size must be greater than 0'), mimeType: z.string().min(1, 'MIME type is required'), documentTagsData: z.string().optional(), - processingOptions: z - .object({ - recipe: z.string().optional(), - lang: z.string().optional(), - }) - .optional(), + processingOptions: knowledgeDocumentUploadMetadataSchema.shape.processingOptions, workflowId: z.string().optional(), [PRIVATE_SECRET_PROVENANCE_FIELD]: privateSecretProvenanceBundleSchema.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 new file mode 100644 index 00000000000..10a3125753d --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { listContractFiles } from '@/lib/api/contracts/v2/__tests__/contract-sweep' +import { MAX_SCHEMA_DEPTH } from '@/lib/api/contracts/v2/__tests__/schema-introspection' + +/** + * Every v2 schema description is user-facing prose on two surfaces at once: the + * published API reference and `sim --help`, which is generated from + * these exact strings. A description spelling an HTTP method and path tells a + * CLI caller to do something the CLI cannot do, so descriptions name the + * operation and its object rather than the transport. + * + * This is a sweep rather than a handful of per-file assertions because the + * strings that regressed last time sat a few lines from ones already fixed by + * hand. Anything deliberately left alone goes in ALLOWED below with its reason, + * and the sweep fails when an allowlisted description no longer appears, so the + * list cannot rot. + * + * Allowlisting is keyed by the description text, not by schema path: these + * schemas are shared between contracts, so one sentence surfaces under many + * paths and fixing it must clear every one of them at once. + */ + +const ENDPOINT_SPELLING = /\b(GET|POST|PATCH|PUT|DELETE)\s+\// + +/** + * Descriptions still naming a transport, deferred rather than endorsed. Each one + * lives in a v2 contract file this change does not touch, and the reason names + * that file so a later pass knows where to go. The second test below fails as + * soon as one of these stops offending, so a fix elsewhere cannot leave a stale + * entry behind. + */ +const ALLOWED = new Map([ + [ + 'Tag definition identifier. Published because `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by it; without it those operations are unreachable from a list read.', + 'not touched here: lives in v2/knowledge.ts', + ], + [ + 'Tag definition identifier. Published for the same reason the vocabulary read publishes it: `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by id, so without it a usage row cannot be acted on without a second read and a slot join.', + 'not touched here: lives in v2/knowledge-tags.ts', + ], + [ + 'Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{knowledgeBaseId}/tags.', + 'not touched here: lives in v2/knowledge.ts', + ], + [ + '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', + ], + [ + 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction.', + 'not touched here: lives in v2/workflows.ts', + ], + [ + 'The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{workflowId}/restore` brings all of them back.', + 'not touched here: lives in v2/workflows.ts', + ], + [ + 'Whether the deployed workflow accepts unauthenticated public API execution. While true, anyone holding the execution URL can run the workflow — and be billed for it — without an API key, so this is the field an audit of what a deployment exposes reads. Changed with `PATCH /workflows/{workflowId}/deployment`.', + 'not touched here: lives in v2/workflows.ts', + ], + [ + 'Operation id from `GET /api/v2/blocks/{blockId}`. Required when the block exposes multiple operations; it may differ from the underlying tool id.', + 'not touched here: lives in v2/workflows.ts', + ], + [ + 'Custom tool id returned by `GET /api/v2/custom-tools`.', + 'not touched here: lives in v2/workflows.ts', + ], + [ + 'Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned by every deployment mutation as well as this read. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{workflowId}/versions`.', + 'not touched here: lives in v2/workflows.ts', + ], +]) + +interface Described { + /** `file.ts#exportName.field`, so a failure names the symbol to edit. */ + key: string + description: string +} + +function describedOf(node: unknown): string | undefined { + const described = node as { description?: unknown; meta?: () => { description?: unknown } } + if (typeof described?.description === 'string') return described.description + const meta = typeof described?.meta === 'function' ? described.meta() : undefined + return typeof meta?.description === 'string' ? meta.description : undefined +} + +function collect(node: unknown, key: string, seen: Set, out: Described[], depth: number) { + if (!node || typeof node !== 'object' || depth <= 0 || seen.has(node)) return + seen.add(node) + const def = (node as { def?: Record }).def + if (!def) return + + const description = describedOf(node) + if (description) out.push({ key, description }) + + for (const wrapper of ['innerType', 'in', 'out', 'schema', 'element', 'valueType', 'keyType']) { + if (def[wrapper]) collect(def[wrapper], key, seen, out, depth - 1) + } + if (typeof def.getter === 'function') { + /** + * A `lazy` schema hides its shape behind a getter, and the depth cap is what + * keeps a self-referential one from spinning. A getter that throws is not + * this sweep's business, so it is skipped rather than failing the run. + */ + try { + collect((def.getter as () => unknown)(), key, seen, out, depth - 1) + } catch {} + } + for (const option of (def.options as unknown[] | undefined) ?? []) { + collect(option, key, seen, out, depth - 1) + } + for (const [field, child] of Object.entries( + (def.shape as Record | undefined) ?? {} + )) { + collect(child, `${key}.${field}`, seen, out, depth - 1) + } +} + +/** Every description reachable from an exported schema or route contract. */ +async function sweepDescriptions(): Promise { + const out: Described[] = [] + for (const file of listContractFiles().filter((path) => path.includes('/contracts/v2/'))) { + const name = file.split('/contracts/v2/')[1] + const module = (await import(file)) as Record + for (const [exported, value] of Object.entries(module)) { + if (!value || typeof value !== 'object') continue + /** + * A fresh visited set per export: schemas are shared between contracts, and + * deduplicating across them would report a shared field under whichever + * export reached it first and hide the rest. + */ + const seen = new Set() + const key = `${name}#${exported}` + if ('def' in value) { + collect(value, key, seen, out, MAX_SCHEMA_DEPTH) + continue + } + const contract = value as { + params?: unknown + query?: unknown + body?: unknown + headers?: unknown + response?: { schema?: unknown } + } + for (const slot of ['params', 'query', 'body', 'headers'] as const) { + if (contract[slot]) collect(contract[slot], `${key}.${slot}`, seen, out, MAX_SCHEMA_DEPTH) + } + if (contract.response?.schema) { + collect(contract.response.schema, `${key}.response`, seen, out, MAX_SCHEMA_DEPTH) + } + } + } + return out +} + +function offendingDescriptions(described: Described[]): Map { + const byDescription = new Map() + for (const { key, description } of described) { + if (!ENDPOINT_SPELLING.test(description)) continue + const keys = byDescription.get(description) + if (keys) keys.push(key) + else byDescription.set(description, [key]) + } + return byDescription +} + +describe('v2 schema descriptions', () => { + it('name the operation rather than an HTTP method and path', async () => { + const described = await sweepDescriptions() + expect(described.length).toBeGreaterThan(1000) + + const unexpected = [...offendingDescriptions(described)] + .filter(([description]) => !ALLOWED.has(description)) + .map(([description, keys]) => `${keys[0]} :: ${description}`) + + expect(unexpected).toEqual([]) + }) + + it('keeps the allowlist honest', async () => { + const offending = offendingDescriptions(await sweepDescriptions()) + const stale = [...ALLOWED.keys()].filter((description) => !offending.has(description)) + + expect(stale).toEqual([]) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts index cd7cdbfcc25..92c87d21ff3 100644 --- a/apps/sim/lib/api/contracts/v2/catalog.ts +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -227,13 +227,11 @@ export const v2BlockSummarySchema = z toolIds: z .array(z.string()) .describe( - 'Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`.' + 'Built-in tools this block can run. Read a tool by its id for the full definition.' ), operationIds: z .array(z.string()) - .describe( - 'Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`.' - ), + .describe('Operations this block exposes. Their fields and tools are on the block read.'), preview: z .boolean() .describe('Whether the block is unreleased and revealed only to this caller.'), diff --git a/apps/sim/lib/api/contracts/v2/chat.ts b/apps/sim/lib/api/contracts/v2/chat.ts index 1f8170befbe..45cf303963e 100644 --- a/apps/sim/lib/api/contracts/v2/chat.ts +++ b/apps/sim/lib/api/contracts/v2/chat.ts @@ -24,8 +24,7 @@ export const v2ChatBodySchema = z.object({ .describe('The message to send to Sim.'), conversationId: z .string() - .min(1, 'conversationId cannot be empty') - .max(128, 'conversationId cannot exceed 128 characters') + .uuid('conversationId must be a valid conversation id') .optional() .describe('Conversation to continue; a new one starts when omitted.'), }) @@ -39,7 +38,7 @@ const v2ChatTokensSchema = z.object({ export const v2ChatResultSchema = z.object({ content: z.string(), conversationId: z.string(), - model: z.string(), + model: z.string().describe('Identifier of the agent that produced the reply.'), tokens: v2ChatTokensSchema.optional(), // untyped-response: cost is a billing passthrough whose shape is owned by the copilot backend, not this contract cost: z.unknown().optional(), diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 14165926f55..c94c5c10940 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -72,13 +72,13 @@ export const v2FileSchema = z .number() .nonnegative() .describe( - 'Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.' + 'Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.' ) .meta({ examples: [1024] }), type: z .string() .describe( - 'MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.' + 'MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.' ) .meta({ examples: ['text/csv'] }), key: z @@ -108,7 +108,7 @@ export const v2FileSchema = z .string() .nullable() .describe( - 'ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.' + 'ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.' ) .meta({ format: 'date-time', examples: ['2026-01-16T09:00:00Z'] }), }) @@ -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 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.' ), 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 `POST /files/{fileId}/restore`. Authorization is identical for both.' + '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.' ), }) .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 `POST /api/v2/files/folders/restore`. Authorization is identical for both.' + '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.' ), }) export type V2ListFileFoldersQuery = z.output @@ -526,7 +526,7 @@ export const v2RestoreFileFolderBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace that owns the archived folder.'), path: v2NonRootFolderPathInputSchema.describe( - 'Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.' + 'Path of the archived folder to restore, as reported by an archived-scope folder list.' ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/knowledge-chunks.test.ts b/apps/sim/lib/api/contracts/v2/knowledge-chunks.test.ts new file mode 100644 index 00000000000..22f5842039c --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge-chunks.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { v2BulkKnowledgeChunksBodySchema } from '@/lib/api/contracts/v2/knowledge-chunks' + +/** + * This description is the only source of the `--chunk` flag help the CLI + * generates, so it and the implementation must state the same rule. It used to + * say unmatched ids were "ignored" while the response returned them in + * `errors[]` — for two of the three operations. + */ +describe('v2 bulk knowledge chunk contract', () => { + const description = v2BulkKnowledgeChunksBodySchema.shape.chunkIds.description ?? '' + + it('does not promise unmatched ids are ignored', () => { + expect(description).not.toMatch(/ignored/i) + }) + + it('points at the field that reports unmatched ids', () => { + expect(description).toMatch(/errors/) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts b/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts index 6acc5d22138..e9602911bff 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts @@ -202,15 +202,20 @@ export const v2BulkKnowledgeChunksBodySchema = z MAX_V2_BULK_KNOWLEDGE_CHUNKS, `chunkIds cannot contain more than ${MAX_V2_BULK_KNOWLEDGE_CHUNKS} chunks` ) - .describe('Chunks to operate on, by identifier. Ids outside the document are ignored.'), + .describe( + 'Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request.' + ), }) .strict() export type V2BulkKnowledgeChunksBody = z.input /** * Bulk chunk outcome. Unlike the per-chunk operations this is best-effort: an - * identifier naming no chunk in the document is skipped rather than failing the - * request, so `processed` is the authoritative count. + * identifier naming no chunk in the document is reported in `errors` rather + * than failing the request, and the same rule holds for all three operations. + * `processed` counts the chunks the operation matched in this document, which is + * not the same as the chunks it changed: enabling chunks that were already + * enabled still counts every one of them. */ export const v2BulkKnowledgeChunksDataSchema = z .object({ @@ -219,11 +224,15 @@ export const v2BulkKnowledgeChunksDataSchema = z .number() .int() .nonnegative() - .describe('Number of chunks the operation changed.') + .describe( + 'Number of chunks in this document the operation matched. Chunks already in the requested state are counted too, so this is not a count of changes.' + ) .meta({ examples: [12] }), errors: z .array(z.string()) - .describe('Per-chunk failures. A populated array still answers 200.'), + .describe( + 'Per-chunk failures, including any identifier that named no chunk in the document. A populated array still answers 200.' + ), }) .strict() .meta({ diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 90ae85e4f81..a30fc2bd912 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -59,7 +59,10 @@ import { rerankerModelSchema, rerankerStatusSchema, } from '@/lib/knowledge/reranker-models' -import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' +import { + KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES, + knowledgeDocumentUploadMetadataSchema, +} from '@/lib/knowledge/upload-metadata' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' /** @@ -481,10 +484,14 @@ const v2KnowledgeDocumentProcessingOptionsSchema = .extend({ recipe: knowledgeDocumentUploadMetadataSchema.shape.processingOptions .unwrap() - .shape.recipe.describe('Optional document processing recipe.'), + .shape.recipe.describe( + `Optional document processing recipe. One of: ${KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES.join(', ')}.` + ), lang: knowledgeDocumentUploadMetadataSchema.shape.processingOptions .unwrap() - .shape.lang.describe('Optional document language code.'), + .shape.lang.describe( + 'Optional document language: hyphen-separated letter and digit subtags such as `en`, `en-US`, or `zh-Hant-TW`. Only that shape is validated, not full BCP-47 conformance.' + ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/logs-stats.ts b/apps/sim/lib/api/contracts/v2/logs-stats.ts index e201d5de7df..163150c2443 100644 --- a/apps/sim/lib/api/contracts/v2/logs-stats.ts +++ b/apps/sim/lib/api/contracts/v2/logs-stats.ts @@ -105,7 +105,7 @@ export const v2LogStatsSchema = z end: v2TimestampSchema.describe('ISO 8601 end of the window.'), }) .describe( - 'The window the buckets span: the oldest matching run through the later of the newest matching run and now. A workspace with no matching runs reports the trailing 24 hours.' + 'The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width.' ), segmentMs: z.number().describe('Width of one bucket in milliseconds.'), }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts index 7fec5395db8..9f8e603b254 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts @@ -106,7 +106,7 @@ export const knowledgeChunkOpenApiRoutes = [ knowledgeOperation({ operationId: 'bulkUpdateKnowledgeChunks', summary: 'Bulk Update Chunks', - description: `Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is skipped rather than failing the request, so \`processed\` is the authoritative count. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, + description: `Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in \`errors\` rather than failing the request. \`processed\` counts the chunks the operation matched, not the chunks it changed. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'Outcome of the bulk chunk operation.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index f39c21cf054..6b2e6e3c2f4 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -207,7 +207,7 @@ const declaredRoutes = [ logsOperation({ operationId: 'getLogStats', summary: 'Get Log Statistics', - description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans the oldest matching run through the later of the newest matching run and now, divided into exactly \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, + description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans \`startDate\` through \`endDate\` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding \`endDate\` when only \`endDate\` was supplied. A supplied \`startDate\` is still used verbatim, so a \`startDate\` without an \`endDate\` yields \`[startDate, now]\`, which can be any width. The window is divided into exactly \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'Bucketed execution statistics for the workspace.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 0f822354b6e..c3370fab825 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1368,11 +1368,14 @@ const declaredRoutes = [ resourceOperation('Secrets', { operationId: 'setSecret', summary: 'Set Secret', - description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit \`value\` on a workspace secret to update \`description\` and \`unredacted\` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers \`404\` when the named secret does not exist. A personal secret always requires \`value\`, having no other writable field. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { byStatus: { - 200: { description: 'The existing secret value was replaced.' }, + 200: { + description: + 'The existing secret value was replaced, or its metadata was updated in place.', + }, 201: { description: 'The secret was created.' }, }, }, @@ -1389,13 +1392,18 @@ const declaredRoutes = [ v2SetSecretContract.body, 'SetSecretRequest', 'Set secret request', - 'Ownership scope and write-only value for the secret.', + 'Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.', [ { workspaceId: WORKSPACE_ID, scope: SECRET_EXAMPLE.scope, value: 'YOUR_SECRET_VALUE', }, + { + workspaceId: WORKSPACE_ID, + scope: 'workspace', + unredacted: false, + }, ] ), response: documentedSchema( diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index e7aa5412f21..4163bf6ad58 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -79,6 +79,7 @@ import { import { TABLE_LIMITS } from '@/lib/table/constants' const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' +const WORKFLOW_ID = '3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36' const TABLE_ID = 'tbl_7c9e6679742540de944be07fc1f90ae7' const ROW_ID = 'row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93' const VIEW_ID = 'view_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07' @@ -964,7 +965,7 @@ const declaredRoutes = [ { workspaceId: WORKSPACE_ID, group: { - workflowId: 'wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619', + workflowId: WORKFLOW_ID, name: 'Enrich company', outputs: [{ blockId: 'block_lookup', path: 'output.revenue', columnName: 'revenue' }], }, diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 78671062a96..ec23ed2c53a 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -327,7 +327,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'replaceWorkflowState', summary: 'Replace Workflow State', - description: `Replace a workflow\u2019s editable draft graph wholesale. \`loops\` and \`parallels\` are accepted but ignored — both are recomputed from \`blocks\`. Omitting \`variables\` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that \`needsRedeployment\` becomes true; \`POST /workflows/{workflowId}/deploy\` publishes the draft.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — but \`needsRedeployment\` describes the state before the write, and warnings raised by persistence itself are necessarily absent.`, + description: `Replace a workflow\u2019s editable draft graph wholesale. \`loops\` and \`parallels\` are accepted but ignored — both are recomputed from \`blocks\`. Omitting \`variables\` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with \`409\` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that \`needsRedeployment\` becomes true; \`POST /workflows/{workflowId}/deploy\` publishes the draft.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — including the warnings the write\u2019s own preparation step raises, and the same \`409\` when an id is already owned by another workflow. Only \`needsRedeployment\` differs: it describes the state before the write.`, errors: RESOURCE_MUTATION_ERRORS, success: jsonSuccess('The draft graph was replaced.'), }), @@ -364,7 +364,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'applyWorkflowOperations', summary: 'Apply Workflow Operations', - description: `Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in \`skipped\`, each with a machine-readable \`type\`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. \`deferred\` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet \`atomic\` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers \`409\` with \`error.details.code: "OPERATIONS_NOT_APPLIED"\`, the same \`skipped\` array, and a \`droppedInputs\` array, having persisted nothing.\n\nA \`block_id\` you supply on an \`add\` or \`insert_into_subflow\` is only a label unless it is already a UUID: the engine mints one and returns the pairing in \`mintedBlockIds\`. References between operations in the same batch are remapped for you, so \`triage\` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation \`params\` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: \`inputs\` keyed by sub-block id, with \`retry\`, \`triggerMode\` and \`advancedMode\` beside it rather than inside it, and \`connections\` keyed by source handle. \`GET /blocks/{blockId}\` publishes the inputs a given block type accepts. The Agent block’s \`inputs.tools\` value is the important exception to that open catalog shape: it is published here as the named \`AgentToolInput\` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only \`inputValidationErrors\` lists inputs that were actually dropped.\n\nAs with \`PUT /workflows/{workflowId}/state\`, this changes only the draft; deploy to publish it. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — but \`needsRedeployment\` describes the state before the write, and warnings raised by persistence itself are necessarily absent.`, + description: `Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in \`skipped\`, each with a machine-readable \`type\`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. \`deferred\` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet \`atomic\` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers \`409\` with \`error.details.code: "OPERATIONS_NOT_APPLIED"\`, the same \`skipped\` array, and a \`droppedInputs\` array, having persisted nothing.\n\nA \`block_id\` you supply on an \`add\` or \`insert_into_subflow\` is only a label unless it is already a UUID: the engine mints one and returns the pairing in \`mintedBlockIds\`. References between operations in the same batch are remapped for you, so \`triage\` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation \`params\` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: \`inputs\` keyed by sub-block id, with \`retry\`, \`triggerMode\` and \`advancedMode\` beside it rather than inside it, and \`connections\` keyed by source handle. \`GET /blocks/{blockId}\` publishes the inputs a given block type accepts. The Agent block’s \`inputs.tools\` value is the important exception to that open catalog shape: it is published here as the named \`AgentToolInput\` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only \`inputValidationErrors\` lists inputs that were actually dropped.\n\nAs with \`PUT /workflows/{workflowId}/state\`, this changes only the draft; deploy to publish it. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — including the warnings the write\u2019s own preparation step raises, and the same \`409\` when an id is already owned by another workflow. Only \`needsRedeployment\` differs: it describes the state before the write.`, errors: RESOURCE_MUTATION_ERRORS, success: jsonSuccess('The batch was applied.'), }), diff --git a/apps/sim/lib/api/contracts/v2/secrets.test.ts b/apps/sim/lib/api/contracts/v2/secrets.test.ts index f6c5cfdc39d..34f08ee9ab0 100644 --- a/apps/sim/lib/api/contracts/v2/secrets.test.ts +++ b/apps/sim/lib/api/contracts/v2/secrets.test.ts @@ -3,9 +3,11 @@ */ import { describe, expect, it } from 'vitest' import { + v2DeleteSecretContract, v2SecretSchema, v2SecretWithValueSchema, v2SetSecretBodySchema, + v2SetSecretContract, } from '@/lib/api/contracts/v2/secrets' const secret = { @@ -70,3 +72,95 @@ describe('v2SecretWithValueSchema value', () => { ).toBe(true) }) }) + +/** Reads the `name` field description off a contract's path-parameter schema. */ +function nameDescription(params: unknown): string | undefined { + const shape = (params as { shape: Record }).shape + return shape.name.description +} + +describe('secret path-parameter descriptions', () => { + it('does not offer writes on the delete path parameter', () => { + const description = nameDescription(v2DeleteSecretContract.params) + + expect(description).toBe('Secret to delete.') + expect(description).not.toMatch(/create|replace/i) + }) + + it('does not offer deletion on the set path parameter', () => { + expect(nameDescription(v2SetSecretContract.params)).not.toMatch(/delete/i) + }) + + it('gives the two operations distinct path-parameter prose', () => { + expect(nameDescription(v2SetSecretContract.params)).not.toBe( + nameDescription(v2DeleteSecretContract.params) + ) + }) +}) + +describe('v2SetSecretBodySchema metadata-only write', () => { + it('accepts a workspace body carrying only unredacted, so restoring redaction costs no value', () => { + const parsed = v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'workspace', + unredacted: false, + }) + + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.value).toBeUndefined() + }) + + it('accepts a workspace body carrying only a description', () => { + expect( + v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'workspace', + description: 'Prod billing key', + }).success + ).toBe(true) + }) + + it('rejects a workspace body with nothing to write rather than resolving to an empty update', () => { + const parsed = v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'workspace', + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues).toEqual([ + expect.objectContaining({ + path: ['value'], + message: 'value, description, or unredacted is required', + }), + ]) + } + }) + + it('still requires a value for a personal secret, which has no metadata field to write', () => { + const parsed = v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'personal', + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues).toEqual([ + expect.objectContaining({ + path: ['value'], + message: 'value is required for a personal secret', + }), + ]) + } + }) + + it('keeps rejecting an empty value, which is a write and not an omission', () => { + expect( + v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'workspace', + value: '', + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/secrets.ts b/apps/sim/lib/api/contracts/v2/secrets.ts index a287a895ffe..bc25ae81d82 100644 --- a/apps/sim/lib/api/contracts/v2/secrets.ts +++ b/apps/sim/lib/api/contracts/v2/secrets.ts @@ -104,10 +104,22 @@ export const v2ListSecretsQuerySchema = z .strict() export type V2ListSecretsQuery = z.output -export const v2SecretParamsSchema = z.object({ - name: v2SecretNameSchema.describe('Secret to create, replace, or delete.'), +/** + * The secret a path addresses, named for what the route does to it. + * + * `PUT` and `DELETE` sit on the same path but are not the same operation, and the + * OpenAPI document already publishes them as two components (`SetSecretParams`, + * `DeleteSecretParams`). One shared `describe()` forced both to read "create, + * replace, or delete", so `sim secrets delete` documented writes the route cannot + * perform. + */ +export const v2SetSecretParamsSchema = z.object({ + name: v2SecretNameSchema.describe('Secret to create or replace.'), +}) + +export const v2DeleteSecretParamsSchema = z.object({ + name: v2SecretNameSchema.describe('Secret to delete.'), }) -export type V2SecretParams = z.output export const v2SetSecretBodySchema = z .object({ @@ -119,7 +131,10 @@ export const v2SetSecretBodySchema = z .string() .min(1, 'value is required') .max(65_536, 'value is too long') - .describe('Write-only secret value. It is never returned.') + .optional() + .describe( + 'Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.' + ) .meta({ writeOnly: true }), description: z .string() @@ -137,19 +152,49 @@ export const v2SetSecretBodySchema = z ), }) .strict() + /** + * `value` is optional on the schema so a workspace secret's redaction policy can + * be flipped back without re-transmitting the plaintext — restoring redaction is + * the safe direction and must not cost more than leaving it off. Two refinements + * keep that from over-relaxing the request: a personal secret has no metadata + * field at all, so a value-less personal write would be a silent no-op rather + * than an update; and a body carrying none of the three writable fields is + * rejected outright instead of resolving to an empty write. + */ .superRefine((data, ctx) => { - if (data.scope === 'personal' && data.description !== undefined) { - ctx.addIssue({ - code: 'custom', - path: ['description'], - message: 'description is only supported for a workspace secret', - }) + if (data.scope === 'personal') { + if (data.value === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: 'value is required for a personal secret', + }) + } + if (data.description !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['description'], + message: 'description is only supported for a workspace secret', + }) + } + if (data.unredacted !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['unredacted'], + message: 'unredacted is only supported for a workspace secret', + }) + } + return } - if (data.scope === 'personal' && data.unredacted !== undefined) { + if ( + data.value === undefined && + data.description === undefined && + data.unredacted === undefined + ) { ctx.addIssue({ code: 'custom', - path: ['unredacted'], - message: 'unredacted is only supported for a workspace secret', + path: ['value'], + message: 'value, description, or unredacted is required', }) } }) @@ -180,12 +225,17 @@ export const v2ListSecretsContract = defineRouteContract({ }, }) -/** Creates or replaces a secret value without returning it. */ +/** + * Creates or replaces a secret value without returning it, or — for a workspace + * secret sent without a value — updates its description and redaction policy + * alone. A value-less write never creates: it answers 404 when the secret is + * absent. + */ export const v2SetSecretContract = defineRouteContract({ method: 'PUT', path: '/api/v2/secrets/[name]', query: noInputSchema, - params: v2SecretParamsSchema, + params: v2SetSecretParamsSchema, body: v2SetSecretBodySchema, response: { mode: 'json', @@ -197,7 +247,7 @@ export const v2SetSecretContract = defineRouteContract({ export const v2DeleteSecretContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/secrets/[name]', - params: v2SecretParamsSchema, + params: v2DeleteSecretParamsSchema, query: v2DeleteSecretQuerySchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 59e9c970e20..95f1686b33a 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 `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' + '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.' ), folderPath: v2FolderPathInputSchema .optional() @@ -664,7 +664,7 @@ export const v2RestoreTableFolderBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace that owns the archived folder.'), path: v2NonRootFolderPathInputSchema.describe( - 'Path the folder held when `DELETE /api/v2/tables/folders` archived it.' + 'Path the folder held when a folder delete archived it.' ), }) .strict() @@ -1185,7 +1185,7 @@ export const v2UpsertTableRowBodySchema = upsertTableRowBodySchema .omit(OMIT_PRIVATE_PROVENANCE) .extend({ data: v2RowDataSchema.describe( - 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.' + 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.' ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts b/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts index cb92abe2bc8..880bc2d50b4 100644 --- a/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts @@ -356,7 +356,7 @@ export const v2ListWorkflowMcpServersContract = defineRouteContract({ toolNamesTruncated: z .boolean() .describe( - "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read `GET /api/v2/workflow-mcp-servers/{serverId}/tools` for one server's inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." + "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read one server's tool inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." ), }), }, diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 46e5c63dc31..1ff2321bb20 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock, workflowAuthzMockFns } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, schemaMock, workflowAuthzMockFns } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -135,6 +135,54 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { expect(result?.messages).toEqual([userMsg]) }) + it('scopes the chat lookup to the requesting user, not the chat id alone', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID) + + const predicate = dbChainMockFns.where.mock.calls[0]?.[0] as { + type: string + conditions: unknown[] + } + expect(predicate.type).toBe('and') + // Three conditions exactly: dropping one silently widens the lookup, so the + // count is asserted alongside the membership checks. + expect(predicate.conditions).toHaveLength(3) + expect(predicate.conditions).toContainEqual({ + type: 'eq', + left: schemaMock.copilotChats.userId, + right: USER_ID, + }) + expect(predicate.conditions).toContainEqual({ + type: 'eq', + left: schemaMock.copilotChats.id, + right: CHAT_ID, + }) + expect(predicate.conditions).toContainEqual({ + type: 'isNull', + column: schemaMock.copilotChats.deletedAt, + }) + }) + + it('resolveOrCreateChat scopes its existing-chat lookup to the requesting user', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + await resolveOrCreateChat({ chatId: CHAT_ID, userId: USER_ID, model: 'm' }) + + const predicate = dbChainMockFns.where.mock.calls[0]?.[0] as { + type: string + conditions: unknown[] + } + expect(predicate.conditions).toHaveLength(3) + expect(predicate.conditions).toContainEqual({ + type: 'eq', + left: schemaMock.copilotChats.userId, + right: USER_ID, + }) + }) + it('resolveOrCreateChat returns conversationHistory from the table for an existing chat', async () => { dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }, { content: asstMsg }]) @@ -145,6 +193,47 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { expect(result.conversationHistory).toEqual([userMsg, asstMsg]) }) + it('resolveOrCreateChat refuses a resumed chat whose type is not the asserted one', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, type: 'mothership' }]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const result = await resolveOrCreateChat({ + chatId: CHAT_ID, + userId: USER_ID, + model: 'm', + type: 'copilot', + }) + + // Same shape an unknown id resolves to: the refusal carries no reason. + expect(result.chat).toBeNull() + expect(result.conversationHistory).toEqual([]) + expect(result.isNew).toBe(false) + }) + + it('resolveOrCreateChat resumes a chat whose type matches the asserted one', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, type: 'mothership' }]) + dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }]) + + const result = await resolveOrCreateChat({ + chatId: CHAT_ID, + userId: USER_ID, + model: 'm', + type: 'mothership', + }) + + expect(result.chat).not.toBeNull() + expect(result.conversationHistory).toEqual([userMsg]) + }) + + it('resolveOrCreateChat stamps a supplied title on a newly created chat', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([chatRow]) + + await resolveOrCreateChat({ userId: USER_ID, model: 'm', title: 'First message' }) + + const insertValues = dbChainMockFns.values.mock.calls[0]?.[0] as Record + expect(insertValues.title).toBe('First message') + }) + it('resolveOrCreateChat creates a new chat with an empty transcript', async () => { dbChainMockFns.returning.mockResolvedValueOnce([chatRow]) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 69b577a31e9..b6701675be3 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -231,6 +231,11 @@ export async function getAccessibleCopilotChatWithMessages( * Resolve or create a copilot chat session. * If chatId is provided, loads the existing chat. Otherwise creates a new one. * Supports both workflow-scoped and workspace-scoped chats. + * + * A resumed chat must match every scope the caller asserted — workflow, + * workspace, and `type`. Any mismatch resolves to `chat: null`, exactly as an + * unknown id does, so callers cannot distinguish the reasons a chat did not + * resolve. `title` is stamped only on a newly created chat. */ export async function resolveOrCreateChat(params: { chatId?: string @@ -239,8 +244,9 @@ export async function resolveOrCreateChat(params: { workspaceId?: string model: string type?: 'mothership' | 'copilot' + title?: string }): Promise { - const { chatId, userId, workflowId, workspaceId, model, type } = params + const { chatId, userId, workflowId, workspaceId, model, type, title } = params if (workspaceId) { await assertActiveWorkspaceAccess(workspaceId, userId) @@ -270,6 +276,16 @@ export async function resolveOrCreateChat(params: { return { chatId, chat: null, conversationHistory: [], isNew: false } } + if (type && chat.type !== type) { + logger.warn('Copilot chat type mismatch', { + chatId, + userId, + requestType: type, + chatType: chat.type, + }) + return { chatId, chat: null, conversationHistory: [], isNew: false } + } + if (chat.workflowId) { const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId) if (!activeWorkflow) { @@ -299,7 +315,7 @@ export async function resolveOrCreateChat(params: { ...(workflowId ? { workflowId } : {}), ...(workspaceId ? { workspaceId } : {}), type: type ?? 'copilot', - title: null, + title: title ?? null, model, lastSeenAt: now, }) diff --git a/apps/sim/lib/copilot/chat/messages-store.test.ts b/apps/sim/lib/copilot/chat/messages-store.test.ts index 5a8b1155c4b..a620ce64757 100644 --- a/apps/sim/lib/copilot/chat/messages-store.test.ts +++ b/apps/sim/lib/copilot/chat/messages-store.test.ts @@ -1,10 +1,11 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { appendCopilotChatMessages, + persistCopilotChatTurn, replaceCopilotChatMessages, } from '@/lib/copilot/chat/messages-store' import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' @@ -231,4 +232,41 @@ describe('messages-store', () => { expect(JSON.stringify(lastValuesRows())).not.toContain('huge') }) }) + + describe('persistCopilotChatTurn', () => { + it('claims the chat row by id AND liveness, so a soft-deleted chat matches nothing', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ model: 'claude-sonnet-4-5' }]) + + await persistCopilotChatTurn('chat-1', [userMsg, assistantMsg]) + + // The chain mock ignores predicates, so the predicate itself is the + // assertion: without the liveness term the update still matches an + // archived row and the turn lands in a conversation the user deleted. + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: schemaMock.copilotChats.id, right: 'chat-1' }, + { type: 'isNull', column: schemaMock.copilotChats.deletedAt }, + ], + }) + }) + + it('writes the transcript with the chat model when the row is still live', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ model: 'claude-sonnet-4-5' }]) + + await persistCopilotChatTurn('chat-1', [userMsg, assistantMsg]) + + const rows = lastValuesRows() + expect(rows.map((r) => r.messageId)).toEqual(['msg-user-1', 'msg-asst-1']) + expect(rows[0].model).toBe('claude-sonnet-4-5') + }) + + it('writes nothing when the claim matches no row', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await persistCopilotChatTurn('chat-1', [userMsg, assistantMsg]) + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/lib/copilot/chat/messages-store.ts b/apps/sim/lib/copilot/chat/messages-store.ts index 15b988c7998..fdf09b7aa3e 100644 --- a/apps/sim/lib/copilot/chat/messages-store.ts +++ b/apps/sim/lib/copilot/chat/messages-store.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' -import { copilotMessages } from '@sim/db/schema' -import { and, eq, notInArray, sql } from 'drizzle-orm' +import { copilotChats, copilotMessages } from '@sim/db/schema' +import { and, eq, isNull, notInArray, sql } from 'drizzle-orm' import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import type { DbOrTx } from '@/lib/db/types' @@ -78,6 +78,40 @@ export async function appendCopilotChatMessages( }) } +/** + * Persist one completed turn — the user message and the assistant reply — into + * a chat's transcript, bumping the chat's `updatedAt` so it sorts by recency. + * + * Headless callers need this because the orchestrator never writes messages: + * the interactive web surface persists them from its own client store, so a + * turn run without that surface would leave a chat that opens to nothing. + * + * Both messages are written in a single transaction, so a failure leaves the + * transcript untouched rather than showing a question with no answer. + * + * The chat row is claimed under the same liveness predicate the accessible-chat + * loaders use, so a chat soft-deleted while the turn was running receives + * nothing: the update matches no row and the transaction returns having written + * neither the transcript nor the recency bump. Dropping the turn is right here + * because the user deleted the conversation after asking — resurrecting it with + * a reply would undo that deletion, and the caller still has its reply in the + * response. Throws on a write failure. + */ +export async function persistCopilotChatTurn( + chatId: string, + messages: PersistedMessage[] +): Promise { + await db.transaction(async (tx) => { + const [updated] = await tx + .update(copilotChats) + .set({ updatedAt: new Date() }) + .where(and(eq(copilotChats.id, chatId), isNull(copilotChats.deletedAt))) + .returning({ model: copilotChats.model }) + if (!updated) return + await appendCopilotChatMessages(chatId, messages, { chatModel: updated.model ?? null }, tx) + }) +} + /** * Replace all messages for a chat from a full snapshot (used by update-messages). * Throws on failure. Pass `executor` to enlist the delete+insert in an existing diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index 1df1be6c40e..446d9bd6ac5 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -77,3 +77,16 @@ export const TOOL_RESULT_MAX_INLINE_CHARS = export const COPILOT_MODES = ['ask', 'build', 'plan'] as const export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const + +/** + * Model stamped on a mothership conversation row created outside the + * interactive send path: `POST /api/mothership/chats` (an empty chat), the + * `sim chat` API turn, and the inbox executor's chat for an email task. + * Shared so those three cannot drift onto different models for the same + * conversation type. + * + * The interactive send path (`lib/copilot/chat/post.ts`) does not read this: + * the chat it creates is stamped with the model it also runs the turn and + * generates the title with, which that module owns separately. + */ +export const MOTHERSHIP_CHAT_DEFAULT_MODEL = 'claude-opus-4-8' diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 15fa0961a17..85d39c44e4d 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -169,6 +169,7 @@ vi.mock('@/lib/table/application/folder-paths', () => ({ index: { idByPath: new Map(), pathById: new Map() }, }), tableFolderPathForId: () => '/', + archivableTableFolderPath: () => '/', })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ diff --git a/apps/sim/lib/credentials/secret-values.test.ts b/apps/sim/lib/credentials/secret-values.test.ts index 2e8239168c3..eb4114439e3 100644 --- a/apps/sim/lib/credentials/secret-values.test.ts +++ b/apps/sim/lib/credentials/secret-values.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { + dbChainMockFns, + flattenMockConditions, + type MockCondition, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -42,6 +49,7 @@ import { readWorkspaceSecretValues, setPersonalSecret, setWorkspaceSecret, + updateWorkspaceSecretMetadata, } from '@/lib/credentials/secret-values' describe('secret value storage', () => { @@ -281,3 +289,117 @@ describe('readWorkspaceSecretValues', () => { expect(mockDecryptSecret).not.toHaveBeenCalled() }) }) + +/** + * The row-queue mocks resolve whatever was queued regardless of the predicate, so + * the only way to pin a WHERE clause is to read the condition tree the `eq`/`and` + * mocks recorded. An unscoped metadata UPDATE would let any workspace flip another + * workspace's secret out of redaction by name, and the cache invalidation would + * then push that flag into the other workspace's runtime redaction catalog — so the + * count is asserted alongside the triple: a dropped condition is exactly the shape + * a "contains" check alone would let through. + */ +function updateConditions(): MockCondition[] { + const call = dbChainMockFns.where.mock.calls.at(-1) + return flattenMockConditions(call?.[0]) +} + +describe('workspace secret metadata updates', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('scopes the update to this workspace, the env_workspace type, and the named key alone', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'STRIPE_KEY', + unredacted: false, + }) + + const conditions = updateConditions() + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.credential.workspaceId, + right: 'workspace-1', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.credential.type, + right: 'env_workspace', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.credential.envKey, + right: 'STRIPE_KEY', + }) + expect(conditions).toHaveLength(3) + }) + + it('writes the metadata without encrypting anything or rewriting the variables map', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + const result = await updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'STRIPE_KEY', + unredacted: false, + }) + + expect(result).toMatchObject({ created: false, updatedAt: expect.any(Date) }) + expect(mockEncryptSecret).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockCreateWorkspaceEnvCredentials).not.toHaveBeenCalled() + + const written = dbChainMockFns.set.mock.calls[0][0] as Record + expect(written.unredacted).toBe(false) + expect(written).not.toHaveProperty('description') + expect(written).not.toHaveProperty('variables') + }) + + it('leaves an omitted field alone rather than clearing it', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'STRIPE_KEY', + description: null, + }) + + const written = dbChainMockFns.set.mock.calls[0][0] as Record + expect(written.description).toBeNull() + expect(written).not.toHaveProperty('unredacted') + }) + + it('invalidates the decrypted env cache, since unredacted rides the run redaction catalog', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'STRIPE_KEY', + unredacted: false, + }) + + expect(mockInvalidateEffectiveDecryptedEnvCache).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + }) + }) + + it('reports a miss instead of creating a secret, and leaves the cache alone', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'ABSENT_KEY', + unredacted: true, + }) + ).resolves.toBeNull() + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockCreateWorkspaceEnvCredentials).not.toHaveBeenCalled() + expect(mockInvalidateEffectiveDecryptedEnvCache).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/secret-values.ts b/apps/sim/lib/credentials/secret-values.ts index 732bff1702b..aae67ca55a6 100644 --- a/apps/sim/lib/credentials/secret-values.ts +++ b/apps/sim/lib/credentials/secret-values.ts @@ -143,6 +143,53 @@ export async function setWorkspaceSecret(params: { return { created, updatedAt } } +/** + * Updates one workspace secret's metadata, leaving its stored value untouched. + * + * Deliberately UPDATE-only: it never encrypts, never rewrites the environment + * variables map, and never inserts a credential row, so restoring redaction on a + * secret costs nothing and a metadata write can never conjure a secret that does + * not exist. A write that matches no row returns `null` and the caller answers + * not-found rather than creating one. + * + * The decrypted-env cache is still invalidated on a match: `unredacted` rides the + * environment snapshot into every run's redaction catalog, so a stale entry would + * keep printing a value the workspace just re-redacted. + */ +export async function updateWorkspaceSecretMetadata(params: { + workspaceId: string + name: string + /** `undefined` leaves any existing description untouched; `null` clears it. */ + description?: string | null + /** `undefined` leaves the current setting. */ + unredacted?: boolean +}): Promise { + const { workspaceId, name, description, unredacted } = params + const updatedAt = new Date() + + const updated = await db + .update(credential) + .set({ + updatedAt, + ...(description !== undefined ? { description } : {}), + ...(unredacted !== undefined ? { unredacted } : {}), + }) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_workspace'), + eq(credential.envKey, name) + ) + ) + .returning({ id: credential.id }) + + if (updated.length === 0) return null + + invalidateEffectiveDecryptedEnvCache({ workspaceId }) + + return { created: false, updatedAt } +} + /** Stores one caller-owned personal secret without decrypting any existing value. */ export async function setPersonalSecret(params: { userId: string diff --git a/apps/sim/lib/knowledge/application/upload-sessions.test.ts b/apps/sim/lib/knowledge/application/upload-sessions.test.ts index 796f0b3c820..57b109eb481 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.test.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.test.ts @@ -362,6 +362,46 @@ describe('knowledge-document upload application lifecycle', () => { ) }) + it('completes a session whose persisted recipe and lang predate their validation', async () => { + mocks.getUpload.mockResolvedValue({ + ...SESSION, + metadata: { + ...SESSION.metadata, + processingOptions: { recipe: 'super-chunker-9000', lang: 'en_US' }, + }, + }) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } + ) + + const result = await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + + expect(result.value.created).toBe(true) + }) + it('returns an already-bound document without re-billing, re-registering, or auditing', async () => { mocks.findBound.mockResolvedValue({ status: 'bound', document: DOCUMENT }) mocks.completeUpload.mockImplementation( diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts index 1d1ed33d2c1..7abafbfe954 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -23,7 +23,7 @@ import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/doc import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import { type KnowledgeDocumentUploadMetadata, - knowledgeDocumentUploadMetadataSchema, + persistedKnowledgeDocumentUploadMetadataSchema, } from '@/lib/knowledge/upload-metadata' import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { requestOrigin } from '@/lib/uploads/upload-session/application' @@ -456,9 +456,13 @@ async function reauthorizeKnowledgeDocumentUpload( return context } +/** + * Reads metadata back off a persisted session, so it uses the lenient schema: + * a session created before `recipe`/`lang` were constrained must still resume. + */ function knowledgeDocumentMetadataFor(session: UploadSessionRecord) { const { authBinding: _authBinding, ...metadata } = session.metadata - return knowledgeDocumentUploadMetadataSchema.parse(metadata) + return persistedKnowledgeDocumentUploadMetadataSchema.parse(metadata) } function knowledgeDocumentInputFor(session: UploadSessionRecord) { diff --git a/apps/sim/lib/knowledge/chunks/service.test.ts b/apps/sim/lib/knowledge/chunks/service.test.ts index c1bd1a00194..02f431bb013 100644 --- a/apps/sim/lib/knowledge/chunks/service.test.ts +++ b/apps/sim/lib/knowledge/chunks/service.test.ts @@ -2,7 +2,8 @@ * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { embedding } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/knowledge/embeddings', () => ({ @@ -67,4 +68,46 @@ describe('batchChunkOperation', () => { expect(result).toEqual({ success: true, processed: 2, errors: [] }) }) + + /** + * Delete used to answer differently from enable and disable: a partially + * unmatched delete reported no error at all, and an entirely unmatched one + * reported a generic message naming no id. Three rules behind one sentence of + * documentation is what the contract could not describe honestly. + */ + it('reports ids that name no chunk when deleting', async () => { + queueTableRows(embedding, [{ id: 'chunk-1', tokenCount: 5, contentLength: 20 }]) + + const result = await batchChunkOperation( + 'document-1', + 'delete', + ['chunk-1', 'chunk-missing'], + 'request-4' + ) + + expect(result.processed).toBe(1) + expect(result.errors).toEqual(['No matching chunks found to delete: chunk-missing']) + }) + + it('names the missing ids when no chunk matches a delete', async () => { + queueTableRows(embedding, []) + + const result = await batchChunkOperation( + 'document-1', + 'delete', + ['missing-1', 'missing-2'], + 'request-5' + ) + + expect(result.processed).toBe(0) + expect(result.errors).toEqual(['No matching chunks found to delete: missing-1, missing-2']) + }) + + it('reports success with no errors when every requested chunk is deleted', async () => { + queueTableRows(embedding, [{ id: 'chunk-1', tokenCount: 5, contentLength: 20 }]) + + const result = await batchChunkOperation('document-1', 'delete', ['chunk-1'], 'request-6') + + expect(result).toEqual({ success: true, processed: 1, errors: [] }) + }) }) diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index 0f61de4cc36..8dd509a5c05 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -340,6 +340,7 @@ export async function batchChunkOperation( const errors: string[] = [] let successCount = 0 + let matchedIds = new Set() if (operation === 'delete') { // Handle batch delete with transaction for consistency @@ -354,15 +355,13 @@ export async function batchChunkOperation( .from(embedding) .where(and(eq(embedding.documentId, documentId), inArray(embedding.id, chunkIds))) - if (chunksToDelete.length === 0) { - errors.push('No matching chunks found to delete') - return - } + matchedIds = new Set(chunksToDelete.map(({ id }) => id)) + if (chunksToDelete.length === 0) return const totalTokensToRemove = chunksToDelete.reduce((sum, chunk) => sum + chunk.tokenCount, 0) const totalCharsToRemove = chunksToDelete.reduce((sum, chunk) => sum + chunk.contentLength, 0) - const deleteResult = await tx + await tx .delete(embedding) .where(and(eq(embedding.documentId, documentId), inArray(embedding.id, chunkIds))) @@ -390,12 +389,17 @@ export async function batchChunkOperation( .returning({ id: embedding.id }) successCount = updatedChunks.length + matchedIds = new Set(updatedChunks.map(({ id }) => id)) + } - const matchedIds = new Set(updatedChunks.map(({ id }) => id)) - const unmatchedIds = chunkIds.filter((chunkId) => !matchedIds.has(chunkId)) - if (unmatchedIds.length > 0) { - errors.push(`No matching chunks found to ${operation}: ${unmatchedIds.join(', ')}`) - } + /** + * One rule for all three operations: an id naming no chunk in this document + * 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 `processed`. + */ + const unmatchedIds = chunkIds.filter((chunkId) => !matchedIds.has(chunkId)) + if (unmatchedIds.length > 0) { + errors.push(`No matching chunks found to ${operation}: ${unmatchedIds.join(', ')}`) } logger.info( diff --git a/apps/sim/lib/knowledge/upload-metadata.test.ts b/apps/sim/lib/knowledge/upload-metadata.test.ts new file mode 100644 index 00000000000..42cf71ed58c --- /dev/null +++ b/apps/sim/lib/knowledge/upload-metadata.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES, + knowledgeDocumentUploadMetadataSchema, + persistedKnowledgeDocumentUploadMetadataSchema, +} from '@/lib/knowledge/upload-metadata' + +describe('knowledgeDocumentUploadMetadataSchema', () => { + it('rejects a recipe outside the accepted set', () => { + const result = knowledgeDocumentUploadMetadataSchema.safeParse({ + processingOptions: { recipe: 'totally-bogus-recipe' }, + }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(['processingOptions', 'recipe']) + expect(result.error?.issues[0]?.message).toContain('recipe must be one of') + }) + + it('rejects a lang outside the enforced subtag shape', () => { + const result = knowledgeDocumentUploadMetadataSchema.safeParse({ + processingOptions: { lang: 'zzzz-nonsense!' }, + }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.message).toContain('hyphen-separated letter and digit subtags') + }) + + /** + * The message promises the shape, not BCP-47 conformance, so a tag the RFC + * rejects for a trailing singleton still parses. Pinned so the two cannot + * drift back apart into a message that claims more than the regex enforces. + */ + it('does not claim the BCP-47 conformance it cannot enforce', () => { + const result = knowledgeDocumentUploadMetadataSchema.safeParse({ + processingOptions: { lang: 'en-a' }, + }) + expect(result.success).toBe(true) + expect( + knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { lang: 'en_US' } }) + .error?.issues[0]?.message + ).not.toContain('BCP-47') + }) + + it('rejects the underscore locale form callers reach for', () => { + expect( + knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { lang: 'en_US' } }) + .success + ).toBe(false) + }) + + it('accepts what first-party callers actually send today', () => { + const result = knowledgeDocumentUploadMetadataSchema.safeParse({ + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }) + expect(result.success).toBe(true) + expect(result.data?.processingOptions).toEqual({ recipe: 'default', lang: 'en' }) + }) + + it('accepts a multi-subtag language tag', () => { + expect( + knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { lang: 'zh-Hant-TW' } }) + .success + ).toBe(true) + }) + + it('keeps the chunker recipes accepted alongside the default sentinel', () => { + expect(KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES).toContain('default') + expect( + knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { recipe: 'markdown' } }) + .success + ).toBe(true) + }) +}) + +describe('persistedKnowledgeDocumentUploadMetadataSchema', () => { + it('drops a recipe persisted before the enum landed instead of throwing', () => { + const parsed = persistedKnowledgeDocumentUploadMetadataSchema.parse({ + tag1: 'product', + processingOptions: { recipe: 'super-chunker-9000', lang: 'en' }, + }) + expect(parsed.processingOptions).toEqual({ recipe: undefined, lang: 'en' }) + expect(parsed.tag1).toBe('product') + }) + + it('drops a lang persisted before the language-tag shape landed instead of throwing', () => { + const parsed = persistedKnowledgeDocumentUploadMetadataSchema.parse({ + processingOptions: { recipe: 'default', lang: 'en_US' }, + }) + expect(parsed.processingOptions).toEqual({ recipe: 'default', lang: undefined }) + }) + + it('does not throw on a session whose processing options are wholly unrecognized', () => { + expect(() => + persistedKnowledgeDocumentUploadMetadataSchema.parse({ + processingOptions: { recipe: 42, lang: false }, + }) + ).not.toThrow() + }) + + it('preserves recognized values', () => { + const parsed = persistedKnowledgeDocumentUploadMetadataSchema.parse({ + processingOptions: { recipe: 'code', lang: 'en-US' }, + }) + expect(parsed.processingOptions).toEqual({ recipe: 'code', lang: 'en-US' }) + }) +}) diff --git a/apps/sim/lib/knowledge/upload-metadata.ts b/apps/sim/lib/knowledge/upload-metadata.ts index ec5761b925d..8b5c96c4ad9 100644 --- a/apps/sim/lib/knowledge/upload-metadata.ts +++ b/apps/sim/lib/knowledge/upload-metadata.ts @@ -1,24 +1,73 @@ import { z } from 'zod' +import type { RecursiveRecipe } from '@/lib/chunkers/types' + +/** + * Recipes the recursive chunker implements. Mirrors `RecursiveRecipe`; the + * `satisfies` keeps a rename or removal there a compile error here. + */ +const RECURSIVE_RECIPES = [ + 'plain', + 'markdown', + 'code', +] as const satisfies readonly RecursiveRecipe[] + +/** + * Recipes accepted on a document upload. `'default'` is not a chunker recipe — + * it is the long-standing sentinel every first-party caller sends to mean "use + * the knowledge base's configured strategy", so it must stay accepted. + */ +export const KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES = ['default', ...RECURSIVE_RECIPES] as const + +export type KnowledgeDocumentUploadRecipe = (typeof KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES)[number] + +/** + * The shape a language tag must have: a 2-8 letter primary subtag followed by + * any number of alphanumeric subtags, e.g. `en`, `en-US`, `zh-Hant-TW`. + * + * A shape check, not BCP-47 conformance. It still admits a malformed tag such + * as `en-a`, whose trailing singleton RFC 5646 requires to be followed by + * extension subtags. `lang` is carried through the processing payload untouched + * and read by nothing that chunks or parses the document, so a parser sized to + * reject that would cost more than the field is worth — the message below + * therefore claims only the shape that is actually enforced. + */ +const LANGUAGE_TAG_SHAPE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/ const knowledgeDocumentUploadTagSchema = z .string() .max(1000, 'Knowledge document tag values cannot exceed 1000 characters') .optional() +const knowledgeDocumentUploadTagShape = { + tag1: knowledgeDocumentUploadTagSchema, + tag2: knowledgeDocumentUploadTagSchema, + tag3: knowledgeDocumentUploadTagSchema, + tag4: knowledgeDocumentUploadTagSchema, + tag5: knowledgeDocumentUploadTagSchema, + tag6: knowledgeDocumentUploadTagSchema, + tag7: knowledgeDocumentUploadTagSchema, +} + +const recipeSchema = z.enum(KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES, { + error: `recipe must be one of: ${KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES.join(', ')}`, +}) + +const langSchema = z + .string() + .max(35, 'lang cannot exceed 35 characters') + .regex( + LANGUAGE_TAG_SHAPE, + 'lang must be hyphen-separated letter and digit subtags, for example "en" or "en-US"' + ) + /** Persisted metadata stored with a resumable Knowledge document upload session. */ export const knowledgeDocumentUploadMetadataSchema = z .object({ - tag1: knowledgeDocumentUploadTagSchema, - tag2: knowledgeDocumentUploadTagSchema, - tag3: knowledgeDocumentUploadTagSchema, - tag4: knowledgeDocumentUploadTagSchema, - tag5: knowledgeDocumentUploadTagSchema, - tag6: knowledgeDocumentUploadTagSchema, - tag7: knowledgeDocumentUploadTagSchema, + ...knowledgeDocumentUploadTagShape, processingOptions: z .object({ - recipe: z.string().max(255, 'recipe cannot exceed 255 characters').optional(), - lang: z.string().max(35, 'lang cannot exceed 35 characters').optional(), + recipe: recipeSchema.optional(), + lang: langSchema.optional(), }) .strict() .optional(), @@ -26,3 +75,26 @@ export const knowledgeDocumentUploadMetadataSchema = z .strict() export type KnowledgeDocumentUploadMetadata = z.output + +/** + * Read-back variant for metadata already persisted on an upload session. + * + * The strict schema above is a *request* boundary and rejects an unrecognized + * `recipe`/`lang`. Sessions created before those constraints existed can carry + * values that no longer parse, and rejecting them here would throw a raw + * `ZodError` out of resume/complete — a 500 on a session that could never be + * finished. Unrecognized values are dropped instead; neither field affects + * processing today, so dropping one changes nothing but the analytics property. + */ +export const persistedKnowledgeDocumentUploadMetadataSchema = z + .object({ + ...knowledgeDocumentUploadTagShape, + processingOptions: z + .object({ + recipe: recipeSchema.optional().catch(undefined), + lang: langSchema.optional().catch(undefined), + }) + .strict() + .optional(), + }) + .strict() diff --git a/apps/sim/lib/logs/application/get-log-stats.ts b/apps/sim/lib/logs/application/get-log-stats.ts index 953f26e5b01..c037049451a 100644 --- a/apps/sim/lib/logs/application/get-log-stats.ts +++ b/apps/sim/lib/logs/application/get-log-stats.ts @@ -47,7 +47,10 @@ export const getLogStats = defineAuthorizedWorkspaceUseCase({ ) const bounds = await readLogStatsBounds(where) - const window = resolveLogStatsWindow(bounds, input.segmentCount) + const window = resolveLogStatsWindow(bounds, input.segmentCount, { + requestedStart: input.filters.startDate, + requestedEnd: input.filters.endDate, + }) const rows = await readLogStatsSegments(where, window.startTime.toISOString(), window.segmentMs) return buildDashboardStats(rows, window, input.segmentCount, { maxWorkflows: MAX_STATS_WORKFLOWS, diff --git a/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts b/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts index d632f95666f..125ab8c6f67 100644 --- a/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts +++ b/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts @@ -129,6 +129,36 @@ describe('getLogStats', () => { ) }) + /** + * The wiring, not the arithmetic: `resolveLogStatsWindow` is exercised for + * real here, so a requested window that never reaches it shows up as both a + * wrong segment origin on the read and a wrong `timeBounds` on the response. + */ + it('spans the requested window rather than the rows that happen to exist', async () => { + const { stats } = await getLogStats.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: { + startDate: new Date('2026-08-01T00:00:00.000Z'), + endDate: new Date('2026-08-02T00:00:00.000Z'), + }, + segmentCount: 2, + }, + }) + + expect(mocks.readSegments).toHaveBeenCalledWith( + expect.anything(), + '2026-08-01T00:00:00.000Z', + 12 * 60 * 60 * 1000 + ) + expect(stats.timeBounds).toEqual({ + start: '2026-08-01T00:00:00.000Z', + end: '2026-08-02T00:00:00.000Z', + }) + expect(stats.segmentMs).toBe(12 * 60 * 60 * 1000) + }) + it('resolves the folder scope only after authorization, and only when asked', async () => { await getLogStats.execute({ principal: workspacePrincipal, diff --git a/apps/sim/lib/logs/stats.test.ts b/apps/sim/lib/logs/stats.test.ts index e645997fef8..087cf4717dc 100644 --- a/apps/sim/lib/logs/stats.test.ts +++ b/apps/sim/lib/logs/stats.test.ts @@ -29,7 +29,7 @@ describe('resolveLogStatsWindow', () => { const now = new Date('2026-01-15T12:00:00.000Z') it('falls back to the trailing 24 hours when nothing ran', () => { - const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, now) + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, { now }) expect(resolved.endTime).toEqual(now) expect(resolved.startTime).toEqual(new Date('2026-01-14T12:00:00.000Z')) @@ -39,7 +39,7 @@ describe('resolveLogStatsWindow', () => { const resolved = resolveLogStatsWindow( { minTime: '2026-01-15T00:00:00.000Z', maxTime: '2026-01-15T06:00:00.000Z' }, 12, - now + { now } ) expect(resolved.endTime).toEqual(now) @@ -50,17 +50,96 @@ describe('resolveLogStatsWindow', () => { const resolved = resolveLogStatsWindow( { minTime: '2026-01-15T12:00:00.000Z', maxTime: '2026-01-15T12:00:01.000Z' }, 500, - now + { now } ) expect(resolved.segmentMs).toBe(60_000) }) it('divides by segmentCount without producing a zero-width bucket', () => { - const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 1, now) + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 1, { now }) expect(resolved.segmentMs).toBe(24 * 60 * 60 * 1000) }) + + /** + * The `segmentMs` assertion is the load-bearing half: pinning `endTime` + * alone would still pass for a fix that relabelled `timeBounds` without + * re-deriving the bucket width the series is stamped from. + */ + it('ends the window at the requested end rather than at now', () => { + const resolved = resolveLogStatsWindow( + { minTime: '2026-01-14T00:00:00.000Z', maxTime: '2026-01-14T06:00:00.000Z' }, + 12, + { requestedEnd: new Date('2026-01-14T12:00:00.000Z'), now } + ) + + expect(resolved.endTime).toEqual(new Date('2026-01-14T12:00:00.000Z')) + expect(resolved.segmentMs).toBe(60 * 60 * 1000) + }) + + it('starts the window at the requested start rather than at the oldest run', () => { + const resolved = resolveLogStatsWindow( + { minTime: '2026-01-14T06:00:00.000Z', maxTime: '2026-01-14T09:00:00.000Z' }, + 12, + { + requestedStart: new Date('2026-01-14T00:00:00.000Z'), + requestedEnd: new Date('2026-01-14T12:00:00.000Z'), + now, + } + ) + + expect(resolved.startTime).toEqual(new Date('2026-01-14T00:00:00.000Z')) + expect(resolved.segmentMs).toBe(60 * 60 * 1000) + }) + + it('reports the requested window when nothing ran inside it', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 6, { + requestedStart: new Date('2026-01-01T00:00:00.000Z'), + requestedEnd: new Date('2026-01-07T00:00:00.000Z'), + now, + }) + + expect(resolved.startTime).toEqual(new Date('2026-01-01T00:00:00.000Z')) + expect(resolved.endTime).toEqual(new Date('2026-01-07T00:00:00.000Z')) + expect(resolved.segmentMs).toBe(24 * 60 * 60 * 1000) + }) + + /** + * The case neither fallback sentence covers on its own: with no rows and only + * a right edge, the 24-hour window is measured back from the requested end, + * not from the wall clock. + */ + it('measures the empty-result fallback back from a requested end', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, { + requestedEnd: new Date('2026-01-10T00:00:00.000Z'), + now, + }) + + expect(resolved.endTime).toEqual(new Date('2026-01-10T00:00:00.000Z')) + expect(resolved.startTime).toEqual(new Date('2026-01-09T00:00:00.000Z')) + }) + + /** The dashboard schema has no `startDate <= endDate` refinement, so a crossed pair reaches here. */ + it('keeps a crossed requested pair from producing a non-positive bucket width', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 6, { + requestedStart: new Date('2026-01-07T00:00:00.000Z'), + requestedEnd: new Date('2026-01-01T00:00:00.000Z'), + now, + }) + + expect(resolved.segmentMs).toBe(60_000) + }) + + it('ignores an unparseable requested bound instead of stamping Invalid Date', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, { + requestedEnd: new Date('not-a-date'), + now, + }) + + expect(resolved.endTime).toEqual(now) + expect(resolved.startTime).toEqual(new Date('2026-01-14T12:00:00.000Z')) + }) }) describe('buildDashboardStats', () => { diff --git a/apps/sim/lib/logs/stats.ts b/apps/sim/lib/logs/stats.ts index a7dd2ba6dd2..3b1a287b4b4 100644 --- a/apps/sim/lib/logs/stats.ts +++ b/apps/sim/lib/logs/stats.ts @@ -11,32 +11,64 @@ export interface LogStatsWindow { segmentMs: number } +/** Requested window and clock overrides for {@link resolveLogStatsWindow}. */ +export interface ResolveLogStatsWindowOptions { + /** Caller-supplied left edge, when the request named one. */ + requestedStart?: Date + /** Caller-supplied right edge, when the request named one. */ + requestedEnd?: Date + /** Wall clock, injectable so the fallbacks are deterministic under test. */ + now?: Date +} + +/** An unparseable bound is treated as absent rather than as `Invalid Date`. */ +function usableBound(bound: Date | undefined): Date | undefined { + return bound && Number.isFinite(bound.getTime()) ? bound : undefined +} + /** - * The window the segments span, derived from the rows that exist rather than - * from a caller-supplied range. + * The window the segments span. + * + * An edge the caller named wins outright: no run outside it can be counted, so + * deriving the span from anything else stamps trailing buckets the query has + * already excluded and computes `segmentMs` over a width nobody asked for. * - * A workspace with no runs still has to answer with a window, because + * An omitted edge still falls back to the rows that exist — the oldest matching + * run on the left, and on the right the later of the newest matching run and + * `now`, so a live dashboard's right edge is the present rather than the last + * thing that happened. + * + * A workspace with no matching runs still has to answer with a window, because * `segmentMs` and every segment timestamp are computed from one — hence the - * trailing-24-hour fallback. The end is pushed to `now` whenever the newest run - * is older than that, so a live dashboard's right edge is the present rather - * than the last thing that happened. + * 24-hour fallback. It is measured back from the right edge, so an empty result + * with no bounds reports the trailing 24 hours, and an empty result with only + * an `endDate` reports the 24 hours preceding that date. */ export function resolveLogStatsWindow( bounds: LogStatsBounds, segmentCount: number, - now: Date = new Date() + options: ResolveLogStatsWindowOptions = {} ): LogStatsWindow { + const requestedStart = usableBound(options.requestedStart) + const requestedEnd = usableBound(options.requestedEnd) + const now = options.now ?? new Date() + let startTime: Date let endTime: Date if (!bounds.minTime || !bounds.maxTime) { - endTime = now - startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = requestedEnd ?? now + startTime = requestedStart ?? new Date(endTime.getTime() - 24 * 60 * 60 * 1000) } else { - startTime = new Date(bounds.minTime) - endTime = new Date(Math.max(new Date(bounds.maxTime).getTime(), now.getTime())) + startTime = requestedStart ?? new Date(bounds.minTime) + endTime = requestedEnd ?? new Date(Math.max(new Date(bounds.maxTime).getTime(), now.getTime())) } + /** + * A crossed pair reaches here from the first-party dashboard, whose query + * schema carries no `startDate <= endDate` refinement, so the floor is what + * keeps `segmentMs` positive instead of zero or negative. + */ const totalMs = Math.max(1, endTime.getTime() - startTime.getTime()) return { startTime, diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index 9f59727b7a2..d75f79e7ada 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -147,6 +147,11 @@ describe('MCP server application use cases', () => { ) }) + /** + * The message reaches the REST API and the CLI alike, so it names the + * operation and the server it collided with rather than an HTTP endpoint only + * one of those two callers can reach. + */ it('rejects an existing live URL before mutation and audit', async () => { mocks.idState.mockResolvedValueOnce({ deleted: false }) @@ -155,7 +160,18 @@ describe('MCP server application use cases', () => { principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, }) - ).rejects.toMatchObject({ code: 'conflict' }) + ).rejects.toMatchObject({ + code: 'conflict', + message: expect.stringContaining('Update that server instead of creating a new one'), + }) + + mocks.idState.mockResolvedValueOnce({ deleted: false }) + await expect( + createMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, + }) + ).rejects.toMatchObject({ message: expect.not.stringMatching(/\/api\/v2\//) }) expect(mocks.create).not.toHaveBeenCalled() expect(mocks.audit).not.toHaveBeenCalled() diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index c0d4a05113e..fd5568ae2da 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -291,7 +291,7 @@ export const createMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ if (idState && !idState.deleted) { throw new OrchestrationError( 'conflict', - 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{mcpServerId}.' + `An MCP server with this URL already exists in this workspace: ${serverId}. Update that server instead of creating a new one.` ) } let result: PerformMcpServerResult & { server: McpServerRow } diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts index eaaf105c0a9..e9e8935b900 100644 --- a/apps/sim/lib/mothership/inbox/executor.test.ts +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -13,11 +13,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckWorkspaceAccess, mockGetUserEntityPermissions, + mockResolveOrCreateChat, mockRunHeadlessCopilotLifecycle, mockSendInboxResponse, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), mockGetUserEntityPermissions: vi.fn(), + mockResolveOrCreateChat: vi.fn(), mockRunHeadlessCopilotLifecycle: vi.fn(), mockSendInboxResponse: vi.fn(), })) @@ -34,7 +36,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ })) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - resolveOrCreateChat: vi.fn(), + resolveOrCreateChat: mockResolveOrCreateChat, })) vi.mock('@/lib/copilot/chat/messages-store', () => ({ @@ -94,6 +96,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('owner-1'), })) +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { executeInboxTask } from '@/lib/mothership/inbox/executor' const INBOX_TASK = { @@ -132,6 +135,12 @@ describe('Inbox execution actor', () => { chatId: 'chat-1', }) mockSendInboxResponse.mockResolvedValue('response-1') + mockResolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1' }, + conversationHistory: [], + isNew: true, + }) dbChainMockFns.returning .mockResolvedValueOnce([{ id: 'task-1' }]) .mockResolvedValueOnce([{ model: 'claude-opus-4-8' }]) @@ -205,6 +214,23 @@ describe('Inbox execution actor', () => { expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() }) + it('stamps the shared mothership model on the chat it creates for a task', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [{ ...INBOX_TASK, chatId: null }]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, [{ id: 'member-1' }]) + mockGetUserEntityPermissions.mockResolvedValue('write') + + await executeInboxTask('task-1') + + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + type: 'mothership', + model: MOTHERSHIP_CHAT_DEFAULT_MODEL, + }) + ) + }) + it('leaves an external sender with no permission at none rather than promoting to read', async () => { queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) queueTableRows(schemaMock.workspace, [WORKSPACE]) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 35d21df8cb0..345c5f1c992 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -14,6 +14,7 @@ import { } from '@/lib/copilot/chat/persisted-message' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' @@ -146,7 +147,7 @@ export async function executeInboxTask(taskId: string): Promise { const chatResult = await resolveOrCreateChat({ userId, workspaceId: ws.id, - model: 'claude-opus-4-8', + model: MOTHERSHIP_CHAT_DEFAULT_MODEL, type: 'mothership', }) chatId = chatResult.chatId diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts index 1cd212e25a1..b2253f9c069 100644 --- a/apps/sim/lib/secrets/application/use-cases.test.ts +++ b/apps/sim/lib/secrets/application/use-cases.test.ts @@ -17,6 +17,7 @@ const { mocks } = vi.hoisted(() => ({ keyAccess: vi.fn(), personalMetadata: vi.fn(), setWorkspace: vi.fn(), + updateWorkspaceMetadata: vi.fn(), setPersonal: vi.fn(), deletePersonal: vi.fn(), listCredentials: vi.fn(), @@ -67,6 +68,7 @@ vi.mock('@/lib/credentials/secret-values', () => ({ readWorkspaceSecretValues: mocks.readWorkspaceValues, setPersonalSecret: mocks.setPersonal, setWorkspaceSecret: mocks.setWorkspace, + updateWorkspaceSecretMetadata: mocks.updateWorkspaceMetadata, })) import { @@ -130,6 +132,10 @@ describe('secret application use cases', () => { mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) mocks.setWorkspace.mockResolvedValue({ created: true, updatedAt: personalUpdatedAt }) + mocks.updateWorkspaceMetadata.mockResolvedValue({ + created: false, + updatedAt: personalUpdatedAt, + }) mocks.setPersonal.mockResolvedValue({ created: true, updatedAt: personalUpdatedAt }) mocks.personalMetadata.mockResolvedValue(null) mocks.deletePersonal.mockResolvedValue(true) @@ -444,6 +450,139 @@ describe('secret application use cases', () => { expect(mocks.personalMetadata).not.toHaveBeenCalled() }) + it('updates workspace metadata through the update-only manager, never re-encrypting the value', async () => { + const result = await setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: false, + }, + }) + + expect(mocks.updateWorkspaceMetadata).toHaveBeenCalledWith({ + workspaceId: workspace.workspaceId, + name: secret.envKey, + description: undefined, + unredacted: false, + }) + expect(mocks.setWorkspace).not.toHaveBeenCalled() + expect(mocks.setPersonal).not.toHaveBeenCalled() + expect(result.created).toBe(false) + }) + + it('still checks the per-key ACL before a metadata-only write', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ + knownKeys: new Set([secret.envKey]), + adminKeys: new Set(), + }) + + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: true, + }, + }) + ).rejects.toThrow(/Credential admin permission required/) + + expect(mocks.updateWorkspaceMetadata).not.toHaveBeenCalled() + }) + + it('reports a metadata write against a missing secret as not found rather than creating one', async () => { + mocks.updateWorkspaceMetadata.mockResolvedValue(null) + + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: true, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.setWorkspace).not.toHaveBeenCalled() + }) + + it('reports a secret deleted between the metadata write and the response read as not found', async () => { + mocks.updateWorkspaceMetadata.mockResolvedValue({ + created: false, + updatedAt: personalUpdatedAt, + }) + mocks.listCredentials.mockResolvedValue({ data: [], nextCursorKeys: null }) + + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: true, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('refuses a workspace write that names none of the three writable fields', async () => { + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + }, + }) + ).rejects.toThrow(/value, description, or unredacted is required/) + + expect(mocks.updateWorkspaceMetadata).not.toHaveBeenCalled() + expect(mocks.setWorkspace).not.toHaveBeenCalled() + }) + + it('refuses a value-less personal write in the use case, not just the contract', async () => { + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: personalSecret.envKey, + scope: 'personal', + }, + }) + ).rejects.toThrow(/value is required for a personal secret/) + + expect(mocks.setPersonal).not.toHaveBeenCalled() + expect(mocks.updateWorkspaceMetadata).not.toHaveBeenCalled() + }) + + it('audits a metadata-only write as an update rather than as setting a value', async () => { + await setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: false, + }, + }) + + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + description: `Updated workspace secret "${secret.envKey}" metadata`, + metadata: expect.objectContaining({ unredacted: false, operation: 'secrets.set' }), + }) + ) + }) + it('deletes a personal secret for the caller rather than for one workspace', async () => { const execute = deleteSecretUseCase.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 1f5b196c629..f4150e16c87 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -19,6 +19,7 @@ import { readWorkspaceSecretValues, setPersonalSecret, setWorkspaceSecret, + updateWorkspaceSecretMetadata, } from '@/lib/credentials/secret-values' import { secretOperations } from '@/lib/secrets/application/operations' import { scanSecretReferences } from '@/lib/secrets/references/scan' @@ -268,7 +269,12 @@ export interface SetSecretInput { workspaceId: string name: string scope: SecretScope - value: string + /** + * Omitted for a workspace-scope metadata-only write, which updates `description` + * and `unredacted` alone and never re-encrypts or replaces the stored value. + * Required for personal scope, which has no other writable field. + */ + value?: string /** * Workspace scope only, and rejected at the contract for personal scope: an * `env_personal` row is a per-workspace mirror of one user-global secret, so a @@ -307,6 +313,50 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ } if (input.scope === 'workspace') { + /** + * A value-less workspace write takes the update-only manager: no encryption, + * no variables rewrite, and no credential insert, so it cannot create a + * secret and cannot cost the caller a re-transmission of the plaintext. A + * miss is a 404, and `created: false` keeps the route answering 200 for + * something it did not create. + */ + if (input.value === undefined) { + /** + * A write that names none of the three writable fields would still issue + * the UPDATE, stamping `updatedAt` and dropping the workspace's env cache + * entry for nothing. The contract rejects it; this repeats the guard for + * every other surface that reaches the use case directly. + */ + if (input.description === undefined && input.unredacted === undefined) { + throw new OrchestrationError( + 'validation', + 'value, description, or unredacted is required' + ) + } + + const metadata = await updateWorkspaceSecretMetadata({ + workspaceId: context.workspaceId, + name: input.name, + description: input.description, + unredacted: input.unredacted, + }) + if (!metadata) throw new OrchestrationError('not_found', 'Secret not found') + /** + * The response read is a second statement, so a delete committed between + * the two leaves nothing to report back. That is the same disappearance + * the miss above answers, so it answers the same way rather than raising + * the unclassified fault a "must exist" read would. + */ + const updated = await findSecretMetadata({ + workspaceId: context.workspaceId, + userId, + scope: 'workspace', + name: input.name, + }) + if (!updated) throw new OrchestrationError('not_found', 'Secret not found') + return { secret: updated, userId, created: false } + } + const mutation = await setWorkspaceSecret({ workspaceId: context.workspaceId, name: input.name, @@ -323,6 +373,15 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ return { secret, userId, created: mutation.created } } + /** + * Personal scope has no metadata field, so a value-less write here could only be + * a silent no-op. The contract rejects it; this repeats the guard for every + * other surface that reaches the use case directly. + */ + if (input.value === undefined) { + throw new OrchestrationError('validation', 'value is required for a personal secret') + } + const mutation = await setPersonalSecret({ userId, name: input.name, value: input.value }) const secret = await getPersonalSecretMetadata({ workspaceId: context.workspaceId, @@ -337,7 +396,10 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ resourceType: AuditResourceType.ENVIRONMENT, resourceId: `${input.scope}:${input.name}`, resourceName: input.name, - description: `Set ${input.scope} secret "${input.name}"`, + description: + input.value === undefined + ? `Updated ${input.scope} secret "${input.name}" metadata` + : `Set ${input.scope} secret "${input.name}"`, metadata: { scope: input.scope, name: input.name, diff --git a/apps/sim/lib/skills/application/editor-use-cases.test.ts b/apps/sim/lib/skills/application/editor-use-cases.test.ts index 09cbd9e0a14..531dda3f3ce 100644 --- a/apps/sim/lib/skills/application/editor-use-cases.test.ts +++ b/apps/sim/lib/skills/application/editor-use-cases.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ listEditors: vi.fn(), listWorkspaceMembers: vi.fn(), recordAudit: vi.fn(), + getSkillById: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -53,7 +54,7 @@ vi.mock('@/lib/skills/orchestration', () => ({ })) vi.mock('@/lib/workflows/skills/operations', () => ({ - getSkillById: vi.fn(), + getSkillById: mocks.getSkillById, listSkillSummariesPage: vi.fn(), listSkillsForUser: vi.fn(), })) @@ -167,6 +168,39 @@ describe('skill editor application use cases', () => { expect(mocks.resolvePermission).toHaveBeenCalled() }) + /** + * The sort orders a copy, never the array the loader handed back. `listSkillEditors` + * builds a fresh array today so nothing else can observe the mutation, but the use + * case does not own that array and must not reorder it for whatever reads it next. + */ + it('sorts without reordering the array the loader returned', async () => { + queueSkill() + const loaded = [ + targetEditor, + { + ...targetEditor, + id: 'workspace-admin-user-3', + userId: 'user-3', + userName: 'Grace', + userEmail: 'grace@example.com', + isWorkspaceAdmin: true, + }, + ] + mocks.listEditors.mockResolvedValue(loaded) + + await listSkillEditorsUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: SKILL_ID, + sortBy: 'name', + sortOrder: 'desc', + }, + }) + + expect(loaded.map(({ userId }) => userId)).toEqual([targetEditor.userId, 'user-3']) + }) + it('creates an explicit grant and audits the authoritative result', async () => { queueSkill() dbChainMockFns.returning.mockResolvedValueOnce([{ id: targetEditor.id }]) @@ -288,4 +322,104 @@ describe('skill editor application use cases', () => { expect(mocks.loadWorkspace).not.toHaveBeenCalled() expect(mocks.resolvePermission).not.toHaveBeenCalled() }) + + /** + * A built-in skill is materialized from code, so `skills get` and `skills + * list` both return it. Reporting it as missing from the editor verbs + * contradicted that: the read and the writes disagree about what is possible, + * not about what exists. + */ + describe('built-in skills', () => { + const BUILTIN_ID = 'builtin-research' + + beforeEach(() => { + mocks.getSkillById.mockResolvedValue({ ...skillRow, id: BUILTIN_ID }) + }) + + it('lists an empty editor roster rather than refusing the read', async () => { + const result = await listSkillEditorsUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: BUILTIN_ID, + sortBy: 'email', + sortOrder: 'asc', + }, + }) + + expect(result).toMatchObject({ editors: [], hasMore: false }) + expect(mocks.listEditors).not.toHaveBeenCalled() + expect(mocks.resolvePermission).toHaveBeenCalled() + }) + + it('refuses a grant as read-only rather than as missing', async () => { + await expect( + grantSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: BUILTIN_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Built-in skills are read-only and cannot be modified', + }) + }) + + it('refuses a revoke as read-only rather than as missing', async () => { + await expect( + revokeSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: BUILTIN_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Built-in skills are read-only and cannot be modified', + }) + }) + + /** + * The internal members route maps no workspace id, so the list used to fall + * through to the mutation resolver and answer a read with the read-only + * refusal — the same incoherence the empty roster was added to remove. + */ + it('never answers a workspace-less list with the read-only refusal', async () => { + await expect( + listSkillEditorsUseCase.execute({ + principal, + input: { skillId: BUILTIN_ID, sortBy: 'email', sortOrder: 'asc' }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: + 'Listing the editors of a built-in skill requires a workspace scope to authorize against', + }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.listEditors).not.toHaveBeenCalled() + }) + + /** + * The internal members contract has no workspace slot, and the v2 contract + * makes `workspaceId` a required query param that is rejected before this + * branch runs. So no caller that reaches this refusal can act on the field + * name, and the message must not spell one. + */ + it('refuses without naming a wire field the caller cannot send', async () => { + const error = await listSkillEditorsUseCase + .execute({ + principal, + input: { skillId: BUILTIN_ID, sortBy: 'email', sortOrder: 'asc' }, + }) + .catch((caught: Error) => caught) + + expect((error as Error).message).not.toMatch(/workspaceId/) + }) + }) }) diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 65499c817bb..278c0208d3c 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -58,12 +58,15 @@ async function resolveSkillContext(workspaceId: string, skillId: string): Promis return { ...workspace, skill: row } } -async function resolveSkillEditorContext( +/** + * Resolves a workspace-owned skill for any editor verb, deriving the scope from + * the skill's own row when the caller asserted none. Both the mutation and the + * list resolver build on it, so neither has to route through the other. + */ +async function resolveOwnedSkillEditorContext( skillId: string, assertedWorkspaceId?: string ): Promise { - if (isBuiltinSkillId(skillId)) throw new OrchestrationError('not_found', 'Skill not found') - const [row] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1) if (!row?.workspaceId || (assertedWorkspaceId && row.workspaceId !== assertedWorkspaceId)) { throw new OrchestrationError('not_found', 'Skill not found') @@ -73,6 +76,61 @@ async function resolveSkillEditorContext( return { ...workspace, skill: row } } +/** + * Resolves the skill an editor MUTATION addresses. + * + * A built-in skill exists — `getSkillById` materializes it from code — so + * reporting it as missing would contradict the read verbs. It has no editor + * roster to change, which is the same refusal `resolveEditableSkill` gives for + * update and delete. + */ +async function resolveSkillEditorContext( + skillId: string, + assertedWorkspaceId?: string +): Promise { + if (isBuiltinSkillId(skillId)) { + throw new OrchestrationError( + 'validation', + 'Built-in skills are read-only and cannot be modified' + ) + } + + return resolveOwnedSkillEditorContext(skillId, assertedWorkspaceId) +} + +/** + * Resolves the skill an editor LIST addresses. + * + * Listing is a read, so a built-in id is not a malformed request: the skill is + * real and `skills get` returns it. It never falls through to the mutation + * resolver, so the read can never answer that the caller tried to modify a + * read-only skill. + * + * A built-in skill owns no row, so there is no workspace to derive scope from + * and nothing to authorize the caller against. The read therefore requires the + * caller to assert the workspace rather than guessing one: an inferred workspace + * would authorize against a scope the caller never asserted. + * + * The refusal names the missing scope, not a wire field. This use case is shared + * by both editor surfaces and neither can act on a field name: v2 takes a + * required `workspaceId` query param, so its contract rejects the omission + * before this branch runs, while the internal members route maps no workspace id + * and has no slot to send one. Naming the field would tell the only caller that + * reaches this message to send something it cannot send. + */ +async function resolveSkillEditorListContext(input: ListSkillEditorsInput): Promise { + if (isBuiltinSkillId(input.skillId)) { + if (!input.workspaceId) { + throw new OrchestrationError( + 'validation', + 'Listing the editors of a built-in skill requires a workspace scope to authorize against' + ) + } + return resolveSkillContext(input.workspaceId, input.skillId) + } + return resolveOwnedSkillEditorContext(input.skillId, input.workspaceId) +} + const authorizationOptions = { delegation: skillDelegationPolicy } async function requireSkillEditorAccess(userId: string, context: SkillContext): Promise { @@ -368,15 +426,18 @@ export interface ListSkillEditorsInput { export const listSkillEditorsUseCase = defineAuthorizedWorkspaceUseCase({ operation: skillOperations.listEditors, resolveContext: ({ input }: { input: ListSkillEditorsInput }) => - resolveSkillEditorContext(input.skillId, input.workspaceId), + resolveSkillEditorListContext(input), authorizationOptions, async execute({ input, context }) { + if (isBuiltinSkillId(input.skillId)) { + return { editors: [], hasMore: false, offset: input.offset ?? 0, limit: input.limit ?? 0 } + } const editors = await listSkillEditors({ id: context.skill.id, workspaceId: context.workspaceId, }) const direction = input.sortOrder === 'asc' ? 1 : -1 - const sorted = editors.sort((left, right) => { + const sorted = [...editors].sort((left, right) => { const leftValue = input.sortBy === 'email' ? (left.userEmail ?? '') : (left.userName ?? '') const rightValue = input.sortBy === 'email' ? (right.userEmail ?? '') : (right.userName ?? '') const primary = leftValue.localeCompare(rightValue) diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts index db7ad035c22..0726111a1ba 100644 --- a/apps/sim/lib/table/application/bulk.test.ts +++ b/apps/sim/lib/table/application/bulk.test.ts @@ -639,6 +639,83 @@ describe('path-keyed bulk table selections', () => { ]) }) + /** + * The v2 audit formatter nulls `resourceId` for every folder row, so the leaf + * name is all a v2 consumer would have left — and two folders named `dup` in + * different trees produce byte-identical rows. The single-folder delete + * records the path for the same reason. + */ + it('records the folder path a path-keyed bulk delete named, as the single delete does', async () => { + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Sales' }], + failed: [], + folderCount: 1, + resourceCount: 0, + }) + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Sales' }], + notFound: [], + contained: [], + covered: new Set(), + }) + + await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: [], + folders: ['/Sales'], + }, + }) + + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'folder.deleted', + resourceId: 'folder-1', + description: 'Deleted table folder "/Sales"', + metadata: expect.objectContaining({ path: '/Sales' }), + }) + ) + }) + + /** + * The id-keyed surface skips the folder-tree index read on purpose, so it has + * no path to record. Pinned so a later change cannot quietly take that lock. + */ + it('records no path for an id-keyed bulk delete', async () => { + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Sales' }], + failed: [], + folderCount: 1, + resourceCount: 0, + }) + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Sales' }], + notFound: [], + contained: [], + covered: new Set(), + }) + + await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'ids' as const, + tableIds: [], + folders: ['folder-1'], + }, + }) + + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'folder.deleted', + description: 'Deleted table folder "Sales"', + metadata: expect.not.objectContaining({ path: expect.anything() }), + }) + ) + }) + /** * One index for the whole batch: `resolveTableFolderPath` takes the folder * tree lock per call, so per-path resolution would be a lock acquisition each. diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts index a75c89d3b33..9fd587b4732 100644 --- a/apps/sim/lib/table/application/bulk.ts +++ b/apps/sim/lib/table/application/bulk.ts @@ -573,30 +573,40 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({ * is unbounded, and per-resource entries would let one request write * thousands of audit rows. */ - projectAudit: ({ result }) => - result.auditedDeletions.map((item) => - item.kind === 'folder' - ? { - action: AuditAction.FOLDER_DELETED, - resourceType: AuditResourceType.FOLDER, - resourceId: item.id, - resourceName: item.name, - description: `Deleted table folder "${item.name}"`, - metadata: { - folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, - affected: result.deletedItems, - bulk: true, - }, - } - : { - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: item.id, - resourceName: item.name, - description: `Archived table "${item.name}"`, - metadata: { bulk: true }, - } - ), + projectAudit: ({ context, result }) => + result.auditedDeletions.map((item) => { + if (item.kind !== 'folder') { + return { + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: item.id, + resourceName: item.name, + description: `Archived table "${item.name}"`, + metadata: { bulk: true }, + } + } + /** + * The v2 audit formatter nulls `resourceId` for every folder row, so the + * caller's own path is the only thing left that distinguishes two + * same-named folders — the single delete records it for the same reason. + * Present only for a path-keyed selection: an id-keyed one deliberately + * skips the folder-tree index read that builds the map. + */ + const path = context.folderPathById?.get(item.id) + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: `Deleted table folder "${path ?? item.name}"`, + metadata: { + folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, + ...(path !== undefined && { path }), + affected: result.deletedItems, + bulk: true, + }, + } + }), afterSuccess: ({ result }) => { rethrowTableBatchTerminalFailure(result) }, diff --git a/apps/sim/lib/table/application/folder-paths.test.ts b/apps/sim/lib/table/application/folder-paths.test.ts new file mode 100644 index 00000000000..bc737b2f62b --- /dev/null +++ b/apps/sim/lib/table/application/folder-paths.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import type { folder as folderTable } from '@sim/db/schema' +import { describe, expect, it } from 'vitest' +import { buildFolderPathIndex } from '@/lib/folders/paths' +import { + archivableTableFolderPath, + tableFolderPathForId, +} from '@/lib/table/application/folder-paths' + +const activeFolder = { + id: 'folder-active', + resourceType: 'table' as const, + name: 'Reports', + userId: 'owner-1', + workspaceId: 'workspace-1', + parentId: null, + sortOrder: 0, + locked: false, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, +} as typeof folderTable.$inferSelect + +/** + * The index the listing actually projects against: `loadActiveFolderPathIndex` + * filters on `isNull(deletedAt)`, so an archived folder is absent from it by + * construction and its id dangles. + */ +const index = buildFolderPathIndex([activeFolder]) +const ARCHIVED_FOLDER_ID = 'folder-archived' + +describe('table folder path projection', () => { + /** + * The scoping proof. A live table pointing at an unresolvable folder is a + * genuine inconsistency, so the strict projector every active-only call site + * uses must keep throwing on the very input the lenient one tolerates. + */ + it('throws on a dangling folder when the table is expected to be active', () => { + expect(() => tableFolderPathForId(index, ARCHIVED_FOLDER_ID)).toThrow( + 'Table references an inactive or missing folder' + ) + }) + + it('answers the root path instead, which is where restore would place it', () => { + expect(archivableTableFolderPath(index, ARCHIVED_FOLDER_ID)).toBe('/') + }) + + it('still resolves a folder that is active', () => { + expect(archivableTableFolderPath(index, activeFolder.id)).toBe('/Reports') + }) + + it('treats no folder as the root', () => { + expect(archivableTableFolderPath(index, null)).toBe('/') + expect(archivableTableFolderPath(index, undefined)).toBe('/') + }) +}) diff --git a/apps/sim/lib/table/application/folder-paths.ts b/apps/sim/lib/table/application/folder-paths.ts index e6864954eb1..0b45216362e 100644 --- a/apps/sim/lib/table/application/folder-paths.ts +++ b/apps/sim/lib/table/application/folder-paths.ts @@ -34,3 +34,25 @@ export function tableFolderPathForId( if (!path) throw new Error('Table references an inactive or missing folder') return path } + +/** + * The same projection for a table that may itself be archived. + * + * Archiving a folder cascades onto the tables inside it but leaves their + * `folderId` pointing at the now-inactive row — which is exactly why restore has + * to re-root a dangling `folderId`. So on any read that can surface an archived + * table, an unresolvable folder is the expected state rather than the + * inconsistency {@link tableFolderPathForId} treats it as, and one such row + * would otherwise throw a bare `Error` and 500 the whole page with no cursor + * position able to skip past it. + * + * The root is the honest answer: it is where restore would put the table if the + * caller restored it now. + */ +export function archivableTableFolderPath( + index: FolderPathIndex, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + return index.pathById.get(folderId) ?? ROOT_FOLDER_PATH +} diff --git a/apps/sim/lib/table/application/tables.test.ts b/apps/sim/lib/table/application/tables.test.ts index 16e58fe2791..ca3067a2bc2 100644 --- a/apps/sim/lib/table/application/tables.test.ts +++ b/apps/sim/lib/table/application/tables.test.ts @@ -59,9 +59,19 @@ vi.mock('@/lib/table/application/context', () => ({ resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, })) +/** + * The two projectors are deliberately distinguishable here: the strict one + * reproduces the bare `Error` a dangling `folderId` raises in production, so a + * listing that reaches for the wrong one fails the test the same way it 500s + * the page. + */ vi.mock('@/lib/table/application/folder-paths', () => ({ resolveTableFolderPath: vi.fn(), - tableFolderPathForId: () => '/', + tableFolderPathForId: (_index: unknown, folderId: string | null | undefined) => { + if (folderId) throw new Error('Table references an inactive or missing folder') + return '/' + }, + archivableTableFolderPath: () => '/', })) vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) @@ -102,6 +112,59 @@ describe('table list scope', () => { mocks.queryTables.mockResolvedValue({ tables: [], nextKeys: null }) }) + /** + * Archiving a folder cascades onto its tables but leaves each `folderId` + * pointing at the soft-deleted row, so the archived scope is exactly the + * population whose folder cannot resolve. Projected strictly, one such row + * threw and 500'd the whole page — and no cursor position could step past it, + * which made every archived table id undiscoverable and `restore` unreachable. + */ + it('renders an archived table whose folder was archived too at the root', async () => { + mocks.queryTables.mockResolvedValue({ + tables: [{ ...ARCHIVED, folderId: 'folder-archived' }], + nextKeys: null, + }) + + const result = await listTablesUseCase.execute({ + principal: PRINCIPAL, + input: { + workspaceId: 'workspace-1', + scope: 'archived', + sortBy: 'createdAt', + sortOrder: 'asc', + limit: 10, + }, + }) + + expect(result.tables).toEqual([ + { table: { ...ARCHIVED, folderId: 'folder-archived' }, folderPath: '/' }, + ]) + }) + + /** + * The negative leg. A LIVE table pointing at a folder that does not resolve is + * a genuine inconsistency, so the active listing must stay loud rather than + * quietly re-rooting it. + */ + it('still fails loudly on a dangling folder in the active listing', async () => { + mocks.queryTables.mockResolvedValue({ + tables: [{ ...ARCHIVED, archivedAt: null, folderId: 'folder-archived' }], + nextKeys: null, + }) + + await expect( + listTablesUseCase.execute({ + principal: PRINCIPAL, + input: { + workspaceId: 'workspace-1', + sortBy: 'createdAt', + sortOrder: 'asc', + limit: 10, + }, + }) + ).rejects.toThrow('Table references an inactive or missing folder') + }) + it('lets the caller scope the listing without changing the default', async () => { await listTablesUseCase.execute({ principal: PRINCIPAL, diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 8f4ca340b3c..001c3ba9f13 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -17,7 +17,6 @@ import { restoreTable, type TableDefinition, type TableSchema, - type TableScope, updateTableDescription, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' @@ -26,14 +25,28 @@ import { resolveArchivedTableContext, resolveTableWorkspaceContext, } from '@/lib/table/application/context' -import { resolveTableFolderPath, tableFolderPathForId } from '@/lib/table/application/folder-paths' +import { + archivableTableFolderPath, + resolveTableFolderPath, + tableFolderPathForId, +} from '@/lib/table/application/folder-paths' import { tableOperations } from '@/lib/table/application/operations' import { signalTableSchemaChanged } from '@/lib/table/events' export interface ListTablesInput { workspaceId: string - /** Which lifecycle set to list. Omitted means `active`, matching every shipped caller. */ - scope?: TableScope + /** + * Which lifecycle set to list. Omitted means `active`, matching every shipped + * caller. Deliberately narrower than the `TableScope` the query layer takes: + * its third value, `'all'`, would mix archived rows into a page projected by + * the strict folder-path resolver, which throws on the dangling `folderId` a + * folder archive leaves behind. + * + * The value set is the one `ListWorkflowsInput['scope']` accepts; the + * optionality is not, since that sibling requires a scope where this one + * defaults an absent scope through to the query. + */ + scope?: 'active' | 'archived' folderPath?: string search?: string sortBy: V2TableSortBy @@ -68,7 +81,10 @@ export const listTablesUseCase = defineAuthorizedTableUseCase({ return { tables: tables.map((table) => ({ table, - folderPath: tableFolderPathForId(folderIndex, table.folderId), + folderPath: + input.scope === 'archived' + ? archivableTableFolderPath(folderIndex, table.folderId) + : tableFolderPathForId(folderIndex, table.folderId), })), nextKeys, sortBy: input.sortBy, diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index 16a704798b4..a9acf0dc61a 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -22,6 +22,8 @@ const mocks = vi.hoisted(() => ({ preValidate: vi.fn(), collectReferences: vi.fn(), collectToolReferences: vi.fn(), + assertIdsUnclaimed: vi.fn(), + collectGraphIds: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -46,12 +48,15 @@ vi.mock('@/lib/workflows/application/context', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ replaceWorkflowNormalizedState: mocks.replace, + assertWorkflowGraphIdsUnclaimed: mocks.assertIdsUnclaimed, + collectWorkflowGraphIds: mocks.collectGraphIds, })) vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowFromNormalizedTables: mocks.loadNormalized, })) vi.mock('@/lib/workflows/sanitization/validation', () => ({ validateWorkflowState: mocks.validate, + sanitizeAgentToolsInBlocks: (blocks: Record) => ({ blocks, warnings: [] }), })) vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mocks.needsRedeployment, @@ -104,6 +109,7 @@ vi.mock('@/lib/workflows/autolayout', () => ({ })) import { ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { applyWorkflowOperations } from '@/lib/workflows/application/apply-workflow-operations' import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' @@ -150,6 +156,8 @@ function graph(blocks: Record = { 'block-1': BLOCK }) { return { blocks, edges: [], loops: {}, parallels: {} } } +const GRAPH_IDS = { blockIds: ['block-1'], edgeIds: [], subflowIds: [] } + describe('applyWorkflowOperations', () => { beforeEach(() => { vi.clearAllMocks() @@ -172,6 +180,8 @@ describe('applyWorkflowOperations', () => { mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] }) mocks.replace.mockResolvedValue({ warnings: [], state: graph() }) mocks.needsRedeployment.mockResolvedValue(true) + mocks.collectGraphIds.mockReturnValue(GRAPH_IDS) + mocks.assertIdsUnclaimed.mockResolvedValue(undefined) }) it('writes once, through the shared persistence primitive', async () => { @@ -232,6 +242,75 @@ describe('applyWorkflowOperations', () => { expect(mocks.notify).not.toHaveBeenCalled() }) + /** + * The commit goes through `replaceWorkflowNormalizedState`, whose in- + * transaction pre-check refuses a graph id another workflow already owns. + * A dry run that skips it reports success for a body whose commit is a 409. + */ + it('checks the ids the commit would insert before reporting success', async () => { + await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + + expect(mocks.collectGraphIds).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: { 'block-1': { ...BLOCK, height: 0, horizontalHandles: true } }, + edges: [], + }) + ) + expect(mocks.assertIdsUnclaimed).toHaveBeenCalledWith( + expect.anything(), + 'workflow-1', + GRAPH_IDS + ) + }) + + it('refuses a dry run whose commit would conflict on a claimed id', async () => { + const conflict = new OrchestrationError( + 'conflict', + 'Block ids already used by another workflow: block-1' + ) + mocks.assertIdsUnclaimed.mockRejectedValue(conflict) + + await expect( + applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + ).rejects.toBe(conflict) + }) + + /** + * A committed apply reports `[...validation.warnings, ...persisted.warnings]`, + * the second half raised by the preparation step inside the write. A dry run + * that dropped that half would not tell a caller its dangling edge is about + * to disappear — the one thing a preview exists to say. + */ + it('reports the preparation warnings the committed apply would', async () => { + mocks.applyOperations.mockReturnValue({ + state: { + blocks: { 'block-1': BLOCK }, + edges: [{ id: 'edge-9', source: 'block-1', target: 'block-missing' }], + loops: {}, + parallels: {}, + }, + validationErrors: [], + skippedItems: [], + }) + mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: ['validation note'] }) + + const dry = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + + expect(dry.warnings).toEqual([ + 'validation note', + 'Dropped edge "edge-9": edge references a missing block', + ]) + }) + /** The preview is worthless if it does not carry the findings. */ it('reports the same lint a committed apply would', async () => { const dry = await applyWorkflowOperations.execute({ diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index 45f4a208967..ff5a34aebb9 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' @@ -44,7 +45,12 @@ import { type ValidationError, } from '@/lib/workflows/editing/types' import { preValidateCredentialInputs } from '@/lib/workflows/editing/validation' -import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' +import { + assertWorkflowGraphIdsUnclaimed, + collectWorkflowGraphIds, + replaceWorkflowNormalizedState, +} from '@/lib/workflows/persistence/replace-normalized-state' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { withBlockVisibility } from '@/blocks/visibility/server-context' @@ -358,6 +364,30 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ * dry run reports precisely what a committed apply of the same body would. */ if (input.dryRun) { + /** + * The same preparation the committed write runs, so a dry run checks the + * ids that write would actually insert — the prepared graph, not the + * engine's output — and reports the notes that write would raise. Without + * it a dry run could report success, and no warnings, for a body whose + * commit is refused with a conflict or silently sanitized. + * + * `prepareWorkflowStateForPersistence` is **not** pure: its sanitization + * step rewrites nested sub-block objects in place, and `graph.blocks` + * holds the very objects this response returns. That is safe only because + * `validateWorkflowState(..., { sanitize: true })` above already ran the + * same sanitizer over these blocks, so this second pass writes back the + * values that are already there. Keep that call ahead of this one. + */ + const prepared = prepareWorkflowStateForPersistence({ + blocks: graph.blocks, + edges: graph.edges, + }) + await assertWorkflowGraphIdsUnclaimed( + db, + context.workflowId, + collectWorkflowGraphIds(prepared.state) + ) + logger.info('Evaluated workflow operations without persisting', { workflowId: context.workflowId, workspaceId: context.workspaceId, @@ -377,7 +407,7 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ inputValidationErrors: validationErrors, mintedBlockIds, lint, - warnings: validation.warnings, + warnings: [...validation.warnings, ...prepared.warnings], needsRedeployment: await checkNeedsRedeployment(context.workflowId), dryRun: true, } diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts index 60e5552712a..8e3354813f6 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts @@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), notify: vi.fn(), replace: vi.fn(), + prepare: vi.fn(), + collectGraphIds: vi.fn(), + assertIdsUnclaimed: vi.fn(), validate: vi.fn(), needsRedeployment: vi.fn(), })) @@ -35,8 +38,13 @@ vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkflowApplicationContext: mocks.resolveContext, })) vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/persistence/prepare-state', () => ({ + prepareWorkflowStateForPersistence: mocks.prepare, +})) vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ replaceWorkflowNormalizedState: mocks.replace, + collectWorkflowGraphIds: mocks.collectGraphIds, + assertWorkflowGraphIdsUnclaimed: mocks.assertIdsUnclaimed, })) vi.mock('@/lib/workflows/sanitization/validation', () => ({ validateWorkflowState: mocks.validate, @@ -88,6 +96,12 @@ describe('replaceWorkflowState', () => { state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, }) mocks.needsRedeployment.mockResolvedValue(true) + mocks.prepare.mockReturnValue({ + state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + warnings: [], + }) + mocks.collectGraphIds.mockReturnValue({ blockIds: ['block-1'], edgeIds: [], subflowIds: [] }) + mocks.assertIdsUnclaimed.mockResolvedValue(undefined) }) /** @@ -333,6 +347,77 @@ describe('replaceWorkflowState', () => { expect(dry.edgesCount).toBe(committed.edgesCount) }) + /** + * The preview promised in {@link ReplaceWorkflowStateInput.dryRun} is + * byte-identical to the committed write of the same body, and preparation + * is where a dropped edge or a stripped inline secret is noted. Reporting + * only the validation half made the dry run quietly less informative than + * the write it previews. + */ + it('merges the preparation warnings a committed write would report', async () => { + mocks.validate.mockReturnValue({ + valid: true, + errors: [], + warnings: ['Dropped block "block-2"'], + }) + mocks.prepare.mockReturnValue({ + state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + warnings: ['Dropped edge "edge-9": target block does not exist'], + }) + mocks.replace.mockResolvedValue({ + warnings: ['Dropped edge "edge-9": target block does not exist'], + state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + }) + + const dry = await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + const committed = await replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + + expect(dry.warnings).toEqual([ + 'Dropped block "block-2"', + 'Dropped edge "edge-9": target block does not exist', + ]) + expect(dry.warnings).toEqual(committed.warnings) + }) + + /** + * A dry run that reports clean for a body that cannot commit is worse than + * the fault it hides. It checks the ids the write would actually insert — + * the prepared graph's, not the caller's body's. + */ + it('refuses a graph whose ids another workflow already owns', async () => { + mocks.assertIdsUnclaimed.mockRejectedValueOnce( + new OrchestrationError('conflict', 'Block ids already used by another workflow: block-1') + ) + + await expect( + replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('checks the ids the prepared graph would insert, not the ids sent', async () => { + const prepared = { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} } + mocks.prepare.mockReturnValue({ state: prepared, warnings: [] }) + + await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + + expect(mocks.collectGraphIds).toHaveBeenCalledWith(prepared) + expect(mocks.assertIdsUnclaimed).toHaveBeenCalledWith(expect.anything(), 'workflow-1', { + blockIds: ['block-1'], + edgeIds: [], + subflowIds: [], + }) + }) + /** A locked workflow refuses the preview too, or the preview would lie. */ it('refuses when the workflow cannot be mutated', async () => { workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValueOnce( diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.ts b/apps/sim/lib/workflows/application/replace-workflow-state.ts index 5dff1adfa36..627fce476a4 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.ts @@ -5,6 +5,7 @@ import { requirePrincipalSubjectUserId, resolvePrincipalAttribution, } from '@sim/auth/principal' +import { db } from '@sim/db' import { createLogger } from '@sim/logger' import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' import { principalAuditSource } from '@/lib/core/application' @@ -19,7 +20,12 @@ import { normalizeWorkflowVariables } from '@/lib/workflows/application/workflow import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import type { WorkflowLintReport } from '@/lib/workflows/editing/lint' import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report' -import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' +import { + assertWorkflowGraphIdsUnclaimed, + collectWorkflowGraphIds, + replaceWorkflowNormalizedState, +} from '@/lib/workflows/persistence/replace-normalized-state' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' const logger = createLogger('ReplaceWorkflowState') @@ -136,6 +142,19 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ }) if (input.dryRun) { + /** + * The same preparation the committed write runs, so a dry run reports the + * notes that write would produce and checks the ids it would actually + * insert — the prepared graph, not the caller's body. Preparing here and + * again inside the write is the cost of the two paths never disagreeing. + */ + const prepared = prepareWorkflowStateForPersistence(graph) + await assertWorkflowGraphIdsUnclaimed( + db, + context.workflowId, + collectWorkflowGraphIds(prepared.state) + ) + logger.info('Validated workflow state without persisting', { workflowId: context.workflowId, workspaceId: context.workspaceId, @@ -147,7 +166,7 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ workspaceId: context.workspaceId, blocksCount: Object.keys(graph.blocks).length, edgesCount: graph.edges.length, - warnings: validation.warnings, + warnings: [...validation.warnings, ...prepared.warnings], needsRedeployment: await checkNeedsRedeployment(context.workflowId), lint, dryRun: true, diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts index 5acceca9fb5..516e73fb092 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts @@ -1,7 +1,9 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { and, inArray, ne } from 'drizzle-orm' +import { DrizzleQueryError } from 'drizzle-orm/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -52,6 +54,27 @@ function input(overrides: Record = {}) { } as Parameters[0] } +/** + * The shape production actually throws: Drizzle wraps every driver fault in a + * `DrizzleQueryError` whose own message is the SQL text and which carries no + * `code`, putting the driver error on `.cause`. A flat error object is a shape + * the database layer never produces. + */ +function wrapDriverError(cause: Error): Error { + return new DrizzleQueryError( + 'insert into "workflow_edges" ("id", "workflow_id") values ($1, $2)', + ['edge-1', 'workflow-1'], + cause + ) +} + +function uniqueViolation(constraintName: string): Error { + return Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + constraint_name: constraintName, + }) +} + describe('replaceWorkflowNormalizedState', () => { beforeEach(() => { vi.clearAllMocks() @@ -147,6 +170,183 @@ describe('replaceWorkflowNormalizedState', () => { expect(mocks.extractCustomTools).not.toHaveBeenCalled() }) + /** + * `workflow_blocks.id`, `workflow_edges.id`, and `workflow_subflows.id` are + * GLOBAL primary keys while the replace deletes only this workflow's rows, so + * an id owned elsewhere survives the delete and faults the insert. These pin + * the refusal, the message, and — because `dbChainMock` resolves rows without + * evaluating predicates — the predicate itself. + */ + describe('ids claimed by another workflow', () => { + const LOOP_BLOCK = { ...BLOCK, id: 'loop-1', type: 'loop', name: 'Loop' } + const EDGE = { id: 'edge-1', source: 'block-1', target: 'block-1' } + + it('refuses a block id another workflow owns instead of faulting the insert', async () => { + queueTableRows(schemaMock.workflowBlocks, [{ id: 'block-1' }]) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ + code: 'conflict', + message: 'Block ids already used by another workflow: block-1', + }) + expect(mocks.save).not.toHaveBeenCalled() + }) + + /** + * The `ne(workflowId)` half is load-bearing: without it every ordinary + * round-trip — re-sending ids this workflow already holds — becomes a 409. + * Asserted on the composed condition, because the chain mock resolves rows + * regardless of what was passed to `where`. + */ + it('scopes the lookup to ids held by a DIFFERENT workflow', async () => { + await replaceWorkflowNormalizedState(input()) + + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowBlocks.id, ['block-1']) + expect(ne).toHaveBeenCalledWith(schemaMock.workflowBlocks.workflowId, 'workflow-1') + expect(and).toHaveBeenCalledWith( + { type: 'inArray', column: schemaMock.workflowBlocks.id, values: ['block-1'] }, + { type: 'ne', left: schemaMock.workflowBlocks.workflowId, right: 'workflow-1' } + ) + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'and', + conditions: [ + { type: 'inArray', column: schemaMock.workflowBlocks.id, values: ['block-1'] }, + { type: 'ne', left: schemaMock.workflowBlocks.workflowId, right: 'workflow-1' }, + ], + }) + expect(mocks.save).toHaveBeenCalled() + }) + + /** + * A block record's key is a label; `saveWorkflowToNormalizedTables` inserts + * `block.id`. Subflow rows are the opposite — their ids come from + * `generateLoopBlocks`, which keys every container by its record key. So a + * body whose key and id diverge must be checked on `Object.values` for + * blocks and on `Object.keys` for subflows, or the pre-check reads ids the + * write never inserts and misses the ones it does. `dbChainMock` resolves + * rows without evaluating predicates, so this is asserted on the composed + * `inArray` rather than on a canned result. + */ + it('checks the ids the write inserts: block values, subflow record keys', async () => { + mocks.prepare.mockReturnValue({ + state: { + blocks: { + 'block-key': { ...BLOCK, id: 'block-value' }, + 'loop-key': { ...LOOP_BLOCK, id: 'loop-value' }, + }, + edges: [], + loops: { 'loop-key': { id: 'loop-key' } }, + parallels: {}, + }, + warnings: [], + }) + + await replaceWorkflowNormalizedState(input()) + + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowBlocks.id, [ + 'block-value', + 'loop-value', + ]) + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowSubflows.id, ['loop-key']) + }) + + it('refuses an edge id another workflow owns', async () => { + mocks.prepare.mockReturnValue({ + state: { ...PREPARED, edges: [EDGE] }, + warnings: [], + }) + queueTableRows(schemaMock.workflowEdges, [{ id: 'edge-1' }]) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ + code: 'conflict', + message: 'Edge ids already used by another workflow: edge-1', + }) + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowEdges.id, ['edge-1']) + expect(ne).toHaveBeenCalledWith(schemaMock.workflowEdges.workflowId, 'workflow-1') + expect(mocks.save).not.toHaveBeenCalled() + }) + + /** + * `workflow_subflows` has its own global primary key, so a value free as a + * block id can still be taken as a subflow id — reported under its own + * label rather than folded into the block families. + */ + it('reports a subflow id collision under its own label', async () => { + mocks.prepare.mockReturnValue({ + state: { + blocks: { 'block-1': BLOCK, 'loop-1': LOOP_BLOCK }, + edges: [], + loops: { 'loop-1': { id: 'loop-1' } }, + parallels: {}, + }, + warnings: [], + }) + queueTableRows(schemaMock.workflowBlocks, []) + queueTableRows(schemaMock.workflowSubflows, [{ id: 'loop-1' }]) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ + code: 'conflict', + message: 'Subflow ids already used by another workflow: loop-1', + }) + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowSubflows.id, ['loop-1']) + expect(mocks.save).not.toHaveBeenCalled() + }) + + /** Ids only — never the workflow or workspace that holds them. */ + it('names the offending ids and nothing about their owner', async () => { + queueTableRows(schemaMock.workflowBlocks, [{ id: 'block-1' }]) + + const error = await replaceWorkflowNormalizedState(input()).catch((thrown) => thrown) + + expect(error).toMatchObject({ code: 'conflict' }) + expect(error.message).toContain('block-1') + expect(error.message).not.toMatch(/workspace|workflow-2/) + }) + + /** + * The pre-check is the good-message path, not the correctness boundary: the + * `FOR UPDATE` locks the target workflow row, not the workflow that would + * claim the id, so two concurrent writes carrying the same fresh id can + * both pass it under READ COMMITTED. The catch is what keeps that race a + * 409 rather than an unclassified 500. + */ + it('re-classifies a 23505 that races past the pre-check', async () => { + mocks.save.mockRejectedValue(wrapDriverError(uniqueViolation('workflow_edges_pkey'))) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ + code: 'conflict', + }) + }) + + /** + * The constraint name is compared exactly: a unique index whose name merely + * contains one of the graph-id constraints belongs to some other table and + * must not be reported as a claimed graph id. + */ + it('leaves a 23505 on an unrelated constraint unclassified', async () => { + const failure = wrapDriverError(uniqueViolation('archive_workflow_blocks_pkey_backup')) + mocks.save.mockRejectedValue(failure) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toBe(failure) + }) + + /** + * Not every 23505 carries a constraint name: a violation raised by a bare + * unique index, or one whose driver dropped the field, arrives with none. + * Exact matching must read that as "not a graph id" — treating an absent + * name as a match would relabel unrelated unique violations across the + * whole write as a graph-id conflict. + */ + it('leaves a 23505 carrying no constraint name unclassified', async () => { + const cause = Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }) + const failure = wrapDriverError(cause) + mocks.save.mockRejectedValue(failure) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toBe(failure) + }) + }) + /** * The lock predicate is scoped, not just `id`: a workflow archived between the * caller's authorization check and this write is refused rather than written, diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts index 138616780c1..9355a0bc45e 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -1,7 +1,8 @@ import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' +import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' @@ -22,6 +23,157 @@ export class WorkflowStatePersistenceError extends Error { } } +/** The three global-primary-key id families one normalized write inserts. */ +export interface WorkflowGraphIds { + blockIds: string[] + edgeIds: string[] + subflowIds: string[] +} + +/** + * The ids a write of `state` would insert. + * + * Derived from the prepared state rather than from the caller's body, and read + * by both the dry-run preview and the committed write, so the two cannot + * disagree about what is about to be claimed. + * + * Each family is read from whichever side `saveWorkflowToNormalizedTables` + * inserts, which is not the same side for all three. Blocks are inserted as + * `block.id` — the record's own field, taken from `Object.values` — so a body + * whose record key differs from the block's `id` is checked on the value that + * reaches the table. Subflow rows are the opposite: their ids come from + * `generateLoopBlocks`/`generateParallelBlocks`, which key every container by + * its record key, so those are collected from `Object.keys`. + * + * Subflow ids are collected separately even though they are container block + * ids: `workflow_subflows` has its own global primary key, so the same value + * can be free as a block id and taken as a subflow id. + */ +export function collectWorkflowGraphIds(state: PreparedWorkflowState): WorkflowGraphIds { + return { + blockIds: Object.values(state.blocks).map((block) => block.id), + edgeIds: state.edges.map((edge) => edge.id), + subflowIds: [...Object.keys(state.loops), ...Object.keys(state.parallels)], + } +} + +/** How many offending ids a conflict message names before it summarizes the rest. */ +const MAX_REPORTED_CONFLICT_IDS = 5 + +/** + * Renders the offending ids, and only the ids. + * + * Never the workflow or workspace that holds them: the caller supplied these + * values, so naming them back discloses nothing they did not already have, + * where naming the owner would. + */ +function describeClaimedIds(ids: string[]): string { + const sorted = [...ids].sort() + const shown = sorted.slice(0, MAX_REPORTED_CONFLICT_IDS).join(', ') + const remaining = sorted.length - MAX_REPORTED_CONFLICT_IDS + return remaining > 0 ? `${shown} (and ${remaining} more)` : shown +} + +/** + * Refuses a graph whose ids are already owned by a different workflow. + * + * `workflow_blocks.id`, `workflow_edges.id`, and `workflow_subflows.id` are + * global primary keys, but the replace deletes only the rows scoped to this + * workflow — so an id owned elsewhere survives the delete and the insert faults + * on it. Detecting it here turns a caller-reachable 500 into a 409 that names + * what to change. + * + * The `ne(workflowId)` half is what keeps the ordinary round-trip working: ids + * this workflow already holds are deleted before the insert, so they are not a + * conflict. + */ +export async function assertWorkflowGraphIdsUnclaimed( + executor: DbOrTx, + workflowId: string, + ids: WorkflowGraphIds +): Promise { + const [blocks, edges, subflows] = await Promise.all([ + ids.blockIds.length > 0 + ? executor + .select({ id: workflowBlocks.id }) + .from(workflowBlocks) + .where( + and(inArray(workflowBlocks.id, ids.blockIds), ne(workflowBlocks.workflowId, workflowId)) + ) + : Promise.resolve([] as { id: string }[]), + ids.edgeIds.length > 0 + ? executor + .select({ id: workflowEdges.id }) + .from(workflowEdges) + .where( + and(inArray(workflowEdges.id, ids.edgeIds), ne(workflowEdges.workflowId, workflowId)) + ) + : Promise.resolve([] as { id: string }[]), + ids.subflowIds.length > 0 + ? executor + .select({ id: workflowSubflows.id }) + .from(workflowSubflows) + .where( + and( + inArray(workflowSubflows.id, ids.subflowIds), + ne(workflowSubflows.workflowId, workflowId) + ) + ) + : Promise.resolve([] as { id: string }[]), + ]) + + const conflicts: string[] = [] + if (blocks.length > 0) { + conflicts.push( + `Block ids already used by another workflow: ${describeClaimedIds(blocks.map((row) => row.id))}` + ) + } + if (edges.length > 0) { + conflicts.push( + `Edge ids already used by another workflow: ${describeClaimedIds(edges.map((row) => row.id))}` + ) + } + if (subflows.length > 0) { + conflicts.push( + `Subflow ids already used by another workflow: ${describeClaimedIds(subflows.map((row) => row.id))}` + ) + } + + if (conflicts.length > 0) { + throw new OrchestrationError('conflict', conflicts.join('; ')) + } +} + +/** Postgres SQLSTATE for a unique-constraint violation. */ +const UNIQUE_VIOLATION = '23505' + +const GRAPH_ID_CONSTRAINTS = [ + 'workflow_blocks_pkey', + 'workflow_edges_pkey', + 'workflow_subflows_pkey', +] as const + +/** + * Whether a driver fault is another workflow having claimed one of these ids. + * + * The pre-check is the good-message path, not the correctness boundary: the + * row lock covers the target workflow, not the workflow that would claim an id, + * so under READ COMMITTED two concurrent writes carrying the same fresh id can + * both pass the check and one insert still faults. This keeps that race a 409. + * + * Read through the `cause` chain: Drizzle wraps every driver fault in a + * `DrizzleQueryError` whose own message is the SQL text and whose `code` is + * absent, so a top-level field read never sees the driver's SQLSTATE. The + * constraint name is compared exactly rather than searched for in a message, + * so an unrelated constraint merely containing one of these names is not + * misclassified. + */ +function isGraphIdUniqueViolation(error: unknown): boolean { + if (getPostgresErrorCode(error) !== UNIQUE_VIOLATION) return false + const constraint = getPostgresConstraintName(error) + return GRAPH_ID_CONSTRAINTS.some((name) => name === constraint) +} + export interface ReplaceWorkflowState { blocks: Record edges: WorkflowState['edges'] @@ -121,7 +273,20 @@ export async function replaceWorkflowNormalizedState( deployedAt: resolved.deployedAt, } as WorkflowState - const result = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + await assertWorkflowGraphIdsUnclaimed(tx, workflowId, collectWorkflowGraphIds(prepared.state)) + + let result: Awaited> + try { + result = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + } catch (error) { + if (isGraphIdUniqueViolation(error)) { + throw new OrchestrationError( + 'conflict', + 'Another workflow claimed one of the submitted block, edge, or subflow ids while this write was in flight' + ) + } + throw error + } if (!result.success) return result const updateData: Partial = { diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.ts index 4d921ee793f..a1c2a036513 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.ts @@ -10,6 +10,11 @@ import { type WorkflowStateContractOutput, workflowStateSchema, } from '@/lib/api/contracts/workflows' +import { + asOrchestrationError, + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { replaceWorkflowNormalizedState, @@ -115,6 +120,26 @@ export async function saveWorkflowNormalizedState(params: { details: error.detail, } } + /** + * The shared write classifies its own caller-fixable refusals — a graph id + * another workflow already owns is a `conflict`, a workflow archived since + * the authorization check is a `not_found`. Reading them here is what keeps + * this door's statuses identical to the ones `replaceWorkflowState` returns + * for the same refusal, rather than collapsing them into the caller's + * generic 500. Read through the cause chain because the throw happens + * inside the transaction callback, which drizzle wraps. + */ + const classified = asOrchestrationError(error) + if (classified) { + return { + success: false, + status: statusForOrchestrationError(classified.code), + error: messageForOrchestrationError( + { error: classified.message, errorCode: classified.code }, + 'Failed to save workflow state' + ), + } + } throw error } diff --git a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts index f648e5fc0ac..99c1dde6531 100644 --- a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts @@ -28,6 +28,7 @@ vi.mock('@/lib/workflows/persistence/replace-normalized-state', async () => { }) vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { WorkflowStatePersistenceError } from '@/lib/workflows/persistence/replace-normalized-state' import { saveWorkflowNormalizedState } from '@/lib/workflows/persistence/save-normalized-state' @@ -160,6 +161,61 @@ describe('saveWorkflowNormalizedState', () => { expect(mocks.notify).not.toHaveBeenCalled() }) + it('reports a claimed graph id as 409 carrying the ids to change', async () => { + mocks.replace.mockRejectedValue( + new OrchestrationError( + 'conflict', + 'Block ids already used by another workflow: block-1, block-2' + ) + ) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 409, + error: 'Block ids already used by another workflow: block-1, block-2', + }) + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('reports a workflow archived since the authorization check as 404', async () => { + mocks.replace.mockRejectedValue(new OrchestrationError('not_found', 'Workflow not found')) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 404, + error: 'Workflow not found', + }) + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('classifies through the wrapper drizzle puts around a throw inside the transaction', async () => { + const wrapped = new Error('insert into "workflow_blocks" ...', { + cause: new OrchestrationError( + 'conflict', + 'Edge ids already used by another workflow: edge-1' + ), + }) + mocks.replace.mockRejectedValue(wrapped) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 409, + error: 'Edge ids already used by another workflow: edge-1', + }) + }) + + it('hides the text of an unclassified orchestration failure behind the generic wording', async () => { + mocks.replace.mockRejectedValue( + new OrchestrationError('internal', 'insert into "workflow_blocks" values ($1, $2)') + ) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 500, + error: 'Failed to save workflow state', + }) + }) + it('propagates an unclassified fault rather than turning it into a status', async () => { mocks.replace.mockRejectedValue(new Error('pool exhausted')) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts index cfdd5757415..641f6390a48 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts @@ -43,6 +43,7 @@ vi.mock('@/lib/file-parsers', () => ({ })) import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text' const WORKSPACE_ID = 'workspace-1' @@ -198,15 +199,22 @@ describe('readWorkspaceFileText', () => { expect(result.truncated).toBe(true) }) + /** + * The message is served to raw HTTP, Copilot, and the CLI alike, so it names + * the remedy rather than an endpoint only one of those three can call. + */ it('rejects an unsupported type and names the raw-bytes escape hatch', async () => { - mocks.getFile.mockResolvedValueOnce(fileRecord({ name: 'photo.heic' })) + mocks.getFile.mockResolvedValue(fileRecord({ name: 'photo.heic' })) await expect( readWorkspaceFileText.execute({ principal: principals[2], input: input() }) ).rejects.toMatchObject({ code: 'validation', - message: expect.stringContaining(`GET /api/v2/files/${FILE_ID}`), + message: expect.stringContaining('download the raw bytes'), }) + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + ).rejects.toMatchObject({ message: expect.not.stringMatching(/\/api\/v2\//) }) expect(mocks.fetchBuffer).not.toHaveBeenCalled() }) @@ -239,6 +247,22 @@ describe('readWorkspaceFileText', () => { ).rejects.toMatchObject({ code: 'payload_too_large' }) }) + /** + * Both numbers are sub-1 KB, which the default size formatting renders as + * "0 Bytes" — leaving the caller unable to work out what to pass instead. + */ + it('names the real size and limit when both are under 1 KB', async () => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 28 })) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input({ maxBytes: 27 }) }) + ).rejects.toMatchObject({ + code: 'payload_too_large', + message: expect.stringContaining('is 28 Bytes, above the 27 Bytes'), + }) + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + it('reports a missing file as not found', async () => { mocks.getFile.mockResolvedValueOnce(null) @@ -319,4 +343,24 @@ describe('readWorkspaceFileText', () => { expect(mocks.fetchServable.mock.calls[0][2]).toMatchObject({ maxBytes: expect.any(Number) }) }) + + /** + * The artifact branch renders the same caller-supplied ceiling, so it needs + * the same exact-byte formatting the source branch does. + */ + it('names a sub-1 KB artifact limit in bytes', async () => { + mocks.getFile.mockResolvedValueOnce( + fileRecord({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 10 }) + ) + mocks.fetchServable.mockRejectedValueOnce( + new PayloadSizeLimitError({ label: 'artifact', maxBytes: 27 }) + ) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input({ maxBytes: 27 }) }) + ).rejects.toMatchObject({ + code: 'payload_too_large', + message: expect.stringContaining('renders to more than 27 Bytes'), + }) + }) }) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts index 2f3600159b2..ad924af8528 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts @@ -50,9 +50,14 @@ export interface ReadWorkspaceFileTextResult { */ async function readSourceBuffer(file: WorkspaceFileRecord, maxBytes: number): Promise { if (file.size > maxBytes) { + /** + * Sizes render with `includeBytes` because a caller-supplied `maxBytes` is + * routinely under 1 KB, and the default formatting collapses every sub-1 KB + * value to "0 Bytes" — naming neither the real size nor the limit to raise. + */ throw new OrchestrationError( 'payload_too_large', - `"${file.name}" is ${formatFileSize(file.size)}, above the ${formatFileSize(maxBytes)} text-extraction limit; download the raw bytes with GET /api/v2/files/${file.id}` + `"${file.name}" is ${formatFileSize(file.size, { includeBytes: true })}, above the ${formatFileSize(maxBytes, { includeBytes: true })} text-extraction limit; download the raw bytes instead of extracting text` ) } return fetchWorkspaceFileBuffer(file, { maxBytes }) @@ -74,7 +79,7 @@ async function executeReadWorkspaceFileText({ if (!isSupportedFileType(extension)) { throw new OrchestrationError( 'validation', - `Text extraction is not supported for "${file.name}"; download the raw bytes with GET /api/v2/files/${file.id}` + `Text extraction is not supported for "${file.name}"; download the raw bytes instead of extracting text` ) } @@ -93,7 +98,7 @@ async function executeReadWorkspaceFileText({ await resolveRenderedWorkspaceArtifact(file, principal, { maxBytes, tooLargeMessage: (limit) => - `"${file.name}" renders to more than ${limit}, above the text-extraction limit; download the raw bytes with GET /api/v2/files/${file.id}`, + `"${file.name}" renders to more than ${limit}, above the text-extraction limit; download the raw bytes instead of extracting text`, }) ).buffer : await readSourceBuffer(file, maxBytes) diff --git a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts index 0cee7dbd69c..3b6cc4e29e9 100644 --- a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts +++ b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts @@ -46,7 +46,7 @@ export async function resolveRenderedWorkspaceArtifact( ) } if (isPayloadSizeLimitError(error)) { - const limit = formatFileSize(options.maxBytes) + const limit = formatFileSize(options.maxBytes, { includeBytes: true }) throw new OrchestrationError( 'payload_too_large', options.tooLargeMessage?.(limit) ?? diff --git a/apps/sim/lib/workspaces/api/route-policies.ts b/apps/sim/lib/workspaces/api/route-policies.ts new file mode 100644 index 00000000000..b765ace5b72 --- /dev/null +++ b/apps/sim/lib/workspaces/api/route-policies.ts @@ -0,0 +1,14 @@ +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' + +/** + * A caller naming a workspace it cannot reach must not be able to tell that + * refusal apart from a workspace that does not exist. Both answer + * `404 "Workspace not found"` — the message the unknown-workspace path in + * `get-public-workspace` and `list-public-workspace-members` already uses, so + * the two responses are byte-identical. + */ +export const v2WorkspaceErrorPolicies = { + concealWorkspaceAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', + }), +} as const diff --git a/package.json b/package.json index 1b51b3735f1..10fd48696c3 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts", "test:tool-registry-boundary": "bunx vitest run scripts/check-tool-registry-boundary.test.ts", "test:tool-request-boundary": "bunx vitest run scripts/check-tool-request-boundary.test.ts", - "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-docs.test.ts", + "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts", "format": "turbo run format", "format:check": "turbo run format:check", "lint": "turbo run lint", diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 0f5f6bb5e96..1ce95179ffb 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -41,7 +41,21 @@ vi.mock('../auth/device-flow', () => ({ createAuthRequest: mocks.createAuthRequest, pollForKey: mocks.pollForKey, })) -vi.mock('../config/index', () => ({ +/** + * The validators and the format list come from the real module rather than a + * copy: a duplicated pattern here would keep passing if the shipped one were + * deleted, which is exactly the regression these tests exist to catch. `../config/profile` is not + * itself mocked, so this is the shipped implementation. + */ +vi.mock('../config/index', async () => ({ + ...(await import('../config/profile').then( + ({ FORBIDDEN_IN_VALUE, normalizeWorkspaceId, OUTPUT_FORMATS, validateProfileName }) => ({ + FORBIDDEN_IN_VALUE, + normalizeWorkspaceId, + OUTPUT_FORMATS, + validateProfileName, + }) + )), configPath: () => '/tmp/sim-config', credentialsPath: () => '/tmp/sim-credentials', DEFAULT_PROFILE: 'default', @@ -232,6 +246,76 @@ describe('login command', () => { expect(mocks.createAuthRequest).toHaveBeenCalledOnce() }) + it('writes the endpoint before the key, so a failed write cannot strand one', async () => { + // A key on disk with no endpoint beside it falls back to the default host + // on the next command, which would send a self-hosted key elsewhere. + setInteractive(false) + const order: string[] = [] + mocks.writeConfigProfile.mockImplementation(() => { + order.push('config') + }) + mocks.writeCredentialsProfile.mockImplementation(() => { + order.push('credentials') + }) + + await login() + + expect(order).toEqual(['config', 'credentials']) + }) + + it('stores nothing when the server answers with an unstorable workspace id', async () => { + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1\nendpoint = http://elsewhere.invalid', + }) + + await expect(login()).rejects.toThrow('Invalid workspace id') + + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + + it('stores nothing when the server answers with a malformed key', async () => { + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + apiKey: ' ', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + }) + + await expect(login()).rejects.toThrow('malformed API key') + + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + + it.each([ + ['a C0 control character', 'sim-key\u0001rest'], + ['a Unicode line separator', 'sim-key\u2028rest'], + ['leading whitespace', ' sim-key'], + ['trailing whitespace', 'sim-key '], + ])('stores nothing when the minted key carries %s', async (_label, apiKey) => { + // The pre-write check has to refuse exactly what the writer refuses. When it + // was the narrower of the two, the settings write landed and the credentials + // write threw — leaving the new endpoint on disk beside the previous key. + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + apiKey, + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + }) + + await expect(login()).rejects.toThrow('malformed API key') + + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + it('clears a stale workspace default when none is selected during login', async () => { setInteractive(false) mocks.profileFrom.mockReturnValue({ @@ -262,6 +346,33 @@ describe('login command', () => { }) expect(console.log).toHaveBeenCalledWith(expect.stringContaining('no default workspace')) }) + + it('refuses an empty workspace id instead of storing it as no workspace', async () => { + setInteractive(false) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: 'ws_old', + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'config', + output: 'default', + }, + }) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: '', + }) + + await expect(login()).rejects.toThrow('Empty workspace id from the login response.') + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) }) describe('profiles command', () => { @@ -449,6 +560,27 @@ describe('profiles command', () => { expect(mocks.writeConfigProfile).not.toHaveBeenCalled() }) + it('refuses a workspace id the response could not have stored, naming the response', async () => { + // The id comes off the wire exactly like the login response's, so it is + // checked the same way. Without this the writer still refused it, but with + // a message about the file format rather than the side that produced it. + mocks.request.mockResolvedValue({ + data: { id: 'ws_acme\nendpoint = http://elsewhere.invalid', name: 'Acme', memberCount: 3 }, + }) + + await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow( + 'Invalid workspace id "ws_acme endpoint = http://elsewhere.invalid" from the workspace response.' + ) + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('refuses a new profile name that would forge a config section', async () => { + await expect(profiles('add', 'evil]\n[default', '--workspace', 'ws_acme')).rejects.toThrow( + 'Invalid profile name' + ) + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + it('does not overwrite an existing profile', async () => { mocks.listProfiles.mockReturnValue(['acme']) @@ -467,8 +599,123 @@ describe('profiles command', () => { await profiles('list') const output = vi.mocked(console.log).mock.calls.flat().join('\n') - expect(output).toContain('acme (auth: default)') - expect(output).not.toContain('acme (no key)') + expect(output).toMatch(/acme\s+yes\s+default/) + }) + + it('refuses an unknown profile like every other command', async () => { + // `profiles list --profile typo` used to exit 0 on a name that resolves to + // nothing, alone among the commands, because it never resolved at all. + mocks.listProfiles.mockReturnValue(['default']) + mocks.profileFrom.mockImplementation(() => { + throw new mocks.ProfileConfigError('Unknown profile "bogus".') + }) + + await expect(profiles('list', '--profile', 'bogus')).rejects.toThrow('Unknown profile "bogus".') + expect(console.log).not.toHaveBeenCalled() + }) + + it('renders the listing in the resolved output format', async () => { + mocks.listProfiles.mockReturnValue(['acme', 'default']) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'stored-key', + workspaceId: 'ws_default', + output: 'json', + sources: { + endpoint: 'config', + apiKey: 'credentials', + workspaceId: 'config', + output: 'config', + }, + }) + + await profiles('list') + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(JSON.parse(output)).toEqual([ + { name: 'acme', active: false, hasKey: true, authProfile: 'acme', error: null }, + { name: 'default', active: true, hasKey: true, authProfile: 'default', error: null }, + ]) + }) + + it('answers a machine format with an empty list rather than prose', async () => { + mocks.listProfiles.mockReturnValue([]) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: null, + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'config', + }, + }) + + await profiles('list') + + expect(JSON.parse(vi.mocked(console.log).mock.calls.flat().join('\n'))).toEqual([]) + }) + + it('lists a broken active profile instead of refusing to list at all', async () => { + // Resolving the active profile supplies the row marker and the format, but + // it throws for exactly the profile this command exists to show. + mocks.listProfiles.mockReturnValue(['broken', 'default']) + mocks.profileFrom.mockImplementation(() => { + throw new mocks.ProfileConfigError('Profile "broken" references missing auth_profile "gone".') + }) + mocks.resolveAuthenticationProfileName.mockImplementation((profile) => { + if (profile === 'broken') { + throw new mocks.ProfileConfigError( + 'Profile "broken" references missing auth_profile "gone".' + ) + } + return profile + }) + + await profiles('list', '--profile', 'broken') + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('broken') + expect(output).toContain('default') + expect(output).toContain('references missing auth_profile "gone"') + }) + + it('refuses an unknown profile named by the environment, not just the flag', async () => { + // The same guard, reached through its other input. `SIM_PROFILE=typo sim + // profiles` must fail exactly like `sim profiles --profile typo`. + mocks.listProfiles.mockReturnValue(['default']) + mocks.profileFrom.mockImplementation(() => { + throw new mocks.ProfileConfigError('Unknown profile "bogus".') + }) + process.env.SIM_PROFILE = 'bogus' + + try { + await expect(profiles('list')).rejects.toThrow('Unknown profile "bogus".') + expect(console.log).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(process.env, 'SIM_PROFILE') + } + }) + + it('refuses an output format it does not know rather than printing a table', async () => { + // A script asking for a machine format must not be handed human output with + // exit 0. The catch exists to tolerate a broken *profile*, not a bad flag. + mocks.listProfiles.mockReturnValue(['default']) + mocks.profileFrom.mockImplementation(() => { + throw new mocks.ProfileConfigError('Unknown output format "jsonl" from env.') + }) + process.env.SIM_OUTPUT = 'jsonl' + + try { + await expect(profiles('list')).rejects.toThrow('Unknown output format "jsonl" from env.') + expect(console.log).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(process.env, 'SIM_OUTPUT') + } }) it('marks a broken profile and still lists the rest', async () => { diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index e2ebab1ed18..d481a176817 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -13,13 +13,18 @@ import { credentialsPath, DEFAULT_PROFILE, deleteProfile, + FORBIDDEN_IN_VALUE, listAuthenticationDependents, listProfiles, + normalizeWorkspaceId, + OUTPUT_FORMATS, + type OutputFormat, ProfileConfigError, type ResolvedProfile, readCredentialsProfile, resolveAuthenticationProfileName, type SettingSource, + validateProfileName, writeConfigProfile, writeCredentialsProfile, } from '../config/index' @@ -31,11 +36,10 @@ import { V2_OPERATIONS, } from '../generated/v2-api' import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../http/client' -import { printRecord, safeOneLine } from '../output/render' +import { type Column, printList, printRecord, safeOneLine, text } from '../output/render' type SelectableWorkspace = ListWorkspacesResponse['data'][number] -const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ const MAX_INTERACTIVE_WORKSPACES = 1000 /** @@ -108,15 +112,44 @@ function selectedProfileName(command: Command): string { } function validateNewProfileName(profileName: string): void { - if (!PROFILE_NAME_PATTERN.test(profileName)) { + // The shape rule lives with the config writer, so `profiles add`, `login + // --profile`, and `configure --profile` cannot drift into three answers. + validateProfileName(profileName) + if (listProfiles().includes(profileName)) { throw new SimApiError( - `Invalid profile name "${profileName}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.`, + `Profile "${profileName}" already exists. Remove it first with: sim logout --all --profile ${profileName}`, 0 ) } - if (listProfiles().includes(profileName)) { +} + +/** + * Refuses a minted key the credentials file could not represent. + * + * The poll response is remote input, and the deployment answering it is + * whatever the endpoint names. A key carrying a line break would be written + * verbatim into an escape-less format, so the writer refuses it — this refuses + * it one step earlier, before anything is on disk, and says which side is wrong. + * + * It shares {@link FORBIDDEN_IN_VALUE} with the writer rather than copying it: + * a second spelling drifted once already, and a key this check accepted but the + * writer rejected stranded the new endpoint beside the previous key. Surrounding + * whitespace is the same failure and is refused here for the same reason — the + * writer will not store text it cannot read back unchanged. + * + * Refused rather than trimmed: a minted key is opaque, so the CLI cannot tell + * padding from the credential. Trimming would store a value the server never + * issued and turn a loud, explained failure into a 401 on every later command. + */ +function requireStorableKey(apiKey: unknown): void { + if ( + typeof apiKey !== 'string' || + !apiKey || + apiKey !== apiKey.trim() || + FORBIDDEN_IN_VALUE.test(apiKey) + ) { throw new SimApiError( - `Profile "${profileName}" already exists. Remove it first with: sim logout --all --profile ${profileName}`, + 'The server returned a malformed API key. Nothing was stored; check the endpoint.', 0 ) } @@ -215,7 +248,11 @@ function addProfileCommand(): Command { writeConfigProfile(profileName, { auth_profile: authProfile, - workspace: workspace.id, + // Server-supplied, exactly like the login response's workspace id, so it + // is checked the same way: the writer would refuse an unstorable one + // anyway, but with a message about the file format rather than the + // response that produced it. + workspace: normalizeWorkspaceId(workspace.id, 'the workspace response'), }) console.log(chalk.green(`✓ Added profile "${profileName}" in ${configPath()}`)) @@ -290,17 +327,34 @@ export function loginCommand(): Command { ) } - writeCredentialsProfile(profile.name, key.apiKey) - // The workspace picked in the browser becomes the profile's default, // whether or not the key is scoped to it. The user chose it by name — // making them look up its id afterwards would waste the one moment the - // answer was already on screen. + // answer was already on screen. It arrives off the wire, so it is + // checked before either file is touched. + // + // Absence is the whole of "no workspace" here, and it is a legitimate + // outcome — a personal key with nothing selected in the browser. A + // *present* value is a workspace id, so every one of them goes to + // `normalizeWorkspaceId` to be accepted or refused by name. Testing + // truthiness instead let an empty string through the absent branch, so + // a malformed response was quietly stored as "no workspace" rather than + // reported. const settings: Record = { endpoint: profile.endpoint, - workspace: key.workspaceId ?? null, + workspace: + key.workspaceId == null + ? null + : normalizeWorkspaceId(key.workspaceId, 'the login response'), } + requireStorableKey(key.apiKey) + + // Config before credentials: the endpoint decides where the key is sent + // later. Storing the key first and then failing on the settings left a + // key on disk with no endpoint beside it, so the next command fell back + // to the default host — sending a self-hosted key somewhere else. writeConfigProfile(profile.name, settings) + writeCredentialsProfile(profile.name, key.apiKey) console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) if (key.workspaceBound && key.workspaceId) { @@ -597,38 +651,101 @@ export function whoamiCommand(): Command { }) } +interface ProfileRow { + name: string + active: boolean + hasKey: boolean + /** The profile whose stored login this one uses; itself unless it is an alias. */ + authProfile: string | null + /** Why the row could not be resolved, for a profile with a broken `auth_profile`. */ + error: string | null +} + +const PROFILE_COLUMNS: Column[] = [ + { header: '', value: (row) => (row.active ? chalk.green('*') : ' ') }, + { header: 'profile', value: (row) => text(row.name) }, + { header: 'key', value: (row) => (row.error ? text(null) : row.hasKey ? 'yes' : 'no') }, + { header: 'auth', value: (row) => text(row.authProfile) }, + { header: 'error', value: (row) => (row.error ? chalk.red(safeOneLine(row.error)) : text(null)) }, +] + +/** + * `profiles` is the command someone runs *because* a profile is broken, so a bad + * `auth_profile` marks its own row rather than aborting the listing and leaving + * them with nothing shown at all. + */ +function buildProfileRow(name: string, active: boolean): ProfileRow { + try { + const authProfile = resolveAuthenticationProfileName(name) + return { + name, + active, + hasKey: Boolean(readCredentialsProfile(authProfile).api_key), + authProfile, + error: null, + } + } catch (error) { + if (!(error instanceof ProfileConfigError)) throw error + return { name, active, hasKey: false, authProfile: null, error: error.message } + } +} + +/** + * The active profile's name and the format to render in, tolerating a profile + * that does not resolve. + * + * Resolving the active profile is what supplies both, but it can also throw — + * and `profiles` is the command someone runs *because* a profile is broken, so + * a broken active profile has to appear as a marked row like any other rather + * than abort the listing. An unknown *name* is still refused: `profiles + * --profile typo` must fail like every other command instead of listing under a + * name that means nothing. + */ +function profileListingContext(command: Command): { activeName: string; output: OutputFormat } { + try { + const profile = profileFrom(command) + return { activeName: profile.name, output: profile.output } + } catch (error) { + if (!(error instanceof ProfileConfigError)) throw error + + const globals = globalsOf(command) + const named = globals.profile || process.env.SIM_PROFILE + if (named && named !== DEFAULT_PROFILE && !listProfiles().includes(named)) throw error + + // A bad format is the caller's own request, not a broken profile: falling + // back to a table would hand a script human output with exit 0. Only the + // profile's *resolution* is tolerated here, never its arguments. + const requested = globals.output ?? process.env.SIM_OUTPUT + if (requested && !(OUTPUT_FORMATS as readonly string[]).includes(requested)) throw error + + return { + activeName: named || DEFAULT_PROFILE, + output: requested ? (requested as OutputFormat) : 'table', + } + } +} + export function profilesCommand(): Command { const command = new Command('profiles') .alias('profile') .description('List profiles or add a workspace profile that shares a stored login') const printProfiles = (_options: unknown, actionCommand: Command): void => { - const profiles = listProfiles() - if (profiles.length === 0) { - console.log(chalk.dim('No profiles yet. Run: sim login')) + // Resolving is what makes `profiles --profile typo` fail like every other + // command instead of listing happily under a name that resolves to nothing, + // and it is also what supplies the output format the listing renders in. + const { activeName, output } = profileListingContext(actionCommand) + const rows = listProfiles().map((name) => buildProfileRow(name, name === activeName)) + + if (rows.length === 0) { + // The prose belongs to the human formats; a script asking for json must + // get an empty list, not a sentence it cannot parse. + if (output === 'table') console.log(chalk.dim('No profiles yet. Run: sim login')) + else printList(output, rows, PROFILE_COLUMNS) return } - const active = selectedProfileName(actionCommand) - for (const name of profiles) { - const marker = name === active ? chalk.green('*') : ' ' - - // `profiles` is the command someone runs *because* a profile is broken, - // so one bad `auth_profile` must mark its own row rather than abort the - // listing and leave them with no profiles shown at all. - let authProfile: string - try { - authProfile = resolveAuthenticationProfileName(name) - } catch (error) { - if (!(error instanceof ProfileConfigError)) throw error - console.log(`${marker} ${name}${chalk.red(` (${safeOneLine(error.message)})`)}`) - continue - } - - const hasKey = Boolean(readCredentialsProfile(authProfile).api_key) - const authentication = authProfile === name ? '' : chalk.dim(` (auth: ${authProfile})`) - console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}${authentication}`) - } + printList(output, rows, PROFILE_COLUMNS) } command.action(printProfiles) diff --git a/packages/sim-cli/src/commands/configure.test.ts b/packages/sim-cli/src/commands/configure.test.ts index 521f5279853..094b535a4bb 100644 --- a/packages/sim-cli/src/commands/configure.test.ts +++ b/packages/sim-cli/src/commands/configure.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readConfigProfile, writeConfigProfile, writeCredentialsProfile } from '../config/index' +import { + listProfiles, + readConfigProfile, + writeConfigProfile, + writeCredentialsProfile, +} from '../config/index' import { configureCommand } from './configure' const mocks = vi.hoisted(() => ({ @@ -12,13 +17,24 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../context', () => ({ + // The real one-liner: the root globals live on the root command, so the + // refusal below only fires if the harness parses argv the way the shipped + // program does. + globalsOf: (command: Command) => command.optsWithGlobals(), profileFrom: mocks.profileFrom, })) let dir: string function run(...args: string[]): Promise { - const root = new Command('sim').exitOverride() + // The three root globals are declared exactly as program.ts declares them, so + // `configure --endpoint …` parses here the way it does in the shipped tree. + const root = new Command('sim') + .exitOverride() + .option('-P, --profile ') + .option('--endpoint ') + .option('-w, --workspace ') + .option('--output ') root.addCommand(configureCommand()) return root.parseAsync(['node', 'sim', 'configure', ...args]) } @@ -108,3 +124,77 @@ describe('configure --set-endpoint', () => { ) }) }) + +describe('configure --set-workspace', () => { + /** + * A stored value is read back as a real setting, so a value carrying a line + * break used to add a setting nobody typed — `endpoint` included, which is + * what decides where the API key is sent. Its sibling `--set-endpoint` has + * been validated all along; this is the same check for the other value. + */ + it('refuses a workspace value that would inject another setting', async () => { + await expect( + run('--set-workspace', 'ws_1\nendpoint = http://elsewhere.invalid') + ).rejects.toThrow(/Invalid workspace id/) + + expect(readConfigProfile('default')).toEqual({}) + }) + + it('stores an ordinary workspace id, trimmed', async () => { + await run('--set-workspace', ' ws_new ') + expect(readConfigProfile('default')).toEqual({ workspace: 'ws_new' }) + }) + + /** + * `listProfiles` counts section names, so the empty section an unset used to + * leave behind made the typo guard accept that name from then on. + */ + it('creates nothing when unsetting on a profile that does not exist', async () => { + mocks.profileName = 'fresh' + + await run('--unset', 'workspace') + + expect(readConfigProfile('fresh')).toEqual({}) + expect(listProfiles()).not.toContain('fresh') + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('No settings stored')) + }) +}) + +/** + * The root globals are transient overrides on every other command, so + * `configure --endpoint …` discarded the value and exited 0 after printing the + * settings it had not changed — which reads like a confirmation. + */ +describe('configure and the root globals', () => { + it('refuses --endpoint and names the flag that stores it', async () => { + await expect(run('--endpoint', 'https://other.example')).rejects.toThrow( + 'sim configure --set-endpoint https://other.example' + ) + expect(readConfigProfile('default')).toEqual({}) + }) + + it('refuses -w and --output the same way', async () => { + await expect(run('-w', 'ws_9')).rejects.toThrow('sim configure --set-workspace ws_9') + await expect(run('--output', 'json')).rejects.toThrow('sim configure --set-output json') + expect(readConfigProfile('default')).toEqual({}) + }) + + it('does not print a stale stored value as if it had been set', async () => { + writeConfigProfile('default', { endpoint: 'https://staging.example' }) + + await expect(run('--endpoint', 'https://other.example')).rejects.toThrow('--set-endpoint') + + expect(console.log).not.toHaveBeenCalled() + expect(readConfigProfile('default')).toEqual({ endpoint: 'https://staging.example' }) + }) + + it('still prints stored settings and still stores a --set- flag', async () => { + writeConfigProfile('default', { endpoint: 'https://staging.example' }) + + await run() + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('https://staging.example')) + + await run('--set-endpoint', 'https://x.example') + expect(readConfigProfile('default')).toMatchObject({ endpoint: 'https://x.example' }) + }) +}) diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts index 3b3088fa4c7..3caa4d46985 100644 --- a/packages/sim-cli/src/commands/configure.ts +++ b/packages/sim-cli/src/commands/configure.ts @@ -7,10 +7,26 @@ import { resolveAuthenticationProfileName, writeConfigProfile, } from '../config/index' -import { normalizeEndpoint } from '../config/profile' -import { profileFrom } from '../context' +import { normalizeEndpoint, normalizeWorkspaceId } from '../config/profile' +import { globalsOf, profileFrom } from '../context' import { SimApiError } from '../http/client' +/** + * The root globals that have a `--set-` twin on this command. + * + * `sim configure --endpoint https://x` parses cleanly — the globals are legal + * anywhere in argv — but `configure` only ever stored its own `--set-` flags, so + * the value was discarded and the command exited 0 after printing the settings + * it did not change, which reads exactly like a confirmation. Refusing and + * naming the twin is the honest answer; making the global write instead would + * give one command a persistent side effect the same flag has on no other. + */ +const GLOBAL_FLAG_TWINS = [ + { option: 'endpoint', flag: '--endpoint', setFlag: '--set-endpoint' }, + { option: 'workspace', flag: '-w, --workspace', setFlag: '--set-workspace' }, + { option: 'output', flag: '--output', setFlag: '--set-output' }, +] as const + /** * Rejects a `--set-…` flag given an empty value. * @@ -49,6 +65,16 @@ export function configureCommand(): Command { }, command: Command ) => { + const globals = globalsOf(command) + for (const { option, flag, setFlag } of GLOBAL_FLAG_TWINS) { + const value = globals[option] + if (value === undefined) continue + throw new SimApiError( + `${flag} applies to a single command and is not stored. To save it, run: sim configure ${setFlag} ${value}`, + 0 + ) + } + // `configure --profile x --set-…` is a documented way to create a // profile, so the name is allowed to be one that does not exist yet. const profile = profileFrom(command, { allowUnknownProfile: true }) @@ -68,7 +94,9 @@ export function configureCommand(): Command { } updates.endpoint = normalizeEndpoint(options.setEndpoint, '--set-endpoint') } - if (options.setWorkspace) updates.workspace = options.setWorkspace + if (options.setWorkspace) { + updates.workspace = normalizeWorkspaceId(options.setWorkspace, '--set-workspace') + } if (options.setOutput) { if (!(OUTPUT_FORMATS as readonly string[]).includes(options.setOutput)) { throw new SimApiError( @@ -104,6 +132,15 @@ export function configureCommand(): Command { return } + // An unset against a profile with nothing stored writes nothing — the + // file layer refuses to conjure a section for a removal — so reporting + // an update would claim a change that did not happen. + const removalOnly = Object.values(updates).every((value) => value === null) + if (removalOnly && Object.keys(readConfigProfile(profile.name)).length === 0) { + console.log(chalk.dim(`No settings stored for profile "${profile.name}".`)) + return + } + writeConfigProfile(profile.name, updates) console.log(chalk.green(`✓ Updated profile "${profile.name}" in ${configPath()}`)) } diff --git a/packages/sim-cli/src/commands/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts index ab0e66f88a7..e4071096593 100644 --- a/packages/sim-cli/src/commands/credentials.test.ts +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -156,6 +156,24 @@ describe('credential connection commands', () => { expect(help).not.toContain('--service-account-json') }) + it('marks its mandatory flags required in the help it renders', () => { + // Commander enforces `requiredOption` but renders nothing to say so — the + // marker is literal text the generated flags carry, so a hand-written + // command is the one place a required flag can look optional. + const flat = (...names: string[]) => + commandAt(...names) + .helpInformation() + .replace(/\s+/g, ' ') + + expect(flat('credentials', 'create')).toContain( + 'Name shown for the credential in Sim (required)' + ) + expect(flat('credentials', 'create')).toContain('read a file or stdin) (required)') + expect(flat('credentials', 'connect')).toContain( + 'Name shown for the new credential in Sim (required)' + ) + }) + it('rejects missing and unsupported provider fields before creation', async () => { mockRequest.mockReset().mockResolvedValue({ data: [ diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts index b90e7167a3b..e95c20dea25 100644 --- a/packages/sim-cli/src/commands/credentials.ts +++ b/packages/sim-cli/src/commands/credentials.ts @@ -193,10 +193,14 @@ export function attachCredentialCommands(program: Command): void { .command('create') .argument('', 'Service-account provider to create a credential for') .description('Create a service-account credential using its discovered provider schema') - .requiredOption('--name ', 'Name shown for the credential in Sim') + // The `(required)` suffix is the marker the generated flags carry, and it + // is literal text rather than something commander renders — a hand-written + // mandatory option that omits it is the only kind of required flag whose + // help does not say so. + .requiredOption('--name ', 'Name shown for the credential in Sim (required)') .requiredOption( '--credentials ', - 'Provider credentials as JSON (or @path / @- to read a file or stdin)' + 'Provider credentials as JSON (or @path / @- to read a file or stdin) (required)' ) .option('--description ', 'Optional credential description') .option( @@ -211,7 +215,7 @@ export function attachCredentialCommands(program: Command): void { .command('connect') .argument('', 'OAuth provider to connect') .description('Create a short-lived link for connecting an OAuth provider') - .requiredOption('--name ', 'Name shown for the new credential in Sim') + .requiredOption('--name ', 'Name shown for the new credential in Sim (required)') .action(async (providerId: string, options: { name: string }, command: Command) => createConnectionLink(command, { providerId, displayName: options.name }) ) diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts index 2645a960265..0cba17c8c09 100644 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -102,7 +102,7 @@ function written(spy: WriteSpy): string { const FINAL = { type: 'final', - data: { content: 'Hello there', conversationId: 'conv-1', model: 'mothership' }, + data: { content: 'Hello there', conversationId: 'conv-1', model: 'sim' }, } describe('sim chat', () => { diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 2cf7599bb9c..c1750c68512 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -3,6 +3,7 @@ import { existsSync, lstatSync, mkdtempSync, + readdirSync, readFileSync, rmSync, symlinkSync, @@ -13,8 +14,14 @@ import { join } from 'node:path' import { Writable } from 'node:stream' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { sleep } from '../../helpers' import { buildGeneratedCommands } from '../../runtime/build' -import { isTerminalSafeContentType, saveToFile, streamToFile } from './files-get' +import { + isTerminalSafeContentType, + removeStagingOnSignal, + saveToFile, + streamToFile, +} from './files-get' import { attachProtocolCommands } from './index' const { output, requestRaw } = vi.hoisted(() => ({ @@ -89,6 +96,133 @@ function program(): Command { return root } +describe('an interrupted download', () => { + /** Staging directories left beside a destination, as `ls -a` shows them. */ + function stagingDirectories(): string[] { + return readdirSync(dir).filter((entry) => entry.startsWith('.sim-download-')) + } + + it('removes the staging directory and re-raises when a signal arrives', () => { + const staging = mkdtempSync(join(dir, '.sim-download-')) + writeFileSync(join(staging, 'payload'), 'partial') + // Injected: the real termination re-raises the signal, which would take the + // test runner down with it. + const terminate = vi.fn() + const dispose = removeStagingOnSignal(() => staging, terminate) + + process.emit('SIGINT') + dispose() + + expect(existsSync(staging)).toBe(false) + expect(terminate).toHaveBeenCalledWith('SIGINT') + }) + + /** + * The handler drops itself before terminating, rather than clearing the + * signal: the process has to die by that signal, and `removeAllListeners` + * bought that by taking every other handler on the process down with it. + */ + it('clears only its own listener before terminating', () => { + const foreign = vi.fn() + process.on('SIGINT', foreign) + const baseline = process.listenerCount('SIGINT') + let remaining: unknown[] = [] + const terminate = vi.fn(() => { + remaining = process.listeners('SIGINT') + }) + const dispose = removeStagingOnSignal(() => null, terminate) + + try { + process.emit('SIGINT') + } finally { + dispose() + process.off('SIGINT', foreign) + } + + expect(terminate).toHaveBeenCalledWith('SIGINT') + expect(remaining).toContain(foreign) + expect(remaining).toHaveLength(baseline) + }) + + it('watches for signals only while a download is staged', async () => { + const before = { int: process.listenerCount('SIGINT'), term: process.listenerCount('SIGTERM') } + let observed = 0 + const body = new ReadableStream({ + pull(controller) { + observed = process.listenerCount('SIGINT') + controller.enqueue(new TextEncoder().encode('data')) + controller.close() + }, + }) + + await saveToFile(body, join(dir, 'out.bin'), false) + + expect(observed).toBe(before.int + 1) + expect(process.listenerCount('SIGINT')).toBe(before.int) + expect(process.listenerCount('SIGTERM')).toBe(before.term) + }) + + /** Resolves once the download has staged its directory beside the target. */ + async function stagingDirectory(): Promise { + for (let attempt = 0; attempt < 2000; attempt += 1) { + const [staged] = stagingDirectories() + if (staged) return join(dir, staged) + await sleep(1) + } + throw new Error('the download staged no directory') + } + + /** + * The removal of the staging directory is asynchronous, so it is a window the + * watch has to outlive: a handler disposed before it leaves the directory + * behind for exactly the interrupt the watch exists to catch. The directory is + * padded first so the removal spans enough turns to sample. + */ + it('keeps watching for signals until the staging directory is gone', async () => { + const before = process.listenerCount('SIGINT') + const body = new ReadableStream({ + async pull(controller) { + const staging = await stagingDirectory() + for (let index = 0; index < 2000; index += 1) { + writeFileSync(join(staging, `pad-${index}`), '') + } + controller.enqueue(new TextEncoder().encode('data')) + controller.close() + }, + }) + const samples: number[] = [] + const poll = setInterval(() => { + if (stagingDirectories().length > 0) samples.push(process.listenerCount('SIGINT')) + }) + + try { + await saveToFile(body, join(dir, 'out.bin'), false) + } finally { + clearInterval(poll) + } + + expect(samples.length).toBeGreaterThan(0) + expect(samples.filter((count) => count !== before + 1)).toEqual([]) + expect(process.listenerCount('SIGINT')).toBe(before) + }) + + it('disposes the watch when the publish itself fails', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + const before = process.listenerCount('SIGINT') + + await expect(saveToFile(bodyOf(['new']), target, false)).rejects.toThrow(/already exists/) + + expect(process.listenerCount('SIGINT')).toBe(before) + }) + + it('leaves no staging directory behind on a completed download', async () => { + await saveToFile(bodyOf(['done']), join(dir, 'out.bin'), false) + + expect(stagingDirectories()).toEqual([]) + }) +}) + describe('streamToFile', () => { it('writes the body to disk', async () => { const target = join(dir, 'out.txt') diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 31c4c84ed26..6392c66c7b9 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,5 +1,5 @@ import { once } from 'node:events' -import { createWriteStream, type WriteStream } from 'node:fs' +import { createWriteStream, rmSync, type WriteStream } from 'node:fs' import { link, lstat, mkdtemp, readlink, rename, rm } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Readable, type Writable } from 'node:stream' @@ -89,6 +89,63 @@ export async function streamToFile( } } +/** Signals that end the process while a download is staged beside its target. */ +const STAGE_SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM'] + +/** + * Ends the process by the signal that arrived, once our own handler has run. + * + * Installing a listener suppresses Node's default termination, so the handler + * has to terminate itself. Re-raising rather than `process.exit(130)` keeps the + * process dying *by signal*, so a wrapping shell still sees 130/143 and a + * `trap` still fires — the behaviour an interrupted download has today. Only + * our own listener is removed, by the handler itself before it calls this: + * `removeAllListeners` would take a caller's own handler with it, and nothing + * here needs one gone but ours. + */ +function reRaise(signal: NodeJS.Signals): void { + process.kill(process.pid, signal) +} + +/** + * Removes the staging directory when a signal ends the process. + * + * `saveStagedFile` cleans up in normal control flow, which a signal never + * reaches: the process is torn down mid-`pipeline`, so every Ctrl-C left + * another `.sim-download-*` holding a partial payload beside the destination. + * The removal is synchronous because the termination that follows gives an + * async `rm` no turn to run. + * + * Exported for its own test: driving it through a real interrupt would take the + * test runner down with it. + */ +export function removeStagingOnSignal( + stagingDirectory: () => string | null, + terminate: (signal: NodeJS.Signals) => void = reRaise +): () => void { + const installed = STAGE_SIGNALS.map((signal) => { + const onSignal = () => { + process.off(signal, onSignal) + const directory = stagingDirectory() + if (directory) { + try { + rmSync(directory, { recursive: true, force: true }) + } catch { + // A staging directory we cannot remove is not worth masking the + // interrupt the caller asked for. + } + } + terminate(signal) + } + process.on(signal, onSignal) + return [signal, onSignal] as const + }) + + return () => { + for (const [signal, onSignal] of installed) process.off(signal, onSignal) + } +} + async function saveStagedFile( body: ReadableStream, target: string, @@ -96,38 +153,47 @@ async function saveStagedFile( ): Promise { let temporaryDirectory: string | null = null let failure: SimApiError | null = null + const disposeSignalCleanup = removeStagingOnSignal(() => temporaryDirectory) try { - const publicationTarget = force ? await forcedPublicationTarget(target) : target - temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-')) - const temporaryPath = join(temporaryDirectory, 'payload') - await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) - if (force) { - await rename(temporaryPath, publicationTarget) - } else { - try { - await link(temporaryPath, publicationTarget) - } catch (error) { - throw unsupportedAtomicPublish(target, error) ?? error + try { + const publicationTarget = force ? await forcedPublicationTarget(target) : target + temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-')) + const temporaryPath = join(temporaryDirectory, 'payload') + await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) + if (force) { + await rename(temporaryPath, publicationTarget) + } else { + try { + await link(temporaryPath, publicationTarget) + } catch (error) { + throw unsupportedAtomicPublish(target, error) ?? error + } } + } catch (error) { + failure = normalizedWriteFailure(target, error) } - } catch (error) { - failure = normalizedWriteFailure(target, error) - } - if (temporaryDirectory) { - try { - await rm(temporaryDirectory, { recursive: true, force: true }) - } catch (cleanupError) { - if (failure) throw combinedCleanupFailure(failure, temporaryDirectory, cleanupError) - throw new SimApiError( - `Saved ${target}, but could not remove temporary directory ${temporaryDirectory}: ${(cleanupError as Error).message}`, - 0 - ) + if (temporaryDirectory) { + try { + await rm(temporaryDirectory, { recursive: true, force: true }) + } catch (cleanupError) { + if (failure) throw combinedCleanupFailure(failure, temporaryDirectory, cleanupError) + throw new SimApiError( + `Saved ${target}, but could not remove temporary directory ${temporaryDirectory}: ${(cleanupError as Error).message}`, + 0 + ) + } } - } - if (failure) throw failure + if (failure) throw failure + } finally { + // Disposed on every path out, and only once the removal above has finished: + // an `rm` is asynchronous, so a handler disposed before it awaits leaves the + // staging directory behind for a signal arriving in exactly the window this + // watch exists for. + disposeSignalCleanup() + } } /** Publishes a complete staged body atomically, with overwrite requiring explicit force. */ 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 91da8871dfa..871fef9373d 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts @@ -3,9 +3,10 @@ */ import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLI_CONTRACT } from '../../contract/commands' import { type ListLogsResponse, V2_OPERATIONS } from '../../generated/v2-api' import { SimApiError } from '../../http/client' -import { attachLogsFollow, type LogRow } from './logs-follow' +import { attachLogsFollow, type LogRow, MAX_CELL_WIDTH } from './logs-follow' const { mockRequest, mockSleep, profile } = vi.hoisted(() => ({ mockRequest: vi.fn(), @@ -236,6 +237,90 @@ describe('sim logs follow', () => { expect(stdout).toHaveLength(3) }) + it('keeps a run id whole when the follow started with an empty backlog', async () => { + // `-n 0` seeds the writer with no rows, so the widths used to lock to the + // header labels — RUN is three characters, and a 36-character run id + // printed as `9f…`, uncopyable. + profile.output = 'table' + const runId = '9f5e9856-1801-4028-a85f-6e335e65d974' + const arrival = row(runId, '2026-08-17T10:00:01.000Z') + arrival.workflow = { + id: 'wf_1', + name: 'clitest-nightly-sync', + description: null, + deleted: false, + } + respondWith([page([]), page([arrival])]) + + await follow('-n', '0') + + const printed = stdout.join('\n') + expect(printed).toContain(runId) + expect(printed).toContain('clitest-nightly-sync') + expect(printed).not.toContain('…') + }) + + it('lines an arriving row up with the header it printed before any rows', async () => { + // The floors are the half of this that keeps `-n 0` readable: without them + // the columns lock to their header labels, and every cell of the first real + // row overflows, so nothing below the header lines up for the life of the + // follow. + profile.output = 'table' + const arrival = row('9f5e9856-1801-4028-a85f-6e335e65d974', '2026-08-17T10:00:01.000Z') + respondWith([page([]), page([arrival])]) + + await follow('-n', '0') + + const [header, printed] = stdout + expect(printed.indexOf('completed')).toBe(header.indexOf('STATUS')) + expect(printed.indexOf('Nightly sync')).toBe(header.indexOf('WORKFLOW')) + expect(printed.indexOf('9f5e9856')).toBe(header.indexOf('RUN')) + }) + + it('keeps a later row wider than the batch that locked the widths', async () => { + profile.output = 'table' + const first = row('run_1', '2026-08-17T10:00:01.000Z') + first.workflow = { id: 'wf_1', name: 'short', description: null, deleted: false } + const second = row('run_2', '2026-08-17T10:00:02.000Z') + second.workflow = { + id: 'wf_2', + name: 'a-considerably-longer-workflow-name', + description: null, + deleted: false, + } + respondWith([page([first]), page([second, first])]) + + await follow('-n', '1') + + expect(stdout.join('\n')).toContain('a-considerably-longer-workflow-name') + }) + + it('still cuts a cell at the width one column may ever take', async () => { + profile.output = 'table' + const huge = 'x'.repeat(MAX_CELL_WIDTH + 40) + const arrival = row('run_1', '2026-08-17T10:00:01.000Z') + arrival.workflow = { id: 'wf_1', name: huge, description: null, deleted: false } + respondWith([page([]), page([arrival])]) + + await follow('-n', '0') + + const printed = stdout.join('\n') + expect(printed).not.toContain(huge) + expect(printed).toContain(`${'x'.repeat(MAX_CELL_WIDTH - 1)}…`) + }) + + it('asks for no column floor wider than a column may render', () => { + // A floor above the cap is silently clamped, so an oversized spec would + // read as deliberate and do nothing. + const oversized = Object.entries(CLI_CONTRACT).flatMap(([operation, spec]) => + [...(spec.columns ?? []), ...(spec.fields ?? [])] + .filter((column) => (column.minWidth ?? 0) > MAX_CELL_WIDTH) + .map((column) => `${operation}.${column.header}`) + ) + + expect(oversized).toEqual([]) + }) + it('keeps rows on stdout and retry notices on stderr', async () => { Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true }) const first = row('run_1', '2026-08-17T10:00:01.000Z') diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.ts b/packages/sim-cli/src/commands/protocol/logs-follow.ts index 3f9d4269941..330cc86011b 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.ts @@ -62,7 +62,7 @@ const MAX_PAGES_PER_POLL = 10 const MAX_REMEMBERED_RUNS = 5000 /** Widest a table column renders, matching the `logs list` table. */ -const MAX_CELL_WIDTH = 60 +export const MAX_CELL_WIDTH = 60 /** How often an interruptible wait checks whether Ctrl-C has arrived. */ const WAIT_SLICE_MS = 250 @@ -135,8 +135,14 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string { } } -const COLUMNS: Column[] = (CLI_CONTRACT.listLogs?.columns ?? []).map((spec) => ({ +interface FollowColumn extends Column { + /** Narrowest this column may lock to, from the contract's `minWidth`. */ + floor: number +} + +const COLUMNS: FollowColumn[] = (CLI_CONTRACT.listLogs?.columns ?? []).map((spec) => ({ header: spec.header, + floor: Math.min(MAX_CELL_WIDTH, spec.minWidth ?? 0), value: (row) => renderCell(at(row, spec.path ?? spec.header), spec.format), })) @@ -156,7 +162,7 @@ function pad(value: string, width: number): string { } /** - * Truncates a cell to its locked column width. + * Truncates a cell to the widest a column may ever render. * * Skipped unless the visible width equals the string length, which is only true * of text carrying neither an escape sequence nor a wide character — slicing @@ -178,18 +184,32 @@ type RowWriter = (rows: LogRow[]) => void * table. Locking the widths is what `kubectl get -w` does, for the same reason. * The header prints on the first call even when that call carries no rows, so * the columns are labelled from the start rather than from the first run. + * + * The lock decides the layout, never the content: a cell wider than its column + * overflows and pushes the rest of its row right, because clamping to a width + * the first batch happened to set is how a copyable run id became `9f…`. Cells + * are still cut at {@link MAX_CELL_WIDTH}, the cap `logs list` uses, so one + * LLM-sized cell cannot take the view. The contract's per-column floors are + * what keep the ragged case rare — without them `-n 0` locks every column to + * its header label and the whole follow prints askew. */ function createTableWriter(): RowWriter { let widths: number[] | null = null return (rows) => { - const lines = rows.map((row) => COLUMNS.map((column) => oneLine(column.value(row)))) + const lines = rows.map((row) => + COLUMNS.map((column) => clamp(oneLine(column.value(row)), MAX_CELL_WIDTH)) + ) if (!widths) { widths = COLUMNS.map((column, index) => Math.min( MAX_CELL_WIDTH, - Math.max(visibleWidth(column.header), ...lines.map((line) => visibleWidth(line[index]))) + Math.max( + column.floor, + visibleWidth(column.header), + ...lines.map((line) => visibleWidth(line[index])) + ) ) ) const header = widths @@ -206,7 +226,7 @@ function createTableWriter(): RowWriter { for (const line of lines) { console.log( line - .map((cell, index) => pad(clamp(cell, locked[index]), locked[index])) + .map((cell, index) => pad(cell, locked[index])) .join(' ') .trimEnd() ) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index 1b749460de2..9747b6fd2c7 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -77,6 +77,48 @@ describe('tables import argument guards', () => { await expect(runImport(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) }) + it('refuses to replace an existing table without --yes', async () => { + await expect(runImport(['f.csv', '--table-id', 't', '--mode', 'replace'])).rejects.toThrow( + /Re-run with --yes to confirm/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('replaces an existing table once --yes is passed', async () => { + mockRequest.mockResolvedValue({ + data: { + session: { id: 'i1', status: 'completed', tableId: 't', rowsProcessed: 0, error: null }, + uploadToken: null, + transfer: null, + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runImport(['--file-id', 'w_1', '--table-id', 't', '--mode', 'replace', '--yes']) + + expect(mockRequest).toHaveBeenCalled() + const body = (mockRequest.mock.calls[0][1] as { body: Record }).body + expect(body.target).toEqual({ type: 'existing', tableId: 't', mode: 'replace' }) + }) + + it('leaves the shapes that write nothing away ungated', async () => { + mockRequest.mockResolvedValue({ + data: { + session: { id: 'i1', status: 'completed', tableId: 't', rowsProcessed: 0, error: null }, + uploadToken: null, + transfer: null, + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runImport(['--file-id', 'w_1', '--table-id', 't', '--mode', 'append']) + expect(mockRequest).toHaveBeenCalled() + + mockRequest.mockClear() + await runImport(['--file-id', 'w_1', '--name', 'Customers']) + expect(mockRequest).toHaveBeenCalled() + }) + it('rejects an invalid import mode before making a request', async () => { await expect( runImport(['--file-id', 'w_1', '--name', 'Customers', '--mode', 'merge']) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index f746c964934..62f12c7b05a 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -26,6 +26,7 @@ interface ImportOptions { createColumns?: string timezone?: string wait: boolean + yes?: boolean } const IMPORT_POLL_MS = 1500 @@ -163,6 +164,10 @@ export function attachTableImport(tables: Command): void { .option('--mapping ', 'Column mapping (--table-id only)') .option('--create-columns ', 'Columns to create (--table-id only)') .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') + // Not the bare `(required)` marker the generated flags use: the docs + // generator keys its Required column off that exact suffix, and this one is + // required for a single shape of the command. + .option('-y, --yes', 'Confirm this destructive operation (required with --mode replace)') .option('--no-wait', 'Return once the import is queued instead of watching it') .action(async (path: string | undefined, options: ImportOptions, command: Command) => { const { client, profile } = clientFrom(command) @@ -173,6 +178,21 @@ export function attachTableImport(tables: Command): void { } const intoExisting = validateTargetOptions(options) + + // Gated the way the eleven destructive `tables` leaves are, but only for + // the shape that destroys something: `--mode replace` empties the table + // before its first batch, while an append or a new table writes nothing + // away. Refused before the file is opened, as the target-option guards + // above are, so the refusal costs nothing. Widening it to every import + // would put a prompt on the common path and teach the reflexive `--yes` + // the gate depends on nobody learning. + if (intoExisting && options.mode === 'replace' && options.yes !== true) { + throw new SimApiError( + 'This deletes every row in the table before loading the CSV and cannot be undone. Re-run with --yes to confirm.', + 0 + ) + } + const local = path ? await localFile(path) : null const source = local ? { diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts index 7fe1dbb3950..f0220dab97c 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts @@ -259,26 +259,74 @@ function program(): Command { return root } +/** + * A workflow id is a bare UUID. `wf_` is the workspace-file prefix, so it never + * names a workflow — spelling one that way here would model the wrong scheme. + */ +const WORKFLOW_ID = '00000000-0000-4000-8000-00000000000a' + async function run(...argv: string[]): Promise { await program().parseAsync(['node', 'sim', 'workflows', 'run', ...argv]) } describe('sim workflows run --follow', () => { it('refuses --follow together with --async', async () => { - await expect(run('wf_1', '--follow', '--async')).rejects.toThrow(/pass one, not both/) + await expect(run(WORKFLOW_ID, '--follow', '--async')).rejects.toThrow(/pass one, not both/) expect(requestRaw).not.toHaveBeenCalled() }) it('refuses stream-only flags without --follow', async () => { - await expect(run('wf_1', '--include-thinking')).rejects.toThrow(/add --follow/) + await expect(run(WORKFLOW_ID, '--include-thinking')).rejects.toThrow(/add --follow/) + expect(request).not.toHaveBeenCalled() + }) + + it('refuses --select-output without --follow and sends nothing', async () => { + await expect(run(WORKFLOW_ID, '--select-output', 'agent_1.content')).rejects.toThrow( + /add --follow/ + ) expect(request).not.toHaveBeenCalled() + expect(requestRaw).not.toHaveBeenCalled() + }) + + it('points a refused --select-output at the dialect the run resource takes', async () => { + // The caller just typed a block *name*, which is what this flag accepts and + // what `workflows runs get` rejects, so a hint that only repeated the flag + // would send them into a second 400. + await expect(run(WORKFLOW_ID, '--select-output', 'agent_1.content')).rejects.toThrow( + /workflows runs get .*--select-output \[\.path\].*block ids, not the block names/s + ) + }) + + it('tells --async --select-output that no stream is coming, rather than to follow', async () => { + // `--async --follow` is refused outright, so "add --follow" would be advice + // that cannot be taken. + const failure = await run(WORKFLOW_ID, '--async', '--select-output', 'agent_1.content').catch( + (error: Error) => error + ) + + expect(failure?.message).toContain('--async returns as soon as the run is queued') + expect(failure?.message).not.toContain('add --follow') + expect(request).not.toHaveBeenCalled() + }) + + it('sends the selection with the stream once --follow is passed', async () => { + requestRaw.mockResolvedValue(streamResponse(sse({ event: 'final', data: { success: true } }))) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await run(WORKFLOW_ID, '--follow', '--select-output', 'agent_1.content') + + expect(requestRaw.mock.calls[0][1].body).toEqual({ + stream: true, + selectedOutputs: ['agent_1.content'], + }) }) it('leaves the generated non-streaming path untouched', async () => { request.mockResolvedValue({ data: { success: true, output: {} } }) vi.spyOn(console, 'log').mockImplementation(() => {}) - await run('wf_1', '--input', '{"topic":"otters"}') + await run(WORKFLOW_ID, '--input', '{"topic":"otters"}') expect(requestRaw).not.toHaveBeenCalled() expect(request).toHaveBeenCalledTimes(1) @@ -289,7 +337,14 @@ describe('sim workflows run --follow', () => { request.mockResolvedValue({ data: { success: true, output: {} } }) vi.spyOn(console, 'log').mockImplementation(() => {}) - await run('wf_1', '--manual', '--trigger', 'slack-trigger', '--input', '{"event":"created"}') + await run( + WORKFLOW_ID, + '--manual', + '--trigger', + 'slack-trigger', + '--input', + '{"event":"created"}' + ) expect(request.mock.calls[0][1].body).toEqual({ input: { event: 'created' }, @@ -301,7 +356,7 @@ describe('sim workflows run --follow', () => { request.mockResolvedValue({ data: { success: true, output: {} } }) vi.spyOn(console, 'log').mockImplementation(() => {}) - await run('wf_1', '--from-block', 'agent-1', '--source-run', 'run-1') + await run(WORKFLOW_ID, '--from-block', 'agent-1', '--source-run', 'run-1') expect(request.mock.calls[0][1].body).toEqual({ run: { @@ -316,7 +371,7 @@ describe('sim workflows run --follow', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--manual', '--mock-payload', '--follow') + await run(WORKFLOW_ID, '--manual', '--mock-payload', '--follow') expect(requestRaw.mock.calls[0][1].body).toEqual({ run: { source: 'manual', entry: { type: 'trigger', useMockPayload: true } }, @@ -325,12 +380,16 @@ describe('sim workflows run --follow', () => { }) it('fails fast on invalid manual flag combinations', async () => { - await expect(run('wf_1', '--trigger', 'trigger-1')).rejects.toThrow(/require --manual/) - await expect(run('wf_1', '--from-block', 'agent-1')).rejects.toThrow(/requires --source-run/) - await expect(run('wf_1', '--source-run', 'run-1')).rejects.toThrow(/requires --from-block/) - await expect(run('wf_1', '--manual', '--async')).rejects.toThrow(/does not support --async/) + await expect(run(WORKFLOW_ID, '--trigger', 'trigger-1')).rejects.toThrow(/require --manual/) + await expect(run(WORKFLOW_ID, '--from-block', 'agent-1')).rejects.toThrow( + /requires --source-run/ + ) + await expect(run(WORKFLOW_ID, '--source-run', 'run-1')).rejects.toThrow(/requires --from-block/) + await expect(run(WORKFLOW_ID, '--manual', '--async')).rejects.toThrow( + /does not support --async/ + ) await expect( - run('wf_1', '--manual', '--mock-payload', '--input', '{"event":"created"}') + run(WORKFLOW_ID, '--manual', '--mock-payload', '--input', '{"event":"created"}') ).rejects.toThrow(/cannot be combined/) expect(request).not.toHaveBeenCalled() expect(requestRaw).not.toHaveBeenCalled() @@ -341,10 +400,10 @@ describe('sim workflows run --follow', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--follow', '--input', '{"topic":"otters"}') + await run(WORKFLOW_ID, '--follow', '--input', '{"topic":"otters"}') const [path, init] = requestRaw.mock.calls[0] - expect(path).toBe('/api/v2/workflows/wf_1/execute') + expect(path).toBe(`/api/v2/workflows/${WORKFLOW_ID}/execute`) expect(init.body).toEqual({ input: { topic: 'otters' }, stream: true }) expect(init.headers.accept).toBe('text/event-stream') expect(init.headers['x-sim-stream-protocol']).toBeUndefined() @@ -355,7 +414,7 @@ describe('sim workflows run --follow', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--follow', '--include-thinking', '--include-tool-calls') + await run(WORKFLOW_ID, '--follow', '--include-thinking', '--include-tool-calls') const init = requestRaw.mock.calls[0][1] expect(init.body).toMatchObject({ stream: true, includeThinking: true, includeToolCalls: true }) @@ -375,7 +434,7 @@ describe('sim workflows run --follow', () => { const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--follow') + await run(WORKFLOW_ID, '--follow') const printed = stdout.mock.calls.map((call) => String(call[0])).join('\n') expect(JSON.parse(printed)).toEqual({ success: true, output: { answer: 42 } }) @@ -392,7 +451,7 @@ describe('sim workflows run --follow', () => { const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await expect(run('wf_1', '--follow')).rejects.toThrow(/Block agent_1 failed/) + await expect(run(WORKFLOW_ID, '--follow')).rejects.toThrow(/Block agent_1 failed/) expect(stdout).toHaveBeenCalled() }) @@ -404,7 +463,7 @@ describe('sim workflows run --follow', () => { headers: new Headers({ 'content-type': 'application/json' }), } as unknown as Response) - await expect(run('wf_1', '--follow')).rejects.toThrow(/instead of an event stream/) + await expect(run(WORKFLOW_ID, '--follow')).rejects.toThrow(/instead of an event stream/) expect(cancel).toHaveBeenCalled() }) @@ -414,6 +473,6 @@ describe('sim workflows run --follow', () => { ) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await expect(run('wf_1', '--follow')).rejects.toBeInstanceOf(SimApiError) + await expect(run(WORKFLOW_ID, '--follow')).rejects.toBeInstanceOf(SimApiError) }) }) diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts index 7e47171a0e0..256e0567f75 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts @@ -342,6 +342,19 @@ function followOrDelegate(previous: ((args: unknown[]) => unknown) | null) { const flags = command.optsWithGlobals() as Record if (flags.follow !== true) { + // `selectedOutputs` is stream-only server-side, so without `--follow` the + // generated path spends a request to be told so. The recovery names the + // run resource and its dialect: `--select-output` here takes block names, + // and `workflows runs get` resolves block ids only, so repeating what was + // just typed there fails a second time. + if (Array.isArray(flags.selectOutput) && flags.selectOutput.length > 0) { + throw new SimApiError( + flags.async === true + ? '--select-output shapes a streamed result, and --async returns as soon as the run is queued, so there is no stream to shape. Drop one of them, or read the finished run with: sim workflows runs get --workflow --select-output [.path] — that resource matches block ids, not the block names --select-output takes here.' + : '--select-output shapes a streamed result; add --follow. To narrow a run that has already finished: sim workflows runs get --workflow --select-output [.path] — that resource matches block ids, not the block names --select-output takes here.', + 0 + ) + } if (flags.includeThinking === true || flags.includeToolCalls === true) { throw new SimApiError( '--include-thinking and --include-tool-calls describe a stream; add --follow', diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-wait.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-wait.test.ts index eeb3d7eb6b8..0e0e9aa2d47 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-wait.test.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-wait.test.ts @@ -89,7 +89,7 @@ function run(payload: RunPayload) { return { data: { runId: 'run_1', - workflowId: 'wf_1', + workflowId: '00000000-0000-4000-8000-00000000000a', startedAt: '2026-08-17T00:00:00.000Z', endedAt: null, durationMs: null, @@ -128,7 +128,7 @@ async function wait(argv: string[] = []): Promise { 'wait', 'run_1', '--workflow', - 'wf_1', + '00000000-0000-4000-8000-00000000000a', ...argv, ]) } @@ -140,7 +140,10 @@ describe('workflows runs wait', () => { await wait() expect(mockRequest).toHaveBeenCalledTimes(3) - expect(mockRequest).toHaveBeenCalledWith('/api/v2/workflows/wf_1/runs/run_1', { method: 'GET' }) + expect(mockRequest).toHaveBeenCalledWith( + '/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs/run_1', + { method: 'GET' } + ) expect(process.exitCode).toBe(0) expect(logged.join('\n')).toContain('completed') }) @@ -193,7 +196,7 @@ describe('workflows runs wait', () => { expect(mockRequest).toHaveBeenCalledTimes(1) expect(process.exitCode).toBe(3) expect(errored.join('\n')).toContain( - 'sim workflows runs resume run_1 --workflow wf_1 --context ctx_9' + 'sim workflows runs resume run_1 --workflow 00000000-0000-4000-8000-00000000000a --context ctx_9' ) }) @@ -295,7 +298,16 @@ describe('workflows runs wait', () => { runs.commands.forEach((command) => command.exitOverride()) await expect( - root.parseAsync(['node', 'sim', 'runs', 'wait', 'run_1', 'run_2', '--workflow', 'wf_1']) + root.parseAsync([ + 'node', + 'sim', + 'runs', + 'wait', + 'run_1', + 'run_2', + '--workflow', + '00000000-0000-4000-8000-00000000000a', + ]) ).rejects.toThrow(/too many arguments/) expect(mockRequest).not.toHaveBeenCalled() }) diff --git a/packages/sim-cli/src/commands/secrets.test.ts b/packages/sim-cli/src/commands/secrets.test.ts index c5e64fdbef7..92dc2e714c3 100644 --- a/packages/sim-cli/src/commands/secrets.test.ts +++ b/packages/sim-cli/src/commands/secrets.test.ts @@ -113,6 +113,18 @@ describe('secrets set', () => { }) }) + it('marks --scope required in the help it renders', () => { + // Commander enforces `makeOptionMandatory` but renders nothing to say so: + // the marker is the literal suffix the generated flags carry. + const secrets = program().commands.find((command) => command.name() === 'secrets') + const set = secrets?.commands.find((command) => command.name() === 'set') + if (!set) throw new Error('Missing secrets set command') + + expect(set.helpInformation().replace(/\s+/g, ' ')).toContain( + 'Secret ownership scope (required)' + ) + }) + it('keeps --value optional in help and rejects an empty direct value', async () => { const secrets = program().commands.find((command) => command.name() === 'secrets') const set = secrets?.commands.find((command) => command.name() === 'set') @@ -163,6 +175,38 @@ describe('secrets set --unredacted', () => { expect('unredacted' in sentBody()).toBe(false) }) + it('changes only the redaction setting, without prompting for a value', async () => { + // The shape a CI job runs: the secret already exists and only its redaction + // setting is changing, so there is no value to read and nothing to prompt. + await set('--scope', 'workspace', '--no-unredacted') + + expect(mockPromptSecret).not.toHaveBeenCalled() + expect(sentBody()).toEqual({ workspaceId: 'ws_local', scope: 'workspace', unredacted: false }) + expect('value' in sentBody()).toBe(false) + }) + + it('changes only the description, without prompting for a value', async () => { + await set('--scope', 'workspace', '--description', 'Billing key') + + expect(mockPromptSecret).not.toHaveBeenCalled() + expect(sentBody().description).toBe('Billing key') + expect('value' in sentBody()).toBe(false) + }) + + it('refuses both spellings of the redaction setting in one invocation', async () => { + // They share one commander attribute, so the loser is dropped silently — + // on the flag governing whether the value is readable in plaintext. + await expect( + set('--scope', 'workspace', '--value', 'v', '--unredacted', '--no-unredacted') + ).rejects.toThrow(/either --unredacted or --no-unredacted, not both/) + expect(mockRequest).not.toHaveBeenCalled() + + await expect( + set('--scope', 'workspace', '--value', 'v', '--no-unredacted', '--unredacted') + ).rejects.toThrow(/either --unredacted or --no-unredacted, not both/) + expect(mockRequest).not.toHaveBeenCalled() + }) + it('rejects the flag for a personal secret before reading the value', async () => { await expect(set('--scope', 'personal', '--unredacted')).rejects.toThrow( '--unredacted is only supported for a workspace secret.' diff --git a/packages/sim-cli/src/commands/secrets.ts b/packages/sim-cli/src/commands/secrets.ts index bf5b8634f18..83312008e63 100644 --- a/packages/sim-cli/src/commands/secrets.ts +++ b/packages/sim-cli/src/commands/secrets.ts @@ -76,7 +76,22 @@ function validateWorkspaceOnlyFlag( } /** - * Reads the secret, from the flag or the prompt. + * Reads the secret, from the flag or the prompt, or not at all. + * + * Nothing is read when the command carries a metadata flag and no `--value`: + * that invocation is changing the description or the redaction setting of a + * secret that already exists, and the API accepts a workspace body with no + * value. Prompting there refused the invocation rather than losing anything: + * off a TTY {@link promptSecret} throws `Interactive secret input requires a + * terminal` before reading a byte, so `sim secrets set NAME --no-unredacted` + * exited 1 in CI for a value it was never asked for, and on a TTY it stopped + * to ask for one — a prompt that rejects an empty entry, so there was no way + * to answer "leave the stored value alone". Skipping the read is what lets a + * metadata-only edit run unattended and without a stored value to re-type. + * + * The knock-on, on a TTY: `sim secrets set NAME --description ...` used to + * prompt for a value and now updates the description alone. A value still + * travels by `--value`, or by the prompt when no metadata flag is passed. * * An abort at the prompt is reported here rather than thrown: Ctrl-C is the * user deciding not to run the command, and the shell's convention for that is @@ -84,8 +99,9 @@ function validateWorkspaceOnlyFlag( * exit is immediate for the same reason the handler's is — the prompt leaves * stdin listening, so a returning process would sit there instead of ending. */ -async function readSecretValue(options: SetSecretOptions): Promise { +async function readSecretValue(options: SetSecretOptions): Promise { if (options.value !== undefined) return validateSecretValue(readValueArgument(options.value)) + if (options.description !== undefined || options.unredacted !== undefined) return undefined try { return validateSecretValue(await promptSecret()) } catch (error) { @@ -95,7 +111,18 @@ async function readSecretValue(options: SetSecretOptions): Promise { } } -async function setSecret(name: string, options: SetSecretOptions, command: Command): Promise { +async function setSecret( + name: string, + options: SetSecretOptions, + command: Command, + redactionSpellings: ReadonlySet +): Promise { + if (redactionSpellings.size > 1) { + throw new SimApiError( + 'Pass either --unredacted or --no-unredacted, not both: they are one setting, and commander keeps only whichever came last.', + 0 + ) + } const description = validateWorkspaceOnlyFlag('description', options.description, options.scope) const unredacted = validateWorkspaceOnlyFlag('unredacted', options.unredacted, options.scope) const value = await readSecretValue(options) @@ -106,7 +133,7 @@ async function setSecret(name: string, options: SetSecretOptions, command: Comma body: { workspaceId: client.requireWorkspace(), scope: options.scope, - value, + ...(value === undefined ? {} : { value }), description, ...(unredacted === undefined ? {} : { unredacted }), }, @@ -120,12 +147,22 @@ export function attachSecretCommands(program: Command): void { const secrets = program.commands.find((command) => command.name() === 'secrets') if (!secrets) throw new Error('The generated secrets command group is missing') + /** + * Which of the two spellings of the redaction setting were typed. + * + * They share one commander attribute, so passing both silently keeps the last + * one — on the flag that decides whether the value is readable in plaintext. + * The parsed options cannot show that, so the occurrences are recorded as + * commander reports them. + */ + const redactionSpellings = new Set() + secrets .command('set') .argument('', 'Secret name, as referenced in workflows') .description('Create or replace a named secret') .addOption( - new Option('--scope ', 'Secret ownership scope') + new Option('--scope ', 'Secret ownership scope (required)') .choices([...SECRET_SCOPES]) .makeOptionMandatory() ) @@ -142,7 +179,9 @@ export function attachSecretCommands(program: Command): void { `${V2_OPERATIONS.setSecret.body.unredacted.describe} Pass --no-unredacted to restore redaction` ) .option('--no-unredacted', 'Send --unredacted as false') + .on('option:unredacted', () => redactionSpellings.add('--unredacted')) + .on('option:no-unredacted', () => redactionSpellings.add('--no-unredacted')) .action((name: string, options: SetSecretOptions, command: Command) => - setSecret(name, options, command) + setSecret(name, options, command, redactionSpellings) ) } diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index 699cc718f04..414ea6abfee 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -3,10 +3,14 @@ export { DEFAULT_ENDPOINT, DEFAULT_PROFILE, deleteProfile, + FORBIDDEN_IN_VALUE, listAuthenticationDependents, listProfiles, + normalizeEndpoint, + normalizeWorkspaceId, OUTPUT_FORMATS, type OutputFormat, + PROFILE_NAME_PATTERN, ProfileConfigError, type ProfileOverrides, type ResolvedProfile, @@ -15,6 +19,7 @@ export { resolveAuthenticationProfileName, resolveProfile, type SettingSource, + validateProfileName, writeConfigProfile, writeCredentialsProfile, } from './profile' diff --git a/packages/sim-cli/src/config/ini.test.ts b/packages/sim-cli/src/config/ini.test.ts index 12e0afe8f6c..974c70c7dfc 100644 --- a/packages/sim-cli/src/config/ini.test.ts +++ b/packages/sim-cli/src/config/ini.test.ts @@ -174,3 +174,106 @@ output = json ) }) }) + +/** + * The format has no escape syntax, so anything that can end a line is structure + * rather than data. These pin the refusal at the writer — the single place + * untrusted text enters the document. + */ +describe('ini write guards', () => { + const INJECTIONS = [ + 'ws_1\nendpoint = http://elsewhere.invalid', + 'ws_1\r\nendpoint = http://elsewhere.invalid', + 'ws_1\u2028endpoint = http://elsewhere.invalid', + 'ws_1\u2029endpoint = http://elsewhere.invalid', + ] + + it('refuses a value that would be read back as a second setting', () => { + for (const value of INJECTIONS) { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, 'default', { workspace: value })).toThrow( + /Refusing to write a value/ + ) + } + }) + + it('refuses a section name that would forge another section header', () => { + const doc = parseIni(SAMPLE) + expect(() => + setSectionValues(doc, 'profile evil]\n[default', { workspace: 'ws_evil' }) + ).toThrow(/Refusing to write a section/) + expect(() => setSectionValues(doc, 'profile evil]', { workspace: 'ws_evil' })).toThrow( + /Refusing to write a section/ + ) + }) + + /** + * The assertion that matters: whatever is written, reading the file back + * cannot produce a section or a setting nobody asked for. + */ + it('cannot forge a section or a setting through a write-then-read cycle', () => { + for (const payload of [...INJECTIONS, 'ws]\n[default]\nendpoint = http://elsewhere.invalid']) { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, `profile ${payload}`, { workspace: 'ws' })).toThrow() + expect(() => setSectionValues(doc, 'profile dev', { workspace: payload })).toThrow() + + const reread = parseIni(serializeIni(doc)) + expect(listSections(reread)).toEqual(['default', 'profile dev']) + expect(getSection(reread, 'default')).toEqual({ + endpoint: 'https://sim.ai', + workspace: 'ws_1', + }) + expect(getSection(reread, 'profile dev')).toEqual({ endpoint: 'http://localhost:3000' }) + } + }) + + /** + * A whitespace-only value reads back as the empty string, so the key would + * look stored while resolving as unset. + */ + it('refuses a blank value', () => { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, 'default', { workspace: ' ' })).toThrow(/blank value/) + }) + + /** + * The reader trims a section name and a value, so padded text would be stored + * as one thing and read back as another: the read reports it missing, and the + * next write appends a second block or key rather than updating the first. + */ + it.each([' profile dev', 'profile dev ', ' profile dev '])( + 'refuses the padded section name %j', + (name) => { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, name, { workspace: 'ws_1' })).toThrow( + /Refusing to write a section/ + ) + } + ) + + it.each([' ws_1', 'ws_1 ', ' ws_1 '])('refuses the padded value %j', (value) => { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, 'default', { workspace: value })).toThrow( + /Refusing to write a value/ + ) + }) + + it('leaves a legitimate value untouched', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'profile staging-1.eu', { endpoint: 'https://staging.example' }) + expect(getSection(parseIni(serializeIni(doc)), 'profile staging-1.eu')).toEqual({ + endpoint: 'https://staging.example', + }) + }) + + /** + * `listProfiles` counts section names, so a section conjured by a removal made + * an unknown profile pass the "does this profile exist?" check for good. + */ + it('does not create a section for a removal-only update', () => { + const doc = parseIni('') + setSectionValues(doc, 'profile fresh', { workspace: null }) + expect(listSections(doc)).toEqual([]) + expect(serializeIni(doc)).toBe('') + }) +}) diff --git a/packages/sim-cli/src/config/ini.ts b/packages/sim-cli/src/config/ini.ts index 412e691c2f0..1ecb1f143bc 100644 --- a/packages/sim-cli/src/config/ini.ts +++ b/packages/sim-cli/src/config/ini.ts @@ -12,6 +12,21 @@ * handful of flat string settings. */ +/** + * An invalid stored setting, or a value the config files cannot represent. + * + * Defined here rather than in `profile.ts` because the writer below is the + * lowest layer that rejects input, and `profile.ts` already imports this module. + * `profile.ts` re-exports it, so callers keep seeing one error type — the one + * the entrypoint renders as a message instead of a stack trace. + */ +export class ProfileConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'ProfileConfigError' + } +} + type Entry = { kind: 'kv'; key: string; value: string } | { kind: 'raw'; text: string } interface Section { @@ -28,6 +43,67 @@ export interface IniDocument { const SECTION_PATTERN = /^\s*\[([^\]]*)\]\s*$/ const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/ +/** The character-class body both forbidden-character patterns are built from. */ +const FORBIDDEN_CLASS = '\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029' + +/** + * Characters a stored value may not contain. + * + * The format has no escape syntax (see the module note), so text that can end a + * line is structure, not data: a value carrying a line break was written + * verbatim and read back on the next load as a *second* setting in the same + * section. That is the class of bug this closes — untrusted text reaching the + * serializer could add settings nobody typed. + * + * The set is every C0 and C1 control character plus U+2028 and U+2029, the two + * Unicode line separators. The separators matter for a second reason: they are + * not line breaks to the reader below, so {@link KV_PATTERN} (whose `.` never + * matches them) fails and the line is kept as opaque `raw` text — the key + * silently vanishes on the next read although the write reported success, and + * because the dead line no longer matches the key, the write after that appends + * a duplicate. + * + * Exported because callers that refuse untrusted text *before* writing — so they + * can say which side produced it — have to refuse exactly this set. A second + * hand-kept copy drifted from this one once already, and the gap let a rejected + * write land after an accepted one. + */ +export const FORBIDDEN_IN_VALUE = new RegExp(`[${FORBIDDEN_CLASS}]`) + +/** As {@link FORBIDDEN_IN_VALUE}, plus the brackets that would close or open a header. */ +const FORBIDDEN_IN_NAME = new RegExp(`[${FORBIDDEN_CLASS}[\\]]`) + +/** Keys have to round-trip through the reader's own key pattern. */ +const WRITABLE_KEY = /^[A-Za-z0-9_.-]+$/ + +/** + * Refuses text that would not survive a write-then-read cycle as the same value. + * + * Rejecting rather than escaping is deliberate: the format has no escape + * syntax, these files are hand-edited and read by AWS-style tooling that would + * not decode one, and no legitimate profile name, workspace id, endpoint, or + * API key contains a line break or a bracket. Inventing an escape here would + * make the files unreadable to everything else that opens them. + */ +function assertWritable(text: string, what: string, forbidden: RegExp): void { + if (forbidden.test(text)) { + throw new ProfileConfigError( + `Refusing to write ${what}: line breaks and control characters cannot be stored in the ~/.sim files, because the format has no way to escape them.` + ) + } + // The reader trims both section names and values, so padded text never comes + // back as written. For a section name that also corrupts the file: the block + // is written under the padded name but read under the trimmed one, so the + // next write finds no match and appends a second block. For a value it is + // quieter but no more acceptable — the key reads back trimmed, so a padded + // secret is silently stored as a different secret than the caller passed. + if (text !== text.trim()) { + throw new ProfileConfigError( + `Refusing to write ${what}: leading or trailing whitespace is not preserved by the ~/.sim files, so it would not read back as written.` + ) + } +} + export function parseIni(text: string): IniDocument { const doc: IniDocument = { preamble: [], sections: [] } let current: Section | null = null @@ -116,16 +192,41 @@ export function listSections(doc: IniDocument): string[] { * {@link getSection}. A removal instead has to clear every block and every * repeat of the key within one: deleting only the first left a later duplicate * to win the merged read, so `--unset` reported success while the value stayed - * in force. + * in force. A removal against a section that is not there writes nothing at all. + * + * This is the one place untrusted text enters the document, so it is where the + * write is refused: see {@link FORBIDDEN_IN_VALUE} for what cannot be stored + * and why the answer is a refusal rather than an escape. */ export function setSectionValues( doc: IniDocument, name: string, values: Record ): void { + // Everything is checked before anything is written, so a rejected write + // leaves the document exactly as it was rather than half-applied. + assertWritable(name, `a section named "${name}"`, FORBIDDEN_IN_NAME) + for (const [key, value] of Object.entries(values)) { + if (!WRITABLE_KEY.test(key)) { + throw new ProfileConfigError(`Refusing to write an unreadable setting name "${key}".`) + } + if (value === null) continue + // A value that is only whitespace reads back as the empty string, so the + // key would look stored and resolve as unset. Checked before the general + // rule below so it keeps its own, more specific message. + if (value.trim() === '') { + throw new ProfileConfigError(`Refusing to write a blank value for "${key}".`) + } + assertWritable(value, `a value for "${key}"`, FORBIDDEN_IN_VALUE) + } + const matching = doc.sections.filter((s) => s.name === name) let section = matching[0] if (!section) { + // A removal has nothing to create the section for, and an empty section is + // not inert: `listProfiles` counts section names, so conjuring one made an + // unknown profile permanently pass the "does this profile exist?" check. + if (Object.values(values).every((value) => value === null)) return section = { name, entries: [] } doc.sections.push(section) matching.push(section) diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 5d422447723..81a702ad9c2 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -11,6 +11,7 @@ import { OUTPUT_FORMATS, resolveAuthenticationProfileName, resolveProfile, + validateProfileName, writeConfigProfile, writeCredentialsProfile, } from './profile' @@ -115,7 +116,9 @@ describe('profile resolution', () => { }) it('fails fast on empty, missing, self-referential, or chained auth profiles', () => { - writeConfigProfile('empty', { auth_profile: '' }) + // Written by hand, because the writer refuses a blank value: it reads back + // as unset while the write reports success. + writeFileSync(configPath(), '[profile empty]\nauth_profile =\n') expect(() => resolveProfile({ profile: 'empty' })).toThrow( 'Profile "empty" has an empty auth_profile.' ) @@ -239,6 +242,57 @@ describe('profile resolution', () => { expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') }) + it('trims a padded endpoint instead of storing text the writer would refuse', () => { + // `new URL()` tolerates padding and hands the string straight back, but the + // config writer refuses it. Untrimmed, `login --endpoint " https://…"` threw + // only after the device flow had already minted and discarded a key. + expect(resolveProfile({ endpoint: ' https://sim.ai/ ' }).endpoint).toBe('https://sim.ai') + + writeConfigProfile('default', { endpoint: 'https://sim.ai' }) + expect(() => + writeConfigProfile('default', { + endpoint: resolveProfile({ endpoint: ' https://staging.sim.ai ' }).endpoint, + }) + ).not.toThrow() + }) + + it('refuses an endpoint carrying a control character, from every source', () => { + // The URL parser deletes tabs and line breaks from anywhere in its input + // before parsing, so the host a reader sees in the string need not be the + // host the request reaches — and the request carries the API key. Trimming + // only reaches the ends, so the normalizer has to refuse the whole set. + for (const endpoint of [ + 'https://www.sim.ai\n@other.invalid', + 'https://www.sim.ai\r@other.invalid', + 'https://www.sim.ai\t@other.invalid', + 'https://www.sim.ai\u0000@other.invalid', + 'https://www.sim.ai\u2028@other.invalid', + ]) { + expect(() => resolveProfile({ endpoint })).toThrow( + 'An endpoint cannot contain line breaks or control characters.' + ) + // The rejected text is echoed back with the control characters redacted, + // so an error message cannot become an escape-sequence delivery vehicle. + expect(() => resolveProfile({ endpoint })).toThrow( + 'Invalid endpoint "https://www.sim.ai @other.invalid" from flag.' + ) + } + + process.env.SIM_ENDPOINT = 'https://www.sim.ai\t@other.invalid' + expect(() => resolveProfile()).toThrow( + 'Invalid endpoint "https://www.sim.ai @other.invalid" from env.' + ) + + Reflect.deleteProperty(process.env, 'SIM_ENDPOINT') + // A tab survives the config reader — `.` matches it, unlike a line break — + // so a hand-edited file can hold one even though the writer refuses to + // produce it, and the read path has to refuse it too. + writeFileSync(configPath(), '[default]\nendpoint = https://www.sim.ai\t@other.invalid\n') + expect(() => resolveProfile()).toThrow( + 'Invalid endpoint "https://www.sim.ai @other.invalid" from config.' + ) + }) + it('fails fast on an endpoint Node cannot parse, naming the source', () => { expect(() => resolveProfile({ endpoint: 'not-a-url' })).toThrow( 'Invalid endpoint "not-a-url" from flag. Use an absolute URL, e.g. https://www.sim.ai or http://localhost:3000' @@ -348,3 +402,102 @@ describe('profile resolution', () => { }) }) }) + +/** + * Config values are serialized without escaping — the format has no escape + * syntax — so text carrying a line break used to be read back as structure: an + * extra setting, or a header for a different profile. Since `endpoint` is what + * decides where the API key is sent, that made a stored name or value a way to + * redirect the key. + */ +describe('config file injection', () => { + const FORGED_SECTION = 'evil]\n[default]\nendpoint = http://elsewhere.invalid\n[x' + const FORGED_SETTING = 'ws_1\nendpoint = http://elsewhere.invalid' + + it('refuses to create a profile whose name would forge a section', () => { + expect(() => resolveProfile({ profile: FORGED_SECTION, allowUnknownProfile: true })).toThrow( + /Invalid profile name/ + ) + }) + + it('redacts control characters out of the rejected name, like its sibling', () => { + // The message lands in a terminal, so echoing the rejected name verbatim + // would let it carry escape sequences there. `normalizeWorkspaceId` already + // redacts through the same pattern. + let message = '' + try { + validateProfileName('evil\u001b[2J\nname') + } catch (error) { + message = (error as Error).message + } + + expect(message).toContain('Invalid profile name "evil [2J name"') + expect(message).not.toMatch(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/) + }) + + it('still resolves an existing profile whose name predates the rule', () => { + // The shape rule governs creation only: a hand-written section keeps + // working, whatever it is called. + writeFileSync(configPath(), '[profile my stack]\nworkspace = ws_hand\n') + + expect(resolveProfile({ profile: 'my stack' })).toMatchObject({ workspaceId: 'ws_hand' }) + expect(resolveProfile({ profile: 'my stack', allowUnknownProfile: true })).toMatchObject({ + workspaceId: 'ws_hand', + }) + expect(() => validateProfileName('my stack')).toThrow(/Invalid profile name/) + }) + + it('refuses to write a profile name that would forge a section', () => { + expect(() => writeConfigProfile(FORGED_SECTION, { workspace: 'ws_evil' })).toThrow( + /Refusing to write a section/ + ) + + expect(existsSync(configPath())).toBe(false) + expect(resolveProfile().endpoint).toBe(DEFAULT_ENDPOINT) + }) + + it('refuses to write a value that would forge a setting', () => { + writeConfigProfile('default', { workspace: 'ws_ok' }) + + expect(() => writeConfigProfile('default', { workspace: FORGED_SETTING })).toThrow( + /Refusing to write a value/ + ) + + expect(readFileSync(configPath(), 'utf8')).not.toContain('elsewhere.invalid') + expect(resolveProfile()).toMatchObject({ + endpoint: DEFAULT_ENDPOINT, + workspaceId: 'ws_ok', + }) + }) + + it('refuses the same through the credentials file', () => { + // The credentials reader merges duplicate sections too, so a forged + // `[victim]` block there would be read as a real key. + expect(() => writeCredentialsProfile(FORGED_SECTION, 'key_evil')).toThrow( + /Refusing to write a section/ + ) + expect(() => writeCredentialsProfile('default', 'key\napi_key = other')).toThrow( + /Refusing to write a value/ + ) + expect(existsSync(credentialsPath())).toBe(false) + }) + + it('leaves an ordinary profile name and value writable', () => { + writeConfigProfile('staging-1.eu', { endpoint: 'https://staging.example' }) + writeCredentialsProfile('staging-1.eu', 'sim_key') + + expect(resolveProfile({ profile: 'staging-1.eu' })).toMatchObject({ + endpoint: 'https://staging.example', + apiKey: 'sim_key', + }) + }) + + it('does not conjure a profile out of an unset', () => { + // An empty section is not inert: `listProfiles` counts section names, so it + // made the unknown-profile guard accept the name from then on. + writeConfigProfile('phantom', { workspace: null }) + + expect(listProfiles()).not.toContain('phantom') + expect(() => resolveProfile({ profile: 'phantom' })).toThrow(/Unknown profile/) + }) +}) diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 7e8025a5f4c..1c274811b64 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -1,9 +1,11 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { + FORBIDDEN_IN_VALUE, getSection, type IniDocument, listSections, + ProfileConfigError, parseIni, removeSection, serializeIni, @@ -35,11 +37,37 @@ export const DEFAULT_ENDPOINT = 'https://www.sim.ai' export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const export type OutputFormat = (typeof OUTPUT_FORMATS)[number] -/** An invalid active profile setting that the user can correct. */ -export class ProfileConfigError extends Error { - constructor(message: string) { - super(message) - this.name = 'ProfileConfigError' +export { FORBIDDEN_IN_VALUE, ProfileConfigError } from './ini' + +/** {@link FORBIDDEN_IN_VALUE}, for redacting every match out of an error message. */ +const FORBIDDEN_IN_VALUE_GLOBAL = new RegExp(FORBIDDEN_IN_VALUE.source, 'g') + +/** + * The shape a newly created profile name has to have. + * + * Enforced only when a profile is being created. A name reaches the config file + * as part of a section header, and the file format has no escape syntax, so a + * name that carries a bracket or a line break would forge a header for another + * profile — the writer refuses that outright, and this pattern is the friendlier + * refusal that names the rule instead of the mechanism. + */ +export const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ + +/** + * Refuses a name for a profile that does not exist yet. + * + * Creation only, deliberately: a hand-written `[profile my stack]` predates this + * rule and must keep resolving, so the read path stays governed by + * {@link requireKnownProfile} alone. + */ +export function validateProfileName(name: string): void { + if (!PROFILE_NAME_PATTERN.test(name)) { + // Redacted the same way as in `normalizeWorkspaceId`: the rejected name is + // untrusted text, and echoing its control characters into a terminal is how + // an error message becomes an escape-sequence delivery vehicle. + throw new ProfileConfigError( + `Invalid profile name "${name.replace(FORBIDDEN_IN_VALUE_GLOBAL, ' ')}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.` + ) } } @@ -280,16 +308,43 @@ export function deleteProfile(profile: string): { config: boolean; credentials: * the user has to edit. */ export function normalizeEndpoint(endpoint: string, source: string): string { - // A trailing slash here produces `https://sim.ai//api/v2/...`, which some - // proxies 404 rather than normalize. - const trimmed = endpoint.replace(/\/+$/, '') + // Surrounding whitespace is stripped first, and for the same reason as in + // `normalizeWorkspaceId`: `new URL()` tolerates padding and hands the padded + // string straight back, but the config writer refuses it. Without this, + // `login --endpoint " https://…"` failed *after* the device flow had already + // minted a key, discarding it and reporting a file-format problem instead of + // naming the flag. It also has to come first so the slash strip sees the real + // end of the URL — and that strip is there because a trailing slash produces + // `https://sim.ai//api/v2/...`, which some proxies 404 rather than normalize. + const trimmed = endpoint.trim().replace(/\/+$/, '') + + // Trimming only reaches the ends, and a control character in the middle is + // the one that matters: the URL parser deletes tabs and line breaks from + // anywhere in its input before parsing it, so a string whose visible text + // names one host can resolve to a different authority — and the resolved one + // is where the API key is sent. Refusing the whole forbidden set here means + // the endpoint this function blesses is the endpoint every later parse sees. + // + // A character check rather than parse-and-compare: comparing `trimmed` + // against `parsed.href` would also reject legitimate endpoints, because the + // parser rewrites percent-encoding, lowercases the scheme and host, + // punycodes an IDN, and drops a default port. The forbidden set is instead a + // strict superset of the characters the parser silently removes, so it is + // exact for this hazard — and it is `FORBIDDEN_IN_VALUE`, the same constant + // the config writer enforces, so a value accepted here can never be refused + // by the write that stores it. + if (FORBIDDEN_IN_VALUE.test(trimmed)) { + throw new ProfileConfigError( + `Invalid endpoint "${endpoint.replace(FORBIDDEN_IN_VALUE_GLOBAL, ' ')}" from ${source}. An endpoint cannot contain line breaks or control characters.` + ) + } let parsed: URL try { parsed = new URL(trimmed) } catch { throw new ProfileConfigError( - `Invalid endpoint "${endpoint}" from ${source}. Use an absolute URL, e.g. ${DEFAULT_ENDPOINT} or http://localhost:3000` + `Invalid endpoint "${endpoint.replace(FORBIDDEN_IN_VALUE_GLOBAL, ' ')}" from ${source}. Use an absolute URL, e.g. ${DEFAULT_ENDPOINT} or http://localhost:3000` ) } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { @@ -301,6 +356,29 @@ export function normalizeEndpoint(endpoint: string, source: string): string { return trimmed } +/** + * Validates a workspace id on its way into the config file. + * + * The sibling of {@link normalizeEndpoint}, and for the same reason: a stored + * setting is read back as a real setting, so a value that could carry a line + * break would come back as an extra setting the user never typed — including an + * `endpoint`, which decides where the API key is sent. Only structure is + * checked, not the id's shape: ids are server-minted and the CLI has no business + * deciding what one may look like. + */ +export function normalizeWorkspaceId(workspaceId: string, source: string): string { + const trimmed = workspaceId.trim() + if (!trimmed) { + throw new ProfileConfigError(`Empty workspace id from ${source}.`) + } + if (FORBIDDEN_IN_VALUE.test(trimmed)) { + throw new ProfileConfigError( + `Invalid workspace id "${trimmed.replace(FORBIDDEN_IN_VALUE_GLOBAL, ' ')}" from ${source}. A workspace id cannot contain line breaks or control characters.` + ) + } + return trimmed +} + /** * Resolves one setting through the precedence chain, reporting where it landed. * Order is flags → environment → files → built-in default, the same order every @@ -322,6 +400,12 @@ export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfil const named = overrides.profile || process.env.SIM_PROFILE const name = named || DEFAULT_PROFILE if (named && !overrides.allowUnknownProfile) requireKnownProfile(named) + // `allowUnknownProfile` means "this profile is about to be created", which is + // the only moment the name shape is the CLI's to decide. An existing profile, + // however it was written, keeps resolving. + if (named && overrides.allowUnknownProfile && !listProfiles().includes(named)) { + validateProfileName(named) + } const config = readConfigProfile(name) const authProfile = resolveAuthenticationProfileName(name) diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index 86415595ddc..73486d2f0b1 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -1,15 +1,46 @@ /** * @vitest-environment node */ -import type { Command } from 'commander' -import { describe, expect, it } from 'vitest' + +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { HELP_EPILOGUE } from '../program' import { buildGeneratedCommands } from '../runtime/build' import { flagNameFor, flagSpecFor } from '../runtime/request' +import { renderPage } from '../runtime/result' import type { OperationSpec } from '../runtime/types' import { CLI_CONTRACT } from './commands' +const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() })) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + }), +})) + +/** + * Runs a leaf through commander, the way the terminal does. + * + * A `confirm` gate is enforced in `runtime/execute`, not by the contract, so + * asserting the string alone would only compare the constant with itself: the + * key could be renamed, or the gate could stop reading it, with the test still + * green. Parsing real argv is what proves the refusal reaches the caller and + * that nothing was sent. + */ +async function runLeaf(argv: string[]): Promise { + const root = new Command('sim').exitOverride().option('--workspace ') + for (const group of buildGeneratedCommands()) root.addCommand(group) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + await root.parseAsync(['node', 'sim', ...argv]) +} + /** Every leaf command's full path, `tables rows count` style. */ function leafPaths(options: { includeHidden?: boolean } = {}): string[] { const paths: string[] = [] @@ -350,6 +381,17 @@ describe('records show what the API actually returns', () => { expect(spec?.describe).toContain('personal API key') }) + it('says which ledger a billing-logs page answers', () => { + // The same workspace, window and flags return a strict subset of rows on a + // personal key, which reads as a bug beside `billing status`. Read off the + // help the terminal prints, since a describe that never reached a command + // would answer nobody. + const help = flatHelp('billing', 'logs') + + expect(help).toContain('personal API key reports only your own events') + expect(help).toMatch(/workspace API key reports every member/) + }) + it('describes a workspace with the fields the strict schema has', () => { const paths = (CLI_CONTRACT.getWorkspace?.fields ?? []).map( (field) => field.path ?? field.header @@ -395,6 +437,57 @@ describe('list columns', () => { expect(toolPaths).toContain('workflowId') }) + /** + * A dispatch's `scope` and `limit` are objects, and column inference drops + * those, so the filtered / select-all-minus / explicit-rows distinction the + * resource publishes had nowhere to appear — while `tableId` and + * `workspaceId`, the command's own arguments, took two columns. + */ + it('renders what a dispatch was asked to run, and drops its own arguments', () => { + const dispatch = { + id: 'disp-1', + tableId: 'tbl-1', + workspaceId: 'ws-1', + status: 'dispatching', + mode: 'incomplete', + scope: { groupIds: ['g1'], filtered: true, excludeRowIds: ['r9', 'r8'] }, + limit: { type: 'rows', max: 500 }, + processedCount: 12, + isManualRun: true, + requestedAt: '2026-08-25T10:00:00.000Z', + completedAt: null, + canceledAt: null, + } + const lines: string[] = [] + const log = vi.spyOn(console, 'log').mockImplementation((line: unknown) => { + lines.push(String(line)) + }) + + try { + renderPage('table', [dispatch], CLI_CONTRACT.listTableDispatches ?? {}) + } finally { + log.mockRestore() + } + + const [header, printed] = lines.join('\n').split('\n') + // Split on the two-space column gap, so a cell is compared by the column it + // landed under rather than by appearing anywhere in the row. + const headers = header.split(/\s{2,}/) + const cells = printed.split(/\s{2,}/) + const cellUnder = (label: string) => cells[headers.indexOf(label)] + + expect(headers).toContain('FILTERED') + expect(headers).toContain('EXCLUDED') + expect(headers).not.toContain('WORKSPACE ID') + expect(headers).not.toContain('TABLE ID') + expect(cellUnder('FILTERED')).toBe('yes') + expect(cellUnder('EXCLUDED')).toBe('2') + expect(cellUnder('GROUPS')).toBe('1') + expect(cellUnder('ROWS')).toBe('—') + expect(cellUnder('MAX ROWS')).toBe('500') + expect(cellUnder('COMPLETED')).toBe('—') + }) + it('lists the audit-log id its own get command takes', () => { expect( (CLI_CONTRACT.listAuditLogs?.columns ?? []).map((column) => column.path ?? column.header) @@ -570,6 +663,27 @@ describe('help and gates state what is actually true', () => { expect(confirm).toContain('--scope') }) + it('names the flag its stream requirement, the way its siblings do', () => { + // `--include-thinking` and `--include-tool-calls` both say so; the flag + // that shares their server-side rule said nothing and spent a 400 to + // discover it. + const help = flatHelp('workflows', 'run') + + expect(help).toContain('--select-output') + expect(help).toContain('requires --follow') + }) + + it('promises the dialect a finished run actually matches', () => { + // The same flag name on two resources: `workflows run` resolves block + // names against the live workflow, `workflows runs get` reads a recorded + // run and matches ids only. The help used to promise names on both. + const help = flatHelp('workflows', 'runs', 'get') + + expect(help).toContain('--select-output ') + expect(help).toContain('blockId') + expect(help).not.toMatch(/blockName|agent_1\.content/) + }) + it('offers no negation for the retry that must travel alone', () => { // `retryProcessing` is `z.literal(true)`, so `--no-retry-processing` sent // `retryProcessing: false` as the entire request — a body that asks for @@ -580,3 +694,40 @@ describe('help and gates state what is actually true', () => { expect(help).not.toContain('--no-retry-processing') }) }) + +describe('the import cancel refuses through commander, not just in the contract', () => { + beforeEach(() => { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: {} }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('refuses an import cancellation without --yes, sends nothing, and says why', async () => { + // The runner commits rows batch by batch and stops between batches, so a + // cancelled import keeps what it wrote; a `replace` has already emptied the + // table by then. The refusal is where the caller reads that, so it is + // asserted through the error commander actually raises. + const refusal = await runLeaf(['tables', 'imports', 'cancel', 'imp-1']).then( + () => '', + (error: Error) => error.message + ) + + expect(refusal).toContain('--yes') + expect(refusal).toContain('replace') + expect(refusal).toMatch(/empties the table/) + expect(refusal).not.toMatch(/not recoverable|cannot be undone/) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('still lets an export cancellation through, since it discards no work', async () => { + await runLeaf(['tables', 'exports', 'cancel', 'tbl-1', 'exp-1']) + expect(mockRequest).toHaveBeenCalled() + }) + + 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', 'exports', 'cancel')).not.toContain('--yes') + }) +}) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 40a7336d932..c423c397926 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -151,7 +151,12 @@ export const CLI_CONTRACT: CliContract = { listBillingLogs: { command: 'billing logs', allWorkspaces: true, - describe: 'List credit usage events', + // Which ledger answered depends on the key, and the counts otherwise read + // as a bug next to `billing status`. Said in the describe for the reason + // `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)", flags: { source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, period: { describe: 'Billing period' }, @@ -364,15 +369,24 @@ export const CLI_CONTRACT: CliContract = { describe: 'Include final output in JSON or YAML output (implies full detail)', }, }, + // The floors are for `logs follow`, which locks its widths on the first + // batch and had nothing to measure at `-n 0`. Each is what the column's own + // rendering needs beyond its header label: an ISO timestamp trimmed to + // seconds, the longest status and core trigger type, a UUID run id, and a + // four-decimal cost above ten credits. `workflow` is free text with no + // bound, so its floor is editorial — enough to tell two runs apart. + // `duration` carries none: a lock is never narrower than its own header, + // and `DURATION` is already the eight characters a floor would have asked + // for. columns: [ - { header: 'started', path: 'startedAt', format: 'timestamp' }, - { header: 'status' }, + { header: 'started', path: 'startedAt', format: 'timestamp', minWidth: 19 }, + { header: 'status', minWidth: 9 }, { header: 'level' }, - { header: 'trigger' }, - { header: 'workflow', path: 'workflow.name' }, + { header: 'trigger', minWidth: 12 }, + { header: 'workflow', path: 'workflow.name', minWidth: 24 }, { header: 'duration', path: 'totalDurationMs', format: 'duration' }, - { header: 'cost', path: 'cost.total', format: 'cost' }, - { header: 'run', path: 'runId' }, + { header: 'cost', path: 'cost.total', format: 'cost', minWidth: 8 }, + { header: 'run', path: 'runId', minWidth: 36 }, ], }, getLog: { @@ -1297,6 +1311,33 @@ export const CLI_CONTRACT: CliContract = { }, }, }, + listTableDispatches: { + // Column inference drops every object-valued field, so `scope` — the whole + // point of the row — was invisible, and `limit` appeared or vanished with + // whether the first row happened to be capped. Declared instead, with the + // scope broken into the scalars that distinguish a plain dispatch from a + // filtered or select-all-minus one. `tableId` and `workspaceId` are gone: + // both are already the command's own arguments. The rest keep the order + // inference gave them and the scope columns are appended, because + // `--output text` is positional and a script may be cutting fields. + columns: [ + { header: 'id' }, + { header: 'status' }, + { header: 'mode' }, + // The cap is `{ type, max } | null`, and only `max` is a value: `type` is + // a `z.literal('rows')` that says the same thing on every row. + { header: 'max rows', path: 'limit.max' }, + { header: 'processed', path: 'processedCount' }, + { header: 'manual', path: 'isManualRun', format: 'bool' }, + { header: 'requested', path: 'requestedAt', format: 'timestamp' }, + { header: 'completed', path: 'completedAt', format: 'timestamp' }, + { header: 'canceled', path: 'canceledAt', format: 'timestamp' }, + { header: 'groups', path: 'scope.groupIds', format: 'count' }, + { header: 'rows', path: 'scope.rowIds', format: 'count' }, + { header: 'filtered', path: 'scope.filtered', format: 'bool' }, + { header: 'excluded', path: 'scope.excludeRowIds', format: 'count' }, + ], + }, runRowEnrichment: { command: 'tables rows enrich', describe: 'Run one row’s enrichment group', @@ -1317,8 +1358,26 @@ export const CLI_CONTRACT: CliContract = { // `resolveTableImportContext` takes when no token is sent — so the flag adds // a credential to type and no import it reaches. getTableImport: { flags: TRANSFER_TOKEN_OMITTED }, - cancelTableImport: { command: 'tables imports cancel', flags: TRANSFER_TOKEN_OMITTED }, - cancelTableExport: { command: 'tables exports cancel' }, + cancelTableImport: { + command: 'tables imports cancel', + flags: TRANSFER_TOKEN_OMITTED, + describe: 'Stop a running import', + // Gated for the reason `tables dispatches cancel` is: the runner commits + // rows batch by batch and its ownership gate stops it between batches, so + // there is never a state to resume from. The message has to hold for both + // modes, and `replace` is the destructive one — it empties the table before + // its first batch, so a cancelled replace leaves neither the old rows nor + // the whole file. + confirm: + 'This stops the import between row batches, so whatever it already wrote stays and nothing resumes it. A replace import empties the table before its first batch, so cancelling one leaves only part of the new file; an append adds its rows again if you import the file a second time.', + }, + cancelTableExport: { + command: 'tables exports cancel', + describe: 'Stop a running export', + // Not `confirm`-gated: the export only reads the table and writes a file + // nobody has yet, so cancelling discards nothing `tables exports create` + // cannot redo. + }, tableExportDownload: { // GET, but it returns a signed URL rather than a listing. command: 'tables exports download', @@ -1347,11 +1406,14 @@ export const CLI_CONTRACT: CliContract = { hidden: true, describe: 'Low-level workflow state and entry-point selection', }, + // Stream-only on the wire, so the requirement is stated where the flag + // is read rather than left to the 400. The dialect differs from the one + // `workflows runs get` takes, which is why both describes name theirs. selectedOutputs: { name: 'select-output', list: true, describe: - 'Return blockName.field values (e.g. agent_1.content); missing fields are omitted', + 'Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted', }, // SSE, not JSON — the generic client cannot consume it, so the response // encoding is chosen by `--follow`, which `workflow-run-follow.ts` adds to @@ -1385,10 +1447,14 @@ export const CLI_CONTRACT: CliContract = { boolean: true, describe: 'Include the final output in JSON or YAML output', }, + // A finished run is read back without loading the workflow, so the + // recorded block ids are all there is to match against — the block names + // `workflows run --select-output` accepts are rejected here. selectedOutputs: { name: 'select-output', list: true, - describe: 'Include blockName.field values in JSON or YAML output (e.g. agent_1.content)', + describe: + 'Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run', }, }, fields: [ @@ -1426,8 +1492,15 @@ export const CLI_CONTRACT: CliContract = { command: 'workflows runs cancel', pathFlags: WORKFLOW_RUN_SCOPE, describe: 'Cancel a running workflow run', - // Not `confirm`-gated: cancelling is recoverable (re-run it), and the - // whole point is to stop something that is already going wrong. + // Not `confirm`-gated: the whole point is to stop something that is + // already going wrong, and for an ordinary in-flight run `re-run it` is a + // real recovery. It is NOT one for a run paused for input — cancelling + // flips the paused row to `cancelled`, and nothing resumes from that + // status, so the snapshot is kept but `workflows runs resume` can never + // take it again and starting over repeats every side effect the run + // already performed. Gating it is a live proposal rather than an + // oversight; it is left ungated here because `confirm` is all-or-nothing + // and cancel is the command most likely to be automated. }, resumeWorkflow: { command: 'workflows runs resume', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 93e1a355f34..901aa87a107 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -152,6 +152,20 @@ export interface ColumnSpec { header: string /** Dot path into the row. Defaults to `header`. */ path?: string + /** + * Narrowest this column may lock to when a renderer fixes its widths before + * it has seen the rows. + * + * `logs list` sizes every column from the page it is about to print, so it + * never needs this. A follow cannot: it locks the widths on its first batch so + * the stream reads as one table, and `logs follow -n 0` locks them on no rows + * at all — every column collapsed to its header label, and a run id printed as + * `9f…`. The floor is what the column's own rendering is known to need (a + * timestamp is 19 characters, a run id 36), so it is stated here beside the + * `format` that produces it rather than guessed by the renderer. Capped by the + * renderer's own maximum cell width; a floor above that is a spec bug. + */ + minWidth?: number /** * Rendering hint; `auto` inspects the value. * @@ -254,8 +268,9 @@ export interface CommandSpec { * key the whole workspace ledger — and the response says which. The value * belongs to the query rather than to any row, so it is not a column; it goes * to stderr so that a `--output text` consumer cutting tab-separated fields - * still reads only rows. The machine formats print the envelope whole and - * carry it already. + * still reads only rows. `json` and `yaml` print the unwrapped `data` array + * and so drop the field too, which is why the note is not limited to the + * human formats — see `runtime/result`. */ pageNote?: { path: string; label: string } /** Allow an optional workspaceId field to omit the configured workspace filter. */ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 2a5dd8f52b9..ff357faaa41 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1599,7 +1599,7 @@ export type CreateKnowledgeDocumentUploadBody = { tag6?: string tag7?: string processingOptions?: { - recipe?: string + recipe?: 'default' | 'plain' | 'markdown' | 'code' lang?: string } } @@ -7650,7 +7650,7 @@ export type SetSecretQuery = Record export type SetSecretBody = { workspaceId: string scope: 'workspace' | 'personal' - value: string + value?: string description?: string | null unredacted?: boolean } @@ -8940,6 +8940,9 @@ export type UpsertTableRowResponse = { * * `summary` is the operation's one-line description, lifted from the OpenAPI * specs so `--help` reuses prose that is already written and already checked. + * + * `personalKeyOnly` marks an operation whose spec description says a workspace + * API key is rejected, so `--help` can say so before the request is sent. */ export const V2_OPERATIONS = { abortFileUpload: { @@ -8999,6 +9002,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Activate Workflow Version', + personalKeyOnly: true, }, addTableColumn: { method: 'POST', @@ -9045,6 +9049,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Index Workspace Files', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9066,6 +9071,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Apply Workflow Operations', + personalKeyOnly: true, query: { dryRun: { kind: 'boolean', @@ -9171,6 +9177,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Bulk Save Tag Definitions', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9194,6 +9201,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Bulk Update Chunks', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9209,7 +9217,8 @@ export const V2_OPERATIONS = { chunkIds: { kind: 'array', required: true, - describe: 'Chunks to operate on, by identifier. Ids outside the document are ignored.', + describe: + 'Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request.', }, }, }, @@ -9220,6 +9229,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Bulk Enable or Disable Documents', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9446,6 +9456,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Credential Connection', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9623,6 +9634,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Create Chunk', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9648,6 +9660,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Create Knowledge Connector', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9763,6 +9776,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Create Tag', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9880,6 +9894,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Service-Account Credential', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9919,6 +9934,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Skill', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -10137,6 +10153,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Workflow MCP Server', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -10168,6 +10185,7 @@ export const V2_OPERATIONS = { pathParamDocs: { credentialId: 'Credential to disconnect.' }, responseMode: 'json', summary: 'Disconnect Credential', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10259,6 +10277,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Chunk', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10277,6 +10296,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Knowledge Connector', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10348,6 +10368,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Tag', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10363,6 +10384,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Delete Tag Definitions', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10395,9 +10417,10 @@ export const V2_OPERATIONS = { method: 'DELETE', path: '/api/v2/secrets/[name]', pathParams: ['name'] as const, - pathParamDocs: { name: 'Secret to create, replace, or delete.' }, + pathParamDocs: { name: 'Secret to delete.' }, responseMode: 'json', summary: 'Delete Secret', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10424,6 +10447,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Skill', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, }, @@ -10537,6 +10561,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Delete Workflow Chat Deployment', + personalKeyOnly: true, }, deleteWorkflowFolder: { method: 'DELETE', @@ -10588,6 +10613,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Delete Workflow MCP Server', + personalKeyOnly: true, }, deployWorkflow: { method: 'POST', @@ -10596,6 +10622,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Deploy Workflow', + personalKeyOnly: true, body: { name: { kind: 'string', describe: 'Optional label for the deployment version.' }, description: { @@ -10611,6 +10638,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Publish Workflow As MCP Tool', + personalKeyOnly: true, body: { workflowId: { kind: 'string', @@ -10763,6 +10791,7 @@ export const V2_OPERATIONS = { pathParamDocs: { auditLogId: 'Audit-log entry identifier.' }, responseMode: 'json', summary: 'Get Audit Log', + personalKeyOnly: true, query: { organizationId: { kind: 'string', @@ -10833,7 +10862,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 `POST /files/{fileId}/restore`. Authorization is identical for both.', + '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.', }, }, }, @@ -10896,6 +10925,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Get Chunk', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10914,6 +10944,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Get Knowledge Connector', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -11027,6 +11058,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Get Next Tag Slot', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -11199,6 +11231,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Get Workflow Chat Deployment', + personalKeyOnly: true, }, getWorkflowDeployment: { method: 'GET', @@ -11215,6 +11248,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Get Workflow MCP Server', + personalKeyOnly: true, }, getWorkflowRun: { method: 'GET', @@ -11285,6 +11319,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Grant Skill Editor', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, email: { @@ -11326,6 +11361,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Audit Logs', + personalKeyOnly: true, query: { action: { kind: 'string', describe: 'Filter by exact action name.' }, resourceType: { @@ -11690,7 +11726,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 `POST /api/v2/files/folders/restore`. Authorization is identical for both.', + '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.', }, }, }, @@ -11735,7 +11771,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 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.', }, search: { kind: 'string', @@ -11831,6 +11867,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'List Chunks', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -11882,6 +11919,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'List Knowledge Connector Documents', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -11912,6 +11950,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'List Knowledge Connectors', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12062,6 +12101,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'List Tag Usage', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12241,6 +12281,7 @@ export const V2_OPERATIONS = { pathParamDocs: { mcpServerId: 'Unique MCP server identifier.' }, responseMode: 'json', summary: 'List MCP Server Tools', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12260,6 +12301,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Secrets', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12467,7 +12509,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 `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', + '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.', }, folderPath: { kind: 'string', @@ -12619,6 +12661,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Workflow MCP Servers', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12658,6 +12701,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'List Workflow MCP Tools', + personalKeyOnly: true, }, listWorkflowRuns: { method: 'GET', @@ -13038,6 +13082,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Create or Replace Workflow Chat Deployment', + personalKeyOnly: true, body: { identifier: { kind: 'string', @@ -13093,6 +13138,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Replace Workflow State', + personalKeyOnly: true, query: { dryRun: { kind: 'boolean', @@ -13148,7 +13194,7 @@ export const V2_OPERATIONS = { kind: 'string', required: true, describe: - 'Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.', + 'Path of the archived folder to restore, as reported by an archived-scope folder list.', }, }, }, @@ -13193,7 +13239,7 @@ export const V2_OPERATIONS = { path: { kind: 'string', required: true, - describe: 'Path the folder held when `DELETE /api/v2/tables/folders` archived it.', + describe: 'Path the folder held when a folder delete archived it.', }, }, }, @@ -13234,6 +13280,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Revert Workflow To Version', + personalKeyOnly: true, }, revokeSkillEditor: { method: 'DELETE', @@ -13245,6 +13292,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Revoke Skill Editor', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, email: { @@ -13261,6 +13309,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Rollback Workflow', + personalKeyOnly: true, body: { version: { kind: 'integer', @@ -13363,9 +13412,10 @@ export const V2_OPERATIONS = { method: 'PUT', path: '/api/v2/secrets/[name]', pathParams: ['name'] as const, - pathParamDocs: { name: 'Secret to create, replace, or delete.' }, + pathParamDocs: { name: 'Secret to create or replace.' }, responseMode: 'json', summary: 'Set Secret', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13382,8 +13432,8 @@ export const V2_OPERATIONS = { }, value: { kind: 'string', - required: true, - describe: 'Write-only secret value. It is never returned.', + describe: + 'Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.', }, description: { kind: 'string', @@ -13407,6 +13457,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Sync Knowledge Connector', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13445,6 +13496,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Undeploy Workflow', + personalKeyOnly: true, }, undeployWorkflowMcpTool: { method: 'DELETE', @@ -13456,6 +13508,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Unpublish Workflow MCP Tool', + personalKeyOnly: true, }, unzipFile: { method: 'POST', @@ -13475,6 +13528,7 @@ export const V2_OPERATIONS = { pathParamDocs: { credentialId: 'Credential to update.' }, responseMode: 'json', summary: 'Update Credential', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -13577,6 +13631,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Chunk', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13604,6 +13659,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Knowledge Connector', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13636,6 +13692,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Knowledge Connector Documents', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13665,6 +13722,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Document', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13710,6 +13768,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Tag', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13824,6 +13883,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Skill', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, name: { kind: 'string', describe: 'New kebab-case skill name.' }, @@ -13963,6 +14023,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Update Workflow MCP Server', + personalKeyOnly: true, body: { name: { kind: 'string', describe: 'Server display name, shown to connecting MCP clients.' }, description: { kind: 'string', describe: 'New server description, or null to clear it.' }, @@ -13979,6 +14040,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Update Workflow Public API Access', + personalKeyOnly: true, body: { isPublicApi: { kind: 'boolean', @@ -14028,6 +14090,7 @@ export const V2_OPERATIONS = { pathParamDocs: { fileId: 'File identifier.' }, responseMode: 'json', summary: 'Enable or Disable File Share', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the file.' }, isActive: { @@ -14067,7 +14130,7 @@ export const V2_OPERATIONS = { kind: 'object', required: true, describe: - 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.', + 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.', }, conflictTarget: { kind: 'string', describe: 'Unique column used to detect a conflict.' }, }, diff --git a/packages/sim-cli/src/program.test.ts b/packages/sim-cli/src/program.test.ts index 56d2153e7ba..b3916507243 100644 --- a/packages/sim-cli/src/program.test.ts +++ b/packages/sim-cli/src/program.test.ts @@ -85,3 +85,50 @@ describe('the root version flag', () => { expect(help).toContain('takes no value') }) }) + +/** + * `sim --help` exited 0 printing the group's help, so a probe + * that reads the exit code to ask "does this command exist?" was told yes. + */ +describe('help typed after a command that does not exist', () => { + it('refuses it inside a group', async () => { + const { out, code } = await parse(['workspaces', 'zzzz', '--help']) + + expect(code).toBe('commander.unknownCommand') + expect(out).not.toContain('Manage workspaces') + }) + + it('refuses a command the group never had', async () => { + const { code } = await parse(['chat-deployments', 'get', '--help']) + + expect(code).toBe('commander.unknownCommand') + }) + + it('refuses it at the root', async () => { + const { code } = await parse(['zzzz', '--help']) + + expect(code).toBe('commander.unknownCommand') + }) + + it('still answers help for a group and for its commands', async () => { + const group = await parse(['workspaces', '--help']) + expect(group.code).toBe('commander.helpDisplayed') + expect(group.out).toContain('Usage: sim workspaces') + + const leaf = await parse(['workspaces', 'get', '--help']) + expect(leaf.code).toBe('commander.helpDisplayed') + expect(leaf.out).toContain('Usage: sim workspaces get') + }) + + /** + * Both exclusions are load-bearing: `files restore` registers a positional + * while hosting a subcommand, and `profiles` acts on its own, so an operand + * there is not an unknown command. + */ + it('leaves a command that legitimately takes an operand alone', async () => { + expect((await parse(['files', 'restore', 'wf_1', '--help'])).code).toBe( + 'commander.helpDisplayed' + ) + expect((await parse(['profiles', 'zzzz', '--help'])).code).toBe('commander.helpDisplayed') + }) +}) diff --git a/packages/sim-cli/src/program.ts b/packages/sim-cli/src/program.ts index d1945b398be..999350d71cd 100644 --- a/packages/sim-cli/src/program.ts +++ b/packages/sim-cli/src/program.ts @@ -5,7 +5,11 @@ import { attachCredentialCommands } from './commands/credentials' import { attachProtocolCommands } from './commands/protocol/index' import { attachSecretCommands } from './commands/secrets' import { OUTPUT_FORMATS } from './config/index' -import { assertNoReservedProgramFlags, buildGeneratedCommands } from './runtime/build' +import { + assertNoReservedProgramFlags, + buildGeneratedCommands, + refuseHelpAfterUnknownCommand, +} from './runtime/build' import { CLI_VERSION } from './version' /** Root program description, shared by `--help` and the generated docs. */ @@ -145,6 +149,7 @@ export function buildProgram(options: { version?: boolean } = {}): Command { program.addHelpText('after', HELP_EPILOGUE) + refuseHelpAfterUnknownCommand(program) assertNoReservedProgramFlags(program) return program diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index cfb5b671bbd..5bfbcc7f0a9 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' import { V2_OPERATIONS } from '../generated/v2-api' +import { SimApiError } from '../http/client' import { buildProgram } from '../program' import { assertNoReservedProgramFlags, buildGeneratedCommands } from './build' import { kebab } from './derive' @@ -19,6 +20,10 @@ import type { OperationSpec } from './types' * catch that class of bug. */ +/** `SimClient.requireWorkspace`'s message, verbatim, for the mocked client. */ +const NO_WORKSPACE_FOR_PROFILE = + 'No workspace set for profile "default". Pass --workspace, or run: sim configure --profile default --set-workspace ' + const { mockRequest, output, profileState } = vi.hoisted(() => ({ mockRequest: vi.fn(), output: { format: 'json' }, @@ -30,7 +35,9 @@ vi.mock('../context', () => ({ client: { request: mockRequest, requireWorkspace: () => { - if (!profileState.workspaceId) throw new Error('workspace required') + // The client's own wording, so a command that skips `requireWorkspace` + // is visible here rather than passing on a placeholder. + if (!profileState.workspaceId) throw new Error(NO_WORKSPACE_FOR_PROFILE) return profileState.workspaceId }, }, @@ -192,6 +199,40 @@ describe('commands parsed through commander', () => { expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) }) + /** + * ~59 v2 operations refuse a workspace API key. The restriction is stated in + * the OpenAPI description, and before the generator carried it into the + * operation table `--help` advertised them identically to their + * workspace-key-capable siblings — the caller met the rule as a `403` only + * after the request had gone out. + */ + describe('a command whose operation refuses a workspace API key', () => { + it('says so in the help line it falls back to from the spec summary', () => { + expect(commandAt('secrets', 'list').helpInformation()).toContain( + '(personal API key required)' + ) + expect(commandAt('mcp-servers', 'tools', 'list').description()).toContain( + '(personal API key required)' + ) + }) + + /** + * The suffix goes after the whole `describe ?? summary ?? METHOD path` + * chain. Folding it into the summary branch would drop it on every command + * carrying a hand-written `describe`, which is most of the restricted ones. + */ + it('says so on a command carrying a hand-written describe', () => { + expect(commandAt('workflows', 'undeploy').description()).toBe( + 'Take a workflow out of deployment (personal API key required)' + ) + }) + + it('leaves a workspace-key-capable sibling unsuffixed', () => { + expect(commandAt('mcp-servers', 'list').description()).not.toContain('personal API key') + expect(commandAt('workflows', 'list').description()).not.toContain('personal API key') + }) + }) + it('describes generated resource and sub-resource groups', () => { expect(commandAt('tables').description()).toBe('Manage tables') expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') @@ -295,7 +336,7 @@ describe('commands parsed through commander', () => { profileState.workspaceId = null const [, unconfiguredAccountOptions] = await run(['billing', 'status', '--all-workspaces']) expect(unconfiguredAccountOptions.query).toEqual({}) - await expect(run(['billing', 'status'])).rejects.toThrow('workspace required') + await expect(run(['billing', 'status'])).rejects.toThrow(NO_WORKSPACE_FOR_PROFILE) profileState.workspaceId = 'ws_local' await expect( run(['--workspace', 'ws_other', 'billing', 'status', '--all-workspaces']) @@ -454,15 +495,26 @@ describe('commands parsed through commander', () => { expect(tablePath).toBe('/api/v2/tables/tbl_1') expect(tableOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) - const [workflowPath, workflowOptions] = await run(['workflow', 'mv', 'wf_1', 'Archive']) - expect(workflowPath).toBe('/api/v2/workflows/wf_1') + const [workflowPath, workflowOptions] = await run([ + 'workflow', + 'mv', + '00000000-0000-4000-8000-00000000000a', + 'Archive', + ]) + expect(workflowPath).toBe('/api/v2/workflows/00000000-0000-4000-8000-00000000000a') expect(workflowOptions.body).toEqual({ folderPath: 'Archive' }) const [knowledgePath, knowledgeOptions] = await run(['kb', 'mv', 'kb_1', 'Archive']) expect(knowledgePath).toBe('/api/v2/knowledge/kb_1') expect(knowledgeOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) - const [, updateOptions] = await run(['workflow', 'update', 'wf_1', '--description', 'Updated']) + const [, updateOptions] = await run([ + 'workflow', + 'update', + '00000000-0000-4000-8000-00000000000a', + '--description', + 'Updated', + ]) expect(updateOptions.body).toEqual({ description: 'Updated' }) const moveHelp = commandAt('workflows', 'mv').helpInformation() @@ -541,10 +593,12 @@ describe('commands parsed through commander', () => { }) expect(workspacePath).toBe('/api/v2/workspaces/ws_local') + // The workspace arrives as a path parameter here, and used to skip + // `requireWorkspace` — so this one precondition had two wordings, and the + // one these two commands printed never named the profile. profileState.workspaceId = null - await expect(run(['workspace', 'get'])).rejects.toThrow( - 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' - ) + await expect(run(['workspace', 'get'])).rejects.toThrow(NO_WORKSPACE_FOR_PROFILE) + await expect(run(['workspace', 'members'])).rejects.toThrow(NO_WORKSPACE_FOR_PROFILE) profileState.workspaceId = 'ws_local' const membersHelp = commandAt('workspaces', 'members').helpInformation() @@ -555,9 +609,38 @@ describe('commands parsed through commander', () => { expect(membersOptions.query).toEqual({ limit: 100, cursor: null }) }) + /** + * The server names its own fields — right for an OpenAPI reader, untypeable + * here: `drop includeJobRuns` names no flag this CLI has. + */ + it('restates a rejected wire field as the flag the caller typed', async () => { + mockRequest.mockReset() + mockRequest.mockRejectedValue( + new SimApiError( + 'sortBy: only "startedAt" can order job runs; drop includeJobRuns or sort by "startedAt"', + 400, + 'BAD_REQUEST', + [{ path: ['sortBy'], message: 'sortBy: drop includeJobRuns' }] + ) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await expect( + program().parseAsync(['node', 'sim', 'logs', 'list', '--include-job-runs']) + ).rejects.toThrow(/drop --include-job-runs/) + }) + it('comma-joins a repeated list flag', async () => { - const [, options] = await run(['logs', 'list', '--workflow', 'wf_1', 'wf_2']) - expect(options.query).toMatchObject({ workflowIds: 'wf_1,wf_2' }) + const [, options] = await run([ + 'logs', + 'list', + '--workflow', + '00000000-0000-4000-8000-00000000000a', + '00000000-0000-4000-8000-00000000000b', + ]) + expect(options.query).toMatchObject({ + workflowIds: '00000000-0000-4000-8000-00000000000a,00000000-0000-4000-8000-00000000000b', + }) }) it('injects the profile workspace without a flag', async () => { @@ -580,11 +663,21 @@ describe('commands parsed through commander', () => { expect(help).toContain('agent_1.content') expect(help).not.toContain('--output ') - const [, withoutInput] = await run(['workflows', 'run', 'wf_1'], { data: { success: true } }) + const [, withoutInput] = await run( + ['workflows', 'run', '00000000-0000-4000-8000-00000000000a'], + { data: { success: true } } + ) expect(withoutInput.body).toEqual({}) const [, selected] = await run( - ['workflows', 'run', 'wf_1', '--select-output', 'agent.answer', 'save.result'], + [ + 'workflows', + 'run', + '00000000-0000-4000-8000-00000000000a', + '--select-output', + 'agent.answer', + 'save.result', + ], { data: { success: true } } ) expect(selected.body).toEqual({ selectedOutputs: ['agent.answer', 'save.result'] }) @@ -638,6 +731,9 @@ describe('commands parsed through commander', () => { const help = commandAt('files', 'move').helpInformation() expect(help).toContain('--file-ids ') expect(help).toMatch(/space-separated.*@path.*one\s+value\s+per\s+line/s) + // The escape a value that genuinely starts with `@` needs, said where the + // caller reads before typing rather than only after the read fails. + expect(help).toContain('@@') }) it('advertises the file-content encoding choices', () => { @@ -680,23 +776,38 @@ describe('commands parsed through commander', () => { 'get', 'run_1', '--workflow', - 'wf_1', + '00000000-0000-4000-8000-00000000000a', '--include-output', '--select-output', 'agent.content', 'writer.text', ]) - expect(path).toBe('/api/v2/workflows/wf_1/runs/run_1') + expect(path).toBe('/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs/run_1') expect(options.query).toEqual({ includeOutput: true, selectedOutputs: 'agent.content,writer.text', }) - const [listPath] = await run(['workflows', 'runs', 'list', '--workflow', 'wf_1']) - expect(listPath).toBe('/api/v2/workflows/wf_1/runs') + const [listPath] = await run([ + 'workflows', + 'runs', + 'list', + '--workflow', + '00000000-0000-4000-8000-00000000000a', + ]) + expect(listPath).toBe('/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs') - const [cancelPath] = await run(['workflows', 'runs', 'cancel', 'run_1', '--workflow', 'wf_1']) - expect(cancelPath).toBe('/api/v2/workflows/wf_1/runs/run_1/cancel') + const [cancelPath] = await run([ + 'workflows', + 'runs', + 'cancel', + 'run_1', + '--workflow', + '00000000-0000-4000-8000-00000000000a', + ]) + expect(cancelPath).toBe( + '/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs/run_1/cancel' + ) const resumeHelp = commandAt('workflows', 'runs', 'resume').helpInformation() expect(resumeHelp).toContain('') @@ -709,13 +820,15 @@ describe('commands parsed through commander', () => { 'resume', 'run_1', '--workflow', - 'wf_1', + '00000000-0000-4000-8000-00000000000a', '--context', 'ctx_1', '--input', '{"approved":true}', ]) - expect(resumePath).toBe('/api/v2/workflows/wf_1/runs/run_1/resume') + expect(resumePath).toBe( + '/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs/run_1/resume' + ) expect(resumeOptions.body).toEqual({ contextId: 'ctx_1', input: { approved: true }, @@ -823,8 +936,12 @@ describe('single-resource rendering', () => { // the record builder kept only scalars, so `workflow` and `state` — the // entire export — vanished with no indication anything was missing. const printed = await lines( - ['workflows', 'get', 'wf_1'], - { id: 'wf_1', name: 'Onboarding', inputs: [{ name: 'email', type: 'string' }] }, + ['workflows', 'get', '00000000-0000-4000-8000-00000000000a'], + { + id: '00000000-0000-4000-8000-00000000000a', + name: 'Onboarding', + inputs: [{ name: 'email', type: 'string' }], + }, 'text' ) @@ -833,9 +950,16 @@ describe('single-resource rendering', () => { }) it('truncates a nested value in the table, and only there', async () => { - const payload = { id: 'wf_1', state: { blocks: 'x'.repeat(5000) } } + const payload = { + id: '00000000-0000-4000-8000-00000000000a', + state: { blocks: 'x'.repeat(5000) }, + } - const table = await lines(['workflows', 'get', 'wf_1'], payload, 'table') + const table = await lines( + ['workflows', 'get', '00000000-0000-4000-8000-00000000000a'], + payload, + 'table' + ) const clamped = table.find((line) => line.startsWith('state')) ?? '' expect(clamped.length).toBeLessThan(300) expect(clamped).toMatch(/…$/) @@ -843,7 +967,11 @@ describe('single-resource rendering', () => { // `text` is the format built for pipes, so it carries the whole value: the // clamp is a legibility cap on the human table, and clamping before the // format branch silently truncated commands whose output is one long value. - const piped = await lines(['workflows', 'get', 'wf_1'], payload, 'text') + const piped = await lines( + ['workflows', 'get', '00000000-0000-4000-8000-00000000000a'], + payload, + 'text' + ) const whole = piped.find((line) => line.startsWith('state')) ?? '' expect(whole).toContain('x'.repeat(5000)) }) @@ -852,15 +980,20 @@ describe('single-resource rendering', () => { // Redirecting this to a file has to yield something `import` accepts, so // `table`/`text` — which flatten and truncate — must not be honoured here. const printed = await lines( - ['workflows', 'export', 'wf_1'], - { version: '1.0', exportedAt: 'now', workflow: { id: 'wf_1' }, state: { blocks: {} } }, + ['workflows', 'export', '00000000-0000-4000-8000-00000000000a'], + { + version: '1.0', + exportedAt: 'now', + workflow: { id: '00000000-0000-4000-8000-00000000000a' }, + state: { blocks: {} }, + }, 'text' ) expect(JSON.parse(printed.join('\n'))).toEqual({ version: '1.0', exportedAt: 'now', - workflow: { id: 'wf_1' }, + workflow: { id: '00000000-0000-4000-8000-00000000000a' }, state: { blocks: {} }, }) }) @@ -1125,6 +1258,44 @@ describe('pagination slot', () => { expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain('fetched 1') }) + /** + * `parseInt` truncated the value before it was checked, so a fractional was + * silently floored and `-0.5` parsed to `-0` — not less than zero, and then + * equal to the `0` that means "everything". Both walked a workspace the + * caller had asked to cap. + */ + it('refuses a limit that is not a whole number, before any request', async () => { + for (const value of ['-0.5', '-0.9', '3.9', '1.5', '', ' ']) { + mockRequest.mockReset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + await expect( + program().parseAsync(['node', 'sim', 'files', 'list', '--limit', value]) + ).rejects.toThrow(/--limit must be a whole number of 0 or more/) + expect(mockRequest).not.toHaveBeenCalled() + } + }) + + it('reads a limit the way the caller wrote it', async () => { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: Array.from({ length: 20 }, (_row, index) => ({ id: `f_${index}` })), + nextCursor: null, + }) + const printed: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + printed.push(line) + }) + + // `parseInt(…, 10)` stopped at the `x` and read 0, which meant everything. + await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', '0x10']) + expect(JSON.parse(printed[0])).toHaveLength(16) + + printed.length = 0 + // And stopped at the `e`, reading a single row where 1000 was asked for. + await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', '1e3']) + expect(JSON.parse(printed[0])).toHaveLength(20) + }) + it('uses a valid per-page size for unlimited and large totals', async () => { for (const requested of ['0', '250']) { mockRequest.mockReset() @@ -1214,19 +1385,25 @@ describe('a body field the contract clears with null', () => { * is as far as the terminal goes, and the word is only ever the word. */ it('sends an empty string as empty and the word null as text, with no companion flag', async () => { - const [, empty] = await run(['workflows', 'update', 'wf_1', '--description', ''], { - data: { id: 'wf_1' }, - }) + const [, empty] = await run( + ['workflows', 'update', '00000000-0000-4000-8000-00000000000a', '--description', ''], + { + data: { id: '00000000-0000-4000-8000-00000000000a' }, + } + ) expect(empty.body).toMatchObject({ description: '' }) - const [, literal] = await run(['workflows', 'update', 'wf_1', '--description', 'null'], { - data: { id: 'wf_1' }, - }) + const [, literal] = await run( + ['workflows', 'update', '00000000-0000-4000-8000-00000000000a', '--description', 'null'], + { + data: { id: '00000000-0000-4000-8000-00000000000a' }, + } + ) expect(literal.body).toMatchObject({ description: 'null' }) - await expect(run(['workflows', 'update', 'wf_1', '--no-description'])).rejects.toThrow( - /unknown option/ - ) + await expect( + run(['workflows', 'update', '00000000-0000-4000-8000-00000000000a', '--no-description']) + ).rejects.toThrow(/unknown option/) }) }) @@ -1342,6 +1519,101 @@ describe('bodies and fields the generator cannot flatten', () => { }) }) + /** + * The pager's flag was handed to any field named `limit`, so a bulk mutation + * that does not paginate carried commander's `100` default into its body: a + * filter matching 250 rows deleted 100, exited 0, and said nothing. + */ + describe('a row cap on a mutation that does not paginate', () => { + const FILTER = '{"all":[{"field":"status","op":"eq","value":"active"}]}' + + it('leaves the cap off the wire entirely when it is not typed', async () => { + const [, omitted] = await run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--filter', + FILTER, + '--yes', + ]) + expect(omitted.body).not.toHaveProperty('limit') + + const [, updated] = await run([ + 'tables', + 'rows', + 'batch-update', + 'tbl_1', + '--filter', + FILTER, + '--data', + '{"status":"done"}', + '--yes', + ]) + expect(updated.body).not.toHaveProperty('limit') + }) + + it('sends the cap the caller typed, unrounded by the pager', async () => { + const [, given] = await run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--filter', + FILTER, + '--limit', + '3', + '--yes', + ]) + expect(given.body).toMatchObject({ limit: 3 }) + }) + + it('documents the cap as the contract states it, without the pager default', () => { + for (const command of ['batch-delete', 'batch-update']) { + const help = commandAt('tables', 'rows', command).helpInformation() + expect(help).toContain('Maximum matching rows to') + expect(help).not.toContain('Maximum items to return') + expect(help).not.toMatch(/--limit[^\n]*default/) + // The help block wraps, so the sentence is matched across the break. + expect(help).toMatch(/caps a --filter\s+match only/) + expect(help).toMatch(/0\s+is not accepted/) + } + }) + + it('refuses a cap typed alongside the id list that supersedes it', async () => { + await expect( + run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--row', + 'row_1', + 'row_2', + '--limit', + '1', + '--yes', + ]) + ).rejects.toThrow(/--limit caps a --filter match .* --row list; pass one, not both/) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('still deletes an explicit id list, with no cap on the wire', async () => { + const [, options] = await run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--row', + 'row_1', + 'row_2', + '--yes', + ]) + expect(options.body).toMatchObject({ rowIds: ['row_1', 'row_2'] }) + expect(options.body).not.toHaveProperty('limit') + }) + }) + it('still gives paginated lists their numeric --limit', async () => { const [, options] = await run(['files', 'list', '--limit', '7']) expect(options.query).toMatchObject({ limit: 7 }) @@ -1462,7 +1734,7 @@ describe('flags the root program would swallow', () => { /** * Commander matches the root's own options anywhere in argv, including after a * subcommand name, so a leaf declaring one never sees it: `sim workflows - * rollback wf_1 --version 1` printed the CLI version and exited 0 without + * rollback 00000000-0000-4000-8000-00000000000a --version 1` printed the CLI version and exited 0 without * issuing a request — a rollback that silently did nothing, with no output to * tell anyone. The generic sweep is the point; the rollback assertion below * only records the one case that got through. @@ -1482,13 +1754,20 @@ describe('flags the root program would swallow', () => { it('exposes the rollback target version under a name of its own', async () => { const [path, init] = await run( - ['workflows', 'rollback', 'wf_1', '--to-version', '3', '--yes'], + [ + 'workflows', + 'rollback', + '00000000-0000-4000-8000-00000000000a', + '--to-version', + '3', + '--yes', + ], { data: {}, } ) - expect(path).toBe('/api/v2/workflows/wf_1/rollback') + expect(path).toBe('/api/v2/workflows/00000000-0000-4000-8000-00000000000a/rollback') expect(init.body).toMatchObject({ version: 3 }) }) }) @@ -1524,21 +1803,29 @@ describe('headers the route contract declares', () => { * operation the assembled tree no longer offers. */ it('sends a declared header when the flag is passed, and omits the slot when it is not', async () => { - const [, sent] = await run(['workflows', 'run', 'wf_1', '--run-id', 'run_mine'], { - data: { status: 'completed' }, - }) + const [, sent] = await run( + ['workflows', 'run', '00000000-0000-4000-8000-00000000000a', '--run-id', 'run_mine'], + { + data: { status: 'completed' }, + } + ) expect(sent.headers).toEqual({ 'x-run-id': 'run_mine' }) - const [, unset] = await run(['workflows', 'run', 'wf_1'], { data: { status: 'completed' } }) + const [, unset] = await run(['workflows', 'run', '00000000-0000-4000-8000-00000000000a'], { + data: { status: 'completed' }, + }) expect(unset.headers).toBeUndefined() }) it('sends no headers for an operation whose contract declares none', async () => { // Paired with an operation that does declare one, so the absence below means // "declares none" rather than "never sends headers". - const [, sent] = await run(['workflows', 'run', 'wf_1', '--run-id', 'run_mine'], { - data: { status: 'completed' }, - }) + const [, sent] = await run( + ['workflows', 'run', '00000000-0000-4000-8000-00000000000a', '--run-id', 'run_mine'], + { + data: { status: 'completed' }, + } + ) expect(sent.headers).toEqual({ 'x-run-id': 'run_mine' }) const [, options] = await run(['tables', 'list']) @@ -1551,7 +1838,16 @@ describe('headers the route contract declares', () => { */ it('does not expose the call-chain header Sim writes for itself', async () => { await expect( - run(['workflows', 'run', 'wf_1', '--x-sim-via', 'wf_0'], { data: { status: 'completed' } }) + run( + [ + 'workflows', + 'run', + '00000000-0000-4000-8000-00000000000a', + '--x-sim-via', + '00000000-0000-4000-8000-000000000000', + ], + { data: { status: 'completed' } } + ) ).rejects.toThrow(/unknown option/) }) @@ -1561,13 +1857,18 @@ describe('headers the route contract declares', () => { * spelled as a raw HTTP header. */ it('exposes the run-id header under a domain name, not its wire spelling', async () => { - const [, options] = await run(['workflows', 'run', 'wf_1', '--run-id', 'run_mine'], { - data: { status: 'completed' }, - }) + const [, options] = await run( + ['workflows', 'run', '00000000-0000-4000-8000-00000000000a', '--run-id', 'run_mine'], + { + data: { status: 'completed' }, + } + ) expect(options.headers).toEqual({ 'x-run-id': 'run_mine' }) await expect( - run(['workflows', 'run', 'wf_1', '--x-run-id', 'run_mine'], { data: { status: 'completed' } }) + run(['workflows', 'run', '00000000-0000-4000-8000-00000000000a', '--x-run-id', 'run_mine'], { + data: { status: 'completed' }, + }) ).rejects.toThrow(/unknown option/) }) @@ -1715,3 +2016,122 @@ describe('the billing ledger a key can see', () => { expect(lines[0].split('\t')[2]).toBe('workflow') }) }) + +describe('a list that is not the whole answer', () => { + /** Captures stderr for one invocation, in one output format. */ + async function noteFor( + format: 'table' | 'text' | 'json', + argv: string[], + response: unknown + ): Promise { + const errors: string[] = [] + const written = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array) => { + errors.push(String(chunk)) + return true + }) + output.format = format + try { + await run(argv, response) + } finally { + output.format = 'json' + written.mockRestore() + } + return errors.join('') + } + + /** + * `sim tools list` answered 100 rows of 4708 with exit 0 and an empty + * stderr, in every format — a clipped inventory that read as the inventory. + */ + it('says so, once, on stderr, in every format', async () => { + for (const format of ['table', 'text', 'json'] as const) { + const note = await noteFor(format, ['tools', 'list', '--limit', '2'], { + data: [{ id: 'a' }, { id: 'b' }], + nextCursor: 'c1', + }) + + expect(note).toContain('more results exist') + expect(note).toContain('--limit 0') + } + }) + + it('says nothing when the list is complete', async () => { + const note = await noteFor('table', ['tools', 'list', '--limit', '2'], { + data: [{ id: 'a' }, { id: 'b' }], + nextCursor: null, + }) + + expect(note).not.toContain('more results exist') + }) + + /** + * The server clips an inventory itself and says so on the envelope, which the + * CLI dropped: `--output json` prints `data` alone, so a reconciling caller + * could not tell a clipped list from a complete one. + */ + it('carries a truncation the server stated on the envelope', async () => { + const paged = await noteFor('json', ['workflow-mcp-servers', 'list'], { + data: [{ id: 'srv_1' }], + nextCursor: null, + toolNamesTruncated: true, + }) + expect(paged).toContain('tool names truncated') + + const unpaged = await noteFor('json', ['workflow-mcp-servers', 'tools', 'list', 'srv_1'], { + data: [{ toolName: 't' }], + nextCursor: null, + truncated: true, + }) + expect(unpaged).toContain('truncated') + }) + + it('says nothing when the server states the list is whole', async () => { + const note = await noteFor('json', ['workflow-mcp-servers', 'list'], { + data: [{ id: 'srv_1' }], + nextCursor: null, + toolNamesTruncated: false, + }) + + expect(note).not.toContain('truncated') + }) + + /** A flag raised on a later page is the same fact, and used to be lost. */ + it('carries a truncation stated on a page after the first', async () => { + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'a' }], nextCursor: 'c1', toolNamesTruncated: false }) + .mockResolvedValueOnce({ data: [{ id: 'b' }], nextCursor: null, toolNamesTruncated: true }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + const errors: string[] = [] + const written = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array) => { + errors.push(String(chunk)) + return true + }) + + try { + await program().parseAsync(['node', 'sim', 'workflow-mcp-servers', 'list', '--limit', '0']) + } finally { + written.mockRestore() + } + + expect(errors.join('')).toContain('tool names truncated') + }) + + it('leaves the rows on stdout exactly as they were', async () => { + const lines: string[] = [] + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [{ id: 'a' }, { id: 'b' }], nextCursor: 'c1' }) + vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + await program().parseAsync(['node', 'sim', 'tools', 'list', '--limit', '2']) + + expect(JSON.parse(lines.join('\n'))).toEqual([{ id: 'a' }, { id: 'b' }]) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 174548e552e..0f778e56347 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -4,6 +4,7 @@ import type { CommandSpec, CommandVariantSpec } from '../contract/types' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { deriveCommandPath } from './derive' import { executeOperation } from './execute' +import { retypeApiError } from './naming' import { addOperationOptions } from './options' import { warnRenamedCommand } from './renamed' import { @@ -127,6 +128,54 @@ export function assertNoReservedProgramFlags(program: Command): void { for (const child of program.commands) walk(child, []) } +/** + * Refuses `--help` typed after a command that does not exist. + * + * Commander answers a help flag before it looks at the operands, so `sim + * workspaces zzzz --help` printed the group's help and exited `0` while the + * same words without the flag exit `1`. A capability probe reading the exit + * code therefore concluded a command exists when it does not. + * + * Only pure dispatchers are guarded. A command that takes arguments or acts on + * its own (`sim files restore `, `sim profiles`) legitimately sees an + * operand it did not register as a subcommand, and refusing there would break + * `--help` on argv the CLI accepts. + */ +export function refuseHelpAfterUnknownCommand(program: Command): void { + const walk = (command: Command): void => { + // Neither the action handler nor `unknownCommand` is in commander's + // typings, for the same reason `rawArgs` is not — reaching for them is what + // keeps this message, its "did you mean" suggestion, and its error code + // identical to the non-help path. + const internals = command as Command & { + _actionHandler?: unknown + unknownCommand: () => never + } + const dispatchesOnly = + command.commands.length > 0 && + !internals._actionHandler && + command.registeredArguments.length === 0 + + if (dispatchesOnly) { + const known = new Set(['help']) + for (const child of command.commands) { + known.add(child.name()) + for (const alias of child.aliases()) known.add(alias) + } + // `beforeHelp` fires inside `outputHelp()`, before a byte is written, and + // by then commander has already assigned the parsed operands. + command.on('beforeHelp', () => { + const first = command.args[0] + if (first === undefined || first.startsWith('-') || known.has(first)) return + internals.unknownCommand() + }) + } + + for (const child of command.commands) walk(child) + } + walk(program) +} + function configureOperation( command: Command, operation: V2OperationName, @@ -213,13 +262,22 @@ function configureOperation( } } - command.description( + // Appended after the whole fallback chain, not inside the summary branch: a + // command with a hand-written `describe` needs the restriction stated just as + // much as one falling back to the spec summary. + const described = spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` + command.description( + operationSpec.personalKeyOnly ? `${described} (personal API key required)` : described ) addOperationOptions(command, operation, spec, operationSpec) assertNoReservedFlags(command, operation) + // The last frame that still knows which operation ran, and so the only one + // that can say `--include-job-runs` where the server said `includeJobRuns`. command.action((...invocation: unknown[]) => - executeOperation(operation, spec, operationSpec, invocation) + executeOperation(operation, spec, operationSpec, invocation).catch((error) => { + throw retypeApiError(error, operation, spec, operationSpec) + }) ) return command } diff --git a/packages/sim-cli/src/runtime/execute.test.ts b/packages/sim-cli/src/runtime/execute.test.ts index 0347b7015f4..8750c52ded4 100644 --- a/packages/sim-cli/src/runtime/execute.test.ts +++ b/packages/sim-cli/src/runtime/execute.test.ts @@ -341,6 +341,98 @@ describe('a bulk call that changed nothing', () => { }) }) +const BULK_UPDATE_CHUNKS: OperationSpec = { + method: 'PATCH', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', + pathParams: ['knowledgeBaseId', 'documentId'], + body: {}, +} + +const ADD_WORKSPACE_FILES: OperationSpec = { + method: 'POST', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files', + pathParams: ['knowledgeBaseId'], + body: {}, +} + +/** + * Two more endpoints that answer `200` having done nothing at all: a chunk + * update where no listed id matched, and an indexing call where every file + * failed. Both printed their own report of the miss and exited `0`, so + * `sim … && next-step` ran on the strength of a no-op. + */ +describe('a bulk call that touched nothing', () => { + function updateChunks(flags: Record) { + const host = new Command('leaf') + return executeOperation('bulkUpdateKnowledgeChunks', {}, BULK_UPDATE_CHUNKS, [ + 'kb_1', + 'doc_1', + flags, + host, + ]) + } + + function indexFiles(flags: Record) { + const host = new Command('leaf') + return executeOperation('addWorkspaceFilesToKnowledgeBase', {}, ADD_WORKSPACE_FILES, [ + 'kb_1', + flags, + host, + ]) + } + + const CHUNK_FLAGS = { operation: 'disable', chunk: ['c1', 'c2'] } + + it('fails the process when no listed chunk matched', async () => { + request.mockResolvedValue({ + data: { + operation: 'disable', + processed: 0, + errors: ['No matching chunks found to disable: c1, c2'], + }, + }) + + await expect(updateChunks(CHUNK_FLAGS)).rejects.toThrow( + /No matching chunks found to disable: c1, c2/ + ) + }) + + it('succeeds on a partial chunk update', async () => { + request.mockResolvedValue({ + data: { operation: 'disable', processed: 1, errors: ['No matching chunks found: c2'] }, + }) + + await expect(updateChunks(CHUNK_FLAGS)).resolves.toBeUndefined() + }) + + it('fails the process when every file failed to index', async () => { + request.mockResolvedValue({ + data: { knowledgeBaseId: 'kb_1', added: [], failed: ['wf_1', 'wf_2'] }, + }) + + await expect(indexFiles({ file: ['wf_1', 'wf_2'] })).rejects.toThrow( + /Indexed nothing: none of the 2 requested files were added\./ + ) + }) + + it('succeeds on a partial index', async () => { + request.mockResolvedValue({ + data: { knowledgeBaseId: 'kb_1', added: [{ documentId: 'd_1' }], failed: ['wf_2'] }, + }) + + await expect(indexFiles({ file: ['wf_1', 'wf_2'] })).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: [] } }) + await expect(indexFiles({ file: ['wf_1'] })).resolves.toBeUndefined() + + request.mockResolvedValue({ data: { operation: 'disable', processed: 0, errors: [] } }) + await expect(updateChunks({ operation: 'disable', chunk: [] })).resolves.toBeUndefined() + }) +}) + /** * Response shapes that report a bulk outcome in the payload rather than in the * status code, and are deliberately left unchecked. @@ -379,10 +471,17 @@ describe('the bulk-outcome check covers every operation shaped like one', () => const shaped = Object.keys(V2_OPERATIONS).filter((operation) => { const declared = responseTypeSource(source, operation) if (/^\s+deletedItems\s*:/m.test(declared)) return true + // Two more spellings of the same shape, both of which the first pass of + // this detector missed: `added`/`failed` (indexing workspace files) and + // `processed`/`errors` (a bulk chunk update). + if (/^\s+processed\s*:/m.test(declared) && /^\s+errors\s*:/m.test(declared)) return true + if (/^\s+added\s*:/m.test(declared) && /^\s+failed\s*:/m.test(declared)) return true return /^\s+moved\s*:/m.test(declared) && /^\s+failed\s*:/m.test(declared) }) - expect(shaped.length).toBeGreaterThan(0) + expect(shaped).toEqual( + expect.arrayContaining(['addWorkspaceFilesToKnowledgeBase', 'bulkUpdateKnowledgeChunks']) + ) for (const operation of shaped) { if (UNCHECKED_BULK_OUTCOMES.has(operation)) continue expect(Object.keys(BULK_OUTCOME_CHECKS)).toContain(operation) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 04da0da62d6..d56bda32502 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -9,11 +9,12 @@ import { DEFAULT_LIMIT } from './options' import { warnRenamedFlag } from './renamed' import { buildRequest, + cursorSlot, flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD, } from './request' -import { renderPage, renderResult } from './result' +import { foldPageEnvelope, renderPage, renderResult } from './result' import type { OperationSpec } from './types' /** @@ -93,6 +94,17 @@ export const BULK_OUTCOME_CHECKS: Readonly { + if (lengthOf(payload.added) > 0) return null + const failed = lengthOf(payload.failed) + if (failed === 0) return null + return `Indexed nothing: none of the ${failed} requested ${failed === 1 ? 'file was' : 'files were'} added.` + }, bulkDeleteTables: (payload) => { const items = payload.deletedItems as { tables?: unknown; folders?: unknown } | undefined const deleted = countOf(items?.tables) + countOf(items?.folders) @@ -101,6 +113,22 @@ export const BULK_OUTCOME_CHECKS: Readonly { + if (countOf(payload.processed) > 0) return null + const requested = lengthOf(body?.chunkIds) + if (requested === 0) return null + const reported = (payload.errors as unknown[] | undefined)?.[0] + return typeof reported === 'string' && reported + ? safeOneLine(reported) + : `Updated nothing: none of the ${requested} requested ${requested === 1 ? 'chunk' : 'chunks'} matched.` + }, moveTables: (payload) => { if (lengthOf(payload.moved) > 0) return null const missed = lengthOf(payload.notFound) + lengthOf(payload.failed) @@ -137,10 +165,34 @@ function bulkFailureMessage( return check(payload as Record, body) } -function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { - if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' - if (operationSpec.body && 'cursor' in operationSpec.body) return 'body' - return null +/** + * Fields that cap a filtered mutation, beside the id list that supersedes them. + * + * `tables rows batch-delete --row a --row b --limit 1` deleted both rows: the + * route drops `limit` outright on the ids branch, so the cap was accepted, + * ignored, and never mentioned again. The refusal is client-side because that + * is where it costs nothing — every already-installed CLI sends `limit: 100` + * alongside `--row`, so a server that started rejecting the pair would break + * them all. + */ +const EXCLUSIVE_CAP_FIELDS: Readonly< + Partial> +> = { + deleteTableRows: { cap: 'limit', ids: 'rowIds' }, +} + +/** 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] + if (!exclusive) return + + const cap = flagNameFor(operation, exclusive.cap) + const ids = flagNameFor(operation, exclusive.ids) + if (flags[camel(cap)] === undefined || flags[camel(ids)] === undefined) return + throw new SimApiError( + `--${cap} caps a --filter match and does nothing to an explicit --${ids} list; pass one, not both`, + 0 + ) } /** @@ -204,6 +256,7 @@ export async function executeOperation( } foldRenamedFlags(operation, commandSpec, requestFlags) + assertCapIsUsable(operation, requestFlags) /** * A dry run writes nothing, so it never needs the destructive confirmation. @@ -229,18 +282,43 @@ export async function executeOperation( (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) ) const omitsWorkspace = commandSpec.allWorkspaces && requestFlags.allWorkspaces === true + /** + * 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 + * parameter, so they skipped `requireWorkspace` and fell into `buildRequest`'s + * own fallback: a second wording for the same precondition, and — because + * `requireWorkspace` checks the key first — advice to set a workspace on an + * install whose actual first step is logging in. + */ + const needsWorkspace = + (hasWorkspaceField || commandSpec.profileWorkspacePath === true) && !omitsWorkspace const request = buildRequest( operation, positional, requestFlags, - hasWorkspaceField && !omitsWorkspace ? client.requireWorkspace() : profile.workspaceId + needsWorkspace ? client.requireWorkspace() : profile.workspaceId ) const paging = cursorSlot(operationSpec) if (paging) { - const rawLimit = Number.parseInt(String(requestFlags.limit ?? DEFAULT_LIMIT), 10) - if (Number.isNaN(rawLimit) || rawLimit < 0) { - throw new SimApiError('--limit must be a non-negative number', 0) + /** + * 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 @@ -265,7 +343,7 @@ export async function executeOperation( ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } : request.body, }) - envelope ??= page + envelope = foldPageEnvelope(envelope, page) rows.push(...page.data) cursor = page.nextCursor if (cursor && rows.length < limit) progress.advance(rows.length) @@ -273,11 +351,15 @@ export async function executeOperation( } finally { progress.finish() } + // A cursor still in hand means the walk stopped at `--limit`, not at the + // end of the list — the one fact that separates a clipped answer from a + // complete one, and it was dropped with the loop variable. renderPage( profile.output, Number.isFinite(limit) ? rows.slice(0, limit) : rows, commandSpec, - envelope + envelope, + { truncated: Boolean(cursor) } ) return } @@ -289,9 +371,16 @@ export async function executeOperation( body: request.body, }) const payload = result?.data ?? result - renderResult(operation, profile.output, payload, commandSpec, { - expandedTrace: requestFlags.trace === true, - }) + renderResult( + operation, + profile.output, + payload, + commandSpec, + { expandedTrace: requestFlags.trace === true }, + // The envelope, not just the payload: a list that does not paginate states + // its own truncation there, and unwrapping `data` discarded it. + result + ) // Printed first, then failed, for the reason `followRun` gives: the envelope // carries the block outputs that explain *why* the run failed, and exiting diff --git a/packages/sim-cli/src/runtime/naming.test.ts b/packages/sim-cli/src/runtime/naming.test.ts new file mode 100644 index 00000000000..05ffb7ec988 --- /dev/null +++ b/packages/sim-cli/src/runtime/naming.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec } from '../contract/types' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { formatApiErrorDetails, SimApiError } from '../http/client' +import { retypeApiError } from './naming' +import type { OperationSpec } from './types' + +function retype(operation: V2OperationName, error: unknown): SimApiError { + return retypeApiError( + error, + operation, + (CLI_CONTRACT[operation] ?? {}) as CommandSpec, + V2_OPERATIONS[operation] as unknown as OperationSpec + ) as SimApiError +} + +function detailLines(operation: V2OperationName, details: unknown): string[] { + return formatApiErrorDetails( + retype(operation, new SimApiError('Invalid request', 400, 'BAD_REQUEST', details)).details + ) +} + +/** + * The server names its own fields, which is right for an OpenAPI reader and + * untypeable in a terminal: `drop includeJobRuns` names no flag the CLI has. + */ +describe('a validation error restated in the spellings a caller can type', () => { + it('names the flag in both the message and the details', () => { + const message = + 'sortBy: only "startedAt" can order job runs; drop includeJobRuns or sort by "startedAt"' + const error = new SimApiError(message, 400, 'BAD_REQUEST', [{ path: ['sortBy'], message }]) + + const retyped = retype('listLogs', error) + expect(retyped.message).toContain('drop --include-job-runs') + expect(retyped.message).toContain('--sort-by') + expect(retyped.message).not.toMatch(/\bincludeJobRuns\b/) + expect(detailLines('listLogs', error)[1]).toContain('--sort-by') + }) + + /** + * The requirement that a mechanical kebab-casing fails: the flag for + * `folderPath` is `--folder`, so translating the wire name by rule would + * print a flag that does not exist. + */ + it('resolves the spelling through the contract, not by kebab-casing', () => { + const line = detailLines('listWorkflows', [ + { path: ['folderPath'], message: 'Path must be a canonical folder path' }, + ])[1] + + expect(line).toContain('--folder:') + expect(line).not.toContain('--folder-path') + expect(line).not.toContain('folderPath') + }) + + it('leaves an English word that happens to be a field name alone', () => { + const message = 'startDate must name a storable instant; there is no year 0000' + const retyped = retype( + 'listLogs', + new SimApiError(message, 400, 'BAD_REQUEST', [{ path: ['startDate'], message }]) + ) + + expect(retyped.message).toBe('--start-date must name a storable instant; there is no year 0000') + expect(retyped.message).not.toContain('--name') + }) + + it('names the global flag the workspace comes from', () => { + expect( + detailLines('listLogs', [{ path: ['workspaceId'], message: 'Workspace is required' }])[1] + ).toContain('--workspace:') + }) + + it('translates only the head of a path into a JSON value the caller wrote', () => { + const line = detailLines('queryRows', [ + { path: ['predicate', 'all', '0', 'op'], message: 'Unsupported operator' }, + ])[1] + + // `predicate` is typed `--filter`, so even the head is not a kebab-cased + // wire name — and only the head is translated. + expect(line).toContain('--filter.all.0.op:') + }) + + it('leaves a CLI-raised error and a non-API throw byte-identical', () => { + const local = new SimApiError('--limit must be a whole number of 0 or more', 0) + expect(retype('listLogs', local)).toBe(local) + + const other = new Error('sortBy is not a flag') + expect( + retypeApiError(other, 'listLogs', {}, V2_OPERATIONS.listLogs as unknown as OperationSpec) + ).toBe(other) + }) +}) diff --git a/packages/sim-cli/src/runtime/naming.ts b/packages/sim-cli/src/runtime/naming.ts new file mode 100644 index 00000000000..b0683c0e132 --- /dev/null +++ b/packages/sim-cli/src/runtime/naming.ts @@ -0,0 +1,140 @@ +import type { CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { SimApiError } from '../http/client' +import { flagNameFor, flagSpecFor, PROFILE_INJECTED_FIELD, pathFlagNameFor } from './request' +import type { OperationSpec } from './types' + +/** + * Identifiers a message quotes from the wire rather than from English. + * + * Genuine multi-segment camelCase — `includeJobRuns`, `startDate` — never + * occurs as prose, so replacing it is safe. A single word is left alone in + * prose: `startDate must name a storable instant` contains the field `name`, + * and substituting it would produce `must --name a storable instant`. The + * structured `details:` column still translates single-word fields, because + * there position identifies them rather than the surrounding sentence. + */ +const WIRE_IDENTIFIER = /^[a-z]+[A-Z]/ + +/** + * The spelling a caller types for a wire field, or `null` when there is none. + * + * Resolved through the same helpers the command builder uses, never by + * kebab-casing the wire name: `folderPath` is typed `--folder` and + * `knowledgeBaseIds` is typed `--kb`, so a mechanical translation would name + * flags that do not exist — strictly worse than leaving the wire name alone. + */ +export function spellingFor( + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec, + field: string +): string | null { + if (field === PROFILE_INJECTED_FIELD) return '--workspace' + if (field === 'cursor') return null + + if (operationSpec.pathParams.includes(field)) { + return commandSpec.pathFlags?.[field] + ? `--${pathFlagNameFor(commandSpec, field)}` + : `<${commandSpec.pathArgumentNames?.[field] ?? field}>` + } + + if (commandSpec.positionals?.includes(field)) return `<${flagNameFor(operation, field)}>` + if (flagSpecFor(operation, field).omit) return null + if (commandSpec.requestFields && !commandSpec.requestFields.includes(field)) return null + + const declared = + (operationSpec.query && field in operationSpec.query) || + (operationSpec.body && field in operationSpec.body) || + (operationSpec.headers && field in operationSpec.headers) + if (!declared) return null + + return `--${flagNameFor(operation, field)}` +} + +/** Every field of this operation a caller can type, keyed by its wire name. */ +function typeableFields( + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec +): Map { + const spellings = new Map() + const fields = [ + ...operationSpec.pathParams, + ...Object.keys(operationSpec.query ?? {}), + ...Object.keys(operationSpec.body ?? {}), + ...Object.keys(operationSpec.headers ?? {}), + ] + for (const field of fields) { + if (spellings.has(field)) continue + const spelling = spellingFor(operation, commandSpec, operationSpec, field) + if (spelling) spellings.set(field, spelling) + } + return spellings +} + +/** Rewrites wire names a message quotes into the flags the caller typed. */ +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) + } + return retyped +} + +/** Rewrites the head of one issue path, leaving keys inside a caller's JSON alone. */ +function retypeDetails(details: unknown, spellings: Map): unknown { + if (Array.isArray(details)) return details.map((issue) => retypeDetails(issue, spellings)) + if (!details || typeof details !== 'object') return details + + const issue = details as Record + const retyped: Record = { ...issue } + + if (Array.isArray(issue.path) && issue.path.length > 0) { + const [head, ...rest] = issue.path.map(String) + const spelling = spellings.get(head) + // Only the head names a field the caller typed; the rest address keys + // inside a JSON value they wrote themselves. + if (spelling) retyped.path = [spelling, ...rest] + } + if (typeof issue.message === 'string') { + retyped.message = retypeMessage(issue.message, spellings) + } + if (Array.isArray(issue.errors)) { + retyped.errors = retypeDetails(issue.errors, spellings) + } + + return retyped +} + +/** + * Restates a server validation error in the spellings the terminal accepts. + * + * The API names its own fields, correctly — `drop includeJobRuns` is right for + * an OpenAPI reader and untypeable here, where the flag is + * `--include-job-runs`. Applied at the one frame that still holds the + * operation, its command spec and its operation spec; by the time the error + * reaches the entrypoint that context is gone. + * + * A CLI-raised error (`status: 0`) is already phrased in flags and passes + * through untouched, as does anything that is not a `SimApiError`. + */ +export function retypeApiError( + error: unknown, + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec +): unknown { + if (!(error instanceof SimApiError) || error.status === 0) return error + + const spellings = typeableFields(operation, commandSpec, operationSpec) + if (spellings.size === 0) return error + + return new SimApiError( + retypeMessage(error.message, spellings), + error.status, + error.code, + error.details === undefined ? undefined : retypeDetails(error.details, spellings) + ) +} diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index d1da7024017..7c87b2c9733 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -2,6 +2,7 @@ import { type Command, Option } from 'commander' import type { CommandSpec } from '../contract/types' import type { V2OperationName } from '../generated/v2-api' import { + cursorSlot, type FieldSpec, flagNameFor, flagSpecFor, @@ -69,12 +70,24 @@ function withoutWireVocabulary(documented: string): string { return documented.replace(WIRE_VOCABULARY_SENTENCE, ' ').trim() } +/** + * What `--limit` means on an operation that does not paginate. + * + * The pager's `0 for everything` spelling is false here: these routes bound the + * field at `1`, and the unbounded form is the flag left off entirely. Said in + * `--help` because nothing else in the terminal says it — the refusal only + * arrives from the server, after the caller has already typed the command. + */ +const NON_PAGINATED_LIMIT_HINT = + ' (caps a --filter match only; omit it to act on every match, and note 0 is not accepted)' + function addFieldOption( command: Command, operation: V2OperationName, field: string, descriptor: FieldSpec, - slot: 'query' | 'body' | 'headers' + slot: 'query' | 'body' | 'headers', + paginates: boolean ): void { if (field === PROFILE_INJECTED_FIELD || field === 'cursor') return @@ -84,7 +97,15 @@ function addFieldOption( const name = flagNameFor(operation, field) const short = flag.short ? `-${flag.short}, ` : '' - if (field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) { + // Gated on the operation actually paginating, not on the field's name: a + // `limit` on a non-paginating operation is a row cap the wire reads + // literally, and commander's `100` default rode into the body of every + // filtered `tables rows batch-delete` as a silent ceiling on what it deleted. + if ( + paginates && + field === 'limit' && + (descriptor.kind === 'number' || descriptor.kind === 'integer') + ) { command.option( '--limit ', 'Maximum items to return (0 for everything)', @@ -93,7 +114,11 @@ function addFieldOption( return } - const documented = describeField(flag, descriptor, name, field) + const documented = `${describeField(flag, descriptor, name, field)}${ + field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer') + ? NON_PAGINATED_LIMIT_HINT + : '' + }` if (descriptor.kind === 'boolean' || flag.boolean) { const booleanDoc = withoutWireVocabulary(documented) @@ -134,7 +159,7 @@ function addFieldOption( const literalNull = slot === 'body' && !takesList && !wantsJson const describe = `${documented}${ takesList - ? ' (space-separated, or @path / @- with one value per line)' + ? ' (space-separated, or @path / @- with one value per line; @@value for a literal leading @)' : wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' : '' @@ -183,11 +208,12 @@ export function addOperationOptions( ) } + const paginates = cursorSlot(operationSpec) !== null for (const slot of ['query', 'body', 'headers'] as const) { for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { if (commandSpec.requestFields && !commandSpec.requestFields.includes(field)) continue if (commandSpec.positionals?.includes(field)) continue - addFieldOption(command, operation, field, descriptor, slot) + addFieldOption(command, operation, field, descriptor, slot, paginates) } } diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index f7a071f3a1d..9808aff4498 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -114,6 +114,25 @@ describe('buildRequest', () => { }) }) + it('still sends an explicit zero, which is a value the caller chose', () => { + expect(buildRequest('listLogs', [], { minCost: '0' }, WORKSPACE).query).toMatchObject({ + minCost: 0, + }) + expect( + buildRequest('readFileText', ['wf_1'], { maxBytes: '0' }, WORKSPACE).query + ).toMatchObject({ maxBytes: 0 }) + }) + + it('still sends an empty body string, which is how a description is cleared', () => { + expect(buildRequest('updateWorkflow', ['wf_1'], { description: '' }, WORKSPACE).body).toEqual({ + description: '', + }) + // Blank-scoped to the query on both spellings: a body string is the value. + expect(buildRequest('updateWorkflow', ['wf_1'], { description: ' ' }, WORKSPACE).body).toEqual({ + description: ' ', + }) + }) + describe('failures, all before any network call', () => { it('rejects a missing path arg', () => { expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') @@ -125,6 +144,77 @@ describe('buildRequest', () => { ) }) + /** + * The URL builder skips an empty value, so a blank filter was not sent and + * not refused either — `logs list --status ""` came back unfiltered while + * `--workflow ""` (a list flag) had always been an error. + */ + it('rejects an empty query filter the way it rejects an empty list entry', () => { + expect(() => buildRequest('listLogs', [], { status: '' }, WORKSPACE)).toThrow( + '--status cannot be empty' + ) + expect(() => buildRequest('listLogs', [], { workflowName: '' }, WORKSPACE)).toThrow( + '--workflow-name cannot be empty' + ) + }) + + /** + * `Number('')` is `0`, so a blank numeric filter coerced into a real one: + * `--max-cost ""` asked for runs costing at most nothing and answered `0` + * rows, the same silent-wrong-result the blank-string refusal exists to + * remove. + */ + it('rejects a blank numeric query filter, which coercion would read as 0', () => { + expect(() => buildRequest('listLogs', [], { maxCost: '' }, WORKSPACE)).toThrow( + '--max-cost cannot be empty' + ) + expect(() => buildRequest('listLogs', [], { minDurationMs: '' }, WORKSPACE)).toThrow( + '--min-duration-ms cannot be empty' + ) + expect(() => buildRequest('readFileText', ['wf_1'], { maxBytes: '' }, WORKSPACE)).toThrow( + '--max-bytes cannot be empty' + ) + }) + + /** + * A quoted space is invisible in a shell and reached the wire as every + * blank the empty string did — `--max-cost " "` as a real `0` ceiling, + * `--deployed-only " "` as an explicit `false`, `--status " "` as the + * `%20` the route reads as blank and answers `400`. + */ + it('rejects a whitespace-only query filter, which is blank on the wire too', () => { + expect(() => buildRequest('listLogs', [], { status: ' ' }, WORKSPACE)).toThrow( + '--status cannot be empty' + ) + expect(() => buildRequest('listLogs', [], { maxCost: ' ' }, WORKSPACE)).toThrow( + '--max-cost cannot be empty' + ) + expect(() => buildRequest('listLogs', [], { minDurationMs: '\t' }, WORKSPACE)).toThrow( + '--min-duration-ms cannot be empty' + ) + expect(() => buildRequest('listWorkflows', [], { deployedOnly: ' ' }, WORKSPACE)).toThrow( + '--deployed-only cannot be empty' + ) + }) + + /** Only an all-whitespace value is blank; the surrounding spaces are the caller's. */ + it('still sends a query value that has content around its whitespace', () => { + expect(buildRequest('listLogs', [], { workflowName: ' q3 ' }, WORKSPACE).query).toMatchObject( + { workflowName: ' q3 ' } + ) + }) + + /** + * A paginating `limit` is the walk size, not a filter, and the pager reads + * it from the flags itself — refusing a blank one in wording that says what + * `0` means there. Left to it rather than pre-empted with a generic + * refusal, whitespace included: the pager trims before it decides. + */ + it('leaves a blank paginating limit to the pager, which words it better', () => { + expect(() => buildRequest('listWorkflows', [], { limit: '' }, WORKSPACE)).not.toThrow() + expect(() => buildRequest('listWorkflows', [], { limit: ' ' }, WORKSPACE)).not.toThrow() + }) + it('rejects a missing required flag', () => { expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( '--data is required' @@ -241,6 +331,17 @@ describe('repeated flags encode per the field kind, not uniformly', () => { ) }) + /** + * `--allowed-emails @example.org` is the natural spelling of a domain + * pattern, and the `@` convention reads it as a file. The escape existed; the + * failure never mentioned it. + */ + it('points at @@ when an @ list value names no file', () => { + expect(() => + coerce(['@example.org'], { kind: 'array' }, { list: true }, 'allowed-emails') + ).toThrow(/cannot read example\.org.*write @@example\.org/s) + }) + it('rejects empty lines in a list file', () => { const path = join(tmpdir(), 'sim-cli-list-empty-line.txt') writeFileSync(path, 'file_1\n\nfile_2') diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index a542693b4c1..d3d611a54f9 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -4,6 +4,7 @@ import type { CommandSpec, FlagSpec } from '../contract/types' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { type QueryValue, SimApiError } from '../http/client' import { camel, kebab } from './derive' +import type { OperationSpec } from './types' /** One request field, as the generator describes it. */ export interface FieldSpec { @@ -29,6 +30,23 @@ export function isProfileWorkspacePath(commandSpec: CommandSpec, param: string): return commandSpec.profileWorkspacePath === true && param === PROFILE_INJECTED_FIELD } +/** + * The slot a cursor-paginated operation carries its `cursor` field in. + * + * Pagination, not the name of a field, is what makes `limit` a page size. It + * lives here rather than beside its reader in `execute.ts` because `options.ts` + * has to ask the same question while it builds the flag, and importing + * `execute.ts` from `options.ts` would close a module cycle — `execute.ts` + * already reads `DEFAULT_LIMIT` from `options.ts`. + */ +export function cursorSlot( + operationSpec: Pick +): 'query' | 'body' | null { + if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' + if (operationSpec.body && 'cursor' in operationSpec.body) return 'body' + return null +} + /** Kinds the CLI can only accept as a JSON string. */ const JSON_KINDS = new Set(['object', 'array', 'unknown']) @@ -162,6 +180,21 @@ function readStdin(): string { * at all, because it can only be read as a request to open a file named * `urgent`. */ +/** + * Points at `@@` when an `@value` names nothing on disk. + * + * `--allowed-emails @example.org` is the natural spelling of a domain pattern + * and reads here as a request to open a file, and "cannot read example.org" + * alone gives no clue the value has a literal spelling at all. Gated on ENOENT + * so a real file that cannot be read (EACCES, EISDIR) is not answered with + * advice about escaping. + */ +function literalAtHint(error: unknown, path: string): string { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT' + ? `. To pass the literal value @${path}, write @@${path}` + : '' +} + export function readArgumentSource(raw: string, flagName: string): { text: string; from: string } { if (raw.startsWith('@@')) return { text: raw.slice(1), from: '' } if (!raw.startsWith('@')) return { text: raw, from: '' } @@ -181,7 +214,10 @@ export function readArgumentSource(raw: string, flagName: string): { text: strin try { return { text: readFileSync(path, 'utf8'), from: ` (read from ${path})` } } catch (error) { - throw new SimApiError(`--${flagName} cannot read ${path}: ${(error as Error).message}`, 0) + throw new SimApiError( + `--${flagName} cannot read ${path}: ${(error as Error).message}${literalAtHint(error, path)}`, + 0 + ) } } @@ -357,6 +393,16 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: return raw } +/** + * The workspace precondition as stated when the profile is not at hand. + * + * `executeOperation` resolves the workspace through `SimClient.requireWorkspace` + * first, which names the profile and checks the API key, so this is a defensive + * floor rather than the wording a caller sees. + */ +const NO_WORKSPACE_FALLBACK = + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + export interface BuiltRequest { path: string query: Record @@ -417,10 +463,7 @@ export function buildRequest( : positional[positionalIndex++] if (value === undefined || value === null) { if (profileWorkspacePath) { - throw new SimApiError( - 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ', - 0 - ) + throw new SimApiError(NO_WORKSPACE_FALLBACK, 0) } throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${argumentName}>`, 0) } @@ -438,6 +481,14 @@ export function buildRequest( const body: Record = {} const headers: Record = {} + /** + * On a paginating operation `limit` is the walk size rather than a filter, + * and the pager reads it from the flags itself — including refusing a blank + * one, in wording that says what `0` means there. Left to it, so the caller + * gets that message instead of the generic refusal below. + */ + const paginatedLimit = cursorSlot(spec) !== null + for (const slot of ['query', 'body', 'headers'] as const) { for (const [field, descriptor] of Object.entries(spec[slot] ?? {})) { const flag = flagSpecFor(operation, field) @@ -457,14 +508,44 @@ export function buildRequest( // typing the flag — including typing the server's own default back — still // decides. It is validated like any other value, enum choices included. const raw = provided ?? flag.requestDefault + + /** + * A blank filter is a mistake, and every v2 JSON route says so + * (`rejectBlankQueryValues`). The CLI never let one reach the wire: the + * URL builder skips an empty value, so `logs list --status ""` searched + * everything and answered `0`, a wider result set presented as an answer. + * Refused here, before the request, the way an empty list entry and an + * empty path parameter already are. Scoped to the query, because an empty + * body string is meaningful — it clears a description. + * + * Read from what the caller typed rather than from the coerced value, + * because coercion erases the blank on a numeric field: `Number('')` is + * `0`, so `--max-cost ""` reached the wire as a real "costing at most + * nothing" filter that a check on the coerced value cannot see. An + * explicit `--max-cost 0` is a value the caller chose and is still sent. + * + * Blank is `trim()`-empty rather than exactly empty, matching both the + * route — `blankQueryValueValidationError` reads `?status=%20` as blank — + * and the list flag beside it, which trims each entry before refusing it. + * A quoted space is invisible in a shell and read as every blank the + * empty string did: `--max-cost " "` as `0`, `--deployed-only " "` as an + * explicit `false`, `--status " "` as a `%20` the server answers `400`. + */ + if ( + slot === 'query' && + typeof raw === 'string' && + raw.trim() === '' && + !(field === 'limit' && paginatedLimit) + ) { + throw new SimApiError(`--${flagName} cannot be empty`, 0) + } + const value = coerce(raw ?? undefined, descriptor, flag, flagName) if (value === undefined) { if (descriptor.required) { throw new SimApiError( - field === PROFILE_INJECTED_FIELD - ? 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' - : `--${flagName} is required`, + field === PROFILE_INJECTED_FIELD ? NO_WORKSPACE_FALLBACK : `--${flagName} is required`, 0 ) } diff --git a/packages/sim-cli/src/runtime/result.test.ts b/packages/sim-cli/src/runtime/result.test.ts index 8a72471e1cc..d9e9412c084 100644 --- a/packages/sim-cli/src/runtime/result.test.ts +++ b/packages/sim-cli/src/runtime/result.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CLI_CONTRACT } from '../contract/commands' import type { CommandSpec } from '../contract/types' -import { renderPage, renderResult } from './result' +import { encodeFolderPath } from './request' +import { decodeFolderPath, renderPage, renderResult } from './result' let logged: string[] @@ -244,8 +245,174 @@ describe('folder paths are shown by name, but piped in wire form', () => { expect(logged).toContain(`web URL\t${webUrl}`) }) + /** + * `%2F` is the one escape that must survive display: decoding it prints a + * root folder named `a/enc` exactly like a folder `enc` nested under `a`, and + * the path people paste back then resolves to the other folder. + */ + describe('a folder whose own name contains the separator', () => { + const slashNamed = [ + { + path: '/cli-test-a%2Fenc', + name: 'cli-test-a/enc', + parentPath: '/', + updatedAt: '2026-08-17T20:35:38.478Z', + }, + ] + const nested = [ + { + path: '/cli-test-a/enc', + name: 'enc', + parentPath: '/cli-test-a', + updatedAt: '2026-08-17T20:35:38.478Z', + }, + ] + + it('keeps it distinguishable from a genuinely nested folder in the table', () => { + renderPage('table', slashNamed, spec) + const [, slashRow] = tableLines() + logged = [] + renderPage('table', nested, spec) + const [, nestedRow] = tableLines() + + expect(slashRow).toContain('%2F') + expect(slashRow.split(/\s{2,}/)[0]).not.toBe(nestedRow.split(/\s{2,}/)[0]) + }) + + it('prints the wire form in text, which is what a script pipes back', () => { + renderPage('text', slashNamed, spec) + expect(logged[0].split('\t')[0]).toBe('/cli-test-a%2Fenc') + }) + + it('survives a round trip back through the encoder', () => { + expect(encodeFolderPath(decodeFolderPath('/cli-test-a%2Fenc'))).toBe('/cli-test-a%2Fenc') + }) + }) + it('shows an undecodable path as it arrived rather than dropping it', () => { renderPage('text', [{ path: '/100%zz', name: 'x', parentPath: '/', updatedAt: null }], spec) expect(logged[0].split('\t')[0]).toBe('/100%zz') }) }) + +describe('a truncation note', () => { + /** The note is stderr in every format; stdout stays exactly the rows. */ + it('leaves the machine formats a bare array', () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + renderPage('json', [{ id: 'a' }], {}, { truncated: true }, { truncated: true }) + + expect(JSON.parse(logged.join('\n'))).toEqual([{ id: 'a' }]) + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain( + 'more results exist' + ) + }) +}) + +describe('a truncation the response states inside its payload', () => { + /** Every note goes to stderr; stdout is asserted to be untouched by it. */ + function captureStderr(): () => string { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + return () => stderr.mock.calls.map(([chunk]) => String(chunk)).join('') + } + + it('reports a clipped file body, which the envelope says nothing about', () => { + const read = captureStderr() + const payload = { fileId: 'wf_probe', name: 'a.txt', text: 'abc', truncated: true } + + renderResult('readFileText', 'json', payload, {}, {}, { data: payload }) + + expect(read()).toContain('the server clipped this result') + expect(JSON.parse(logged.join('\n'))).toEqual(payload) + }) + + it('reports a clipped row search', () => { + const read = captureStderr() + const payload = { matches: [{ ordinal: 1, rowId: 'row_1', column: 'name' }], truncated: true } + + renderResult('searchTableRows', 'json', payload, {}, {}, { data: payload }) + + expect(read()).toContain('the server clipped this result') + }) + + it('names the tool names, not the servers, on the server list', () => { + const read = captureStderr() + + renderPage('json', [{ id: 'srv_1' }], {}, { data: [], toolNamesTruncated: true }) + + expect(read()).toContain('the server clipped the tool names it returned') + }) + + it('says nothing when a negated flag reports the answer was whole', () => { + const read = captureStderr() + + renderResult('readFileText', 'json', {}, {}, {}, { data: { notTruncated: true } }) + + expect(read()).toBe('') + }) + + it.each(['notTruncated', 'unTruncated', 'nonTruncated', 'neverTruncated', 'isNotTruncated'])( + 'says nothing for the negated spelling %s', + (flag) => { + const read = captureStderr() + + renderResult('readFileText', 'json', {}, {}, {}, { data: { [flag]: true } }) + + expect(read()).toBe('') + } + ) + + /** + * The flag pattern, not the negation veto, is what rejects this: `was not + * truncated` carries neither the `Not`/`Un` casing the veto looks for nor the + * unbroken `Truncated` shape the pattern demands. Asserted separately + * so loosening the pattern to a bare `truncated` substring goes red here + * rather than silently leaning on the veto to cover it. + */ + it('says nothing for a key that only resembles the flag', () => { + const read = captureStderr() + + renderResult('readFileText', 'json', {}, {}, {}, { data: { 'was not truncated': true } }) + + expect(read()).toBe('') + }) + + /** + * The bound the scan is built on: `data` is descended only while it is an + * object, because a page's `data` is the rows and a row's keys are the + * caller's own. A column literally named `truncated` is a value in somebody's + * table, not a statement by the API, and reading it as one would warn about a + * clip that never happened on every row that ever holds `true`. + */ + it('ignores a row column named like the flag, because rows are not the envelope', () => { + const read = captureStderr() + const rows = [{ id: 'a', truncated: true }] + + renderPage('json', rows, {}, { data: rows }) + + expect(read()).toBe('') + expect(JSON.parse(logged.join('\n'))).toEqual(rows) + }) + + /** + * The other half of the same bound: the scan stops at `data`. A flag nested + * below it is not read, so a future "scan deeper" change has to face this + * test and the row-column case above together. + */ + it('does not descend past data', () => { + const read = captureStderr() + + renderResult('readFileText', 'json', {}, {}, {}, { data: { file: { truncated: true } } }) + + expect(read()).toBe('') + }) + + it('leaves yaml a bare payload, with the note on stderr', () => { + const read = captureStderr() + + renderPage('yaml', [{ id: 'a' }], {}, { data: [], truncated: true }) + + expect(logged.join('\n')).not.toContain('clipped') + expect(read()).toContain('the server clipped this result') + }) +}) diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index 4b4bb4a400b..a7e8259d658 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -47,6 +47,12 @@ function at(row: unknown, path: string): unknown { * decode is shown as it arrived rather than dropped — the point is to show the * name, and a malformed one is still the truth about what the server holds. * + * A segment whose decoded name contains the separator is shown in wire form for + * the same reason: decoding it would print a root folder named `a/b` as + * `/a/b`, byte-identical to a folder `b` nested under `a` — and the printed + * path is what people paste back, so `folders delete` addressed the other + * folder. Rendering must not manufacture structure that is not there. + * * Callers must reach this only from a `table` or `text` rendering path — the * hand-written `ls` builds its own columns and so decodes through here directly. * `json` and `yaml` render from the raw payload so that switching format never @@ -57,7 +63,8 @@ export function decodeFolderPath(value: string): string { .split('/') .map((segment) => { try { - return decodeURIComponent(segment) + const decoded = decodeURIComponent(segment) + return decoded.includes('/') ? segment : decoded } catch { return segment } @@ -278,9 +285,12 @@ export function renderPage( format: OutputFormat, rows: unknown[], spec: CommandSpec, - envelope?: unknown + envelope?: unknown, + options: { truncated?: boolean } = {} ): void { writePageNote(spec, envelope) + writeEnvelopeTruncation(envelope) + writeCursorTruncation(rows.length, options.truncated === true) printList( format, rows, @@ -304,14 +314,139 @@ function writePageNote(spec: CommandSpec, envelope: unknown): void { process.stderr.write(chalk.dim(`${spec.pageNote.label}: ${String(value)}\n`)) } +/** + * Response fields that state the server itself clipped what it returned. + * + * Matched by shape rather than listed per command, so a flag added to a route + * envelope is surfaced the day it lands: the CLI accumulates the rows and + * prints those, so an envelope field reaches no output format on its own. + */ +const TRUNCATION_FLAG = /^truncated$|^[A-Za-z0-9]+Truncated$/ + +/** + * Negating prefixes whose `Truncated` suffix states the opposite. + * + * A bare `Truncated$` match also accepts `notTruncated` and `isNotTruncated`, + * where `true` means the answer is whole, and a note about a clip that did not + * happen is the worst thing this can print. These four prefixes are the + * spellings worth anticipating rather than a decision procedure for English — + * a field negated some other way slips through and has to be added here. + */ +const NEGATED_TRUNCATION_FLAG = /^(?:not|un|non|never)Truncated$|(?:Not|Un|Non|Never)Truncated$/ + +/** The flags one object raised, in the spelling the wire used. */ +function truncationFlags(container: unknown): string[] { + if (!container || typeof container !== 'object' || Array.isArray(container)) return [] + return Object.entries(container) + .filter( + ([key, value]) => + value === true && TRUNCATION_FLAG.test(key) && !NEGATED_TRUNCATION_FLAG.test(key) + ) + .map(([key]) => key) +} + +/** + * The flags a whole response raised, on its envelope or inside its payload. + * + * Two responses state their clip one level down: `files text`, whose file body + * stops at `maxBytes`, and `tables rows search`, whose match list the server + * stops building. An envelope-only scan said nothing about either — silently + * handing back a partial file is the same defect the note exists to close, on a + * worse payload than a short list. + * + * Only `data` is descended, and only while it is an object: a page's `data` is + * the rows, and a row's keys are the user's rather than the API's, so a shape + * match there is a guess about a name somebody else chose. + */ +function responseTruncationFlags(envelope: unknown): string[] { + return [...truncationFlags(envelope), ...truncationFlags(at(envelope, 'data'))] +} + +/** + * Carries a truncation stated on any page, not only the first. + * + * The envelope is otherwise the first page's, because a fact about the whole + * query (billing's `scope`) is stated once — but `toolNamesTruncated` is + * computed per page, so a walk that clipped on page 7 would have said nothing. + */ +export function foldPageEnvelope(current: unknown, page: unknown): unknown { + if (current === undefined) return page + const raised = truncationFlags(page) + if (raised.length === 0 || !current || typeof current !== 'object') return current + return { + ...(current as Record), + ...Object.fromEntries(raised.map((flag) => [flag, true])), + } +} + +/** `toolNamesTruncated` as a reader says it. */ +function spellOut(flag: string): string { + return flag + .replace(/([a-z])([A-Z])/g, '$1 $2') + .toLowerCase() + .trim() +} + +/** + * What a flag says was clipped, read from the words before its suffix. + * + * `toolNamesTruncated` on `workflow-mcp-servers list` is a clip of each row's + * tool names, not of the servers, so one wording about "this list" named the + * wrong thing on the one endpoint whose subject is not the list. A bare + * `truncated` carries no subject and stands for the whole answer. + */ +function clippedSubject(flag: string): string { + const subject = flag.replace(/^truncated$|Truncated$/, '') + return subject ? `the ${spellOut(subject)} it returned` : 'this result' +} + +/** + * States a server-side clip once, on stderr, in every format. + * + * The API answers a clipped inventory with a flag on the envelope, and the CLI + * printed only `data` — so `--output json` could not tell a complete list from + * one the server cut short. stdout stays byte-for-byte the rows, for the reason + * `writePageNote` gives. + */ +function writeEnvelopeTruncation(envelope: unknown): void { + for (const flag of responseTruncationFlags(envelope)) { + process.stderr.write( + chalk.dim( + `${spellOut(flag)}: the server clipped ${clippedSubject(flag)}, so the answer is incomplete\n` + ) + ) + } +} + +/** + * States that the walk stopped at `--limit` while more pages remained. + * + * `sim tools list` answered 100 of 4708 rows with an exit code of 0 and nothing + * on stderr, in every format — indistinguishable from a complete inventory. + * Said whenever a cursor survives, including when the caller set a small limit: + * 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 { + if (!truncated) return + process.stderr.write( + chalk.dim(`showing the first ${count}; more results exist — re-run with --limit 0 for all\n`) + ) +} + /** Renders one non-paginated operation result according to its CLI contract. */ export function renderResult( operation: V2OperationName, format: OutputFormat, raw: unknown, spec: CommandSpec, - options: RenderResultOptions = {} + options: RenderResultOptions = {}, + envelope?: unknown ): void { + // Before any branch: the flag lives on the envelope `execute` unwraps one + // line before rendering, and states a fact about the payload below it. + writeEnvelopeTruncation(envelope) + if (spec.document) { printDocument(format, raw) return diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts index f08294e3e23..9bd155d1584 100644 --- a/packages/sim-cli/src/runtime/types.ts +++ b/packages/sim-cli/src/runtime/types.ts @@ -13,5 +13,12 @@ export interface OperationSpec { headers?: Record opaqueBody?: boolean summary?: string + /** + * The operation rejects a workspace API key; only a personal one works. + * + * Emitted by `scripts/generate-v2-cli-api.ts` from the OpenAPI description so + * `--help` states the restriction the caller would otherwise meet as a `403`. + */ + personalKeyOnly?: true responseMode?: 'json' | 'binary' | 'stream' } diff --git a/scripts/generate-cli-docs.test.ts b/scripts/generate-cli-docs.test.ts new file mode 100644 index 00000000000..84a267ed2d9 --- /dev/null +++ b/scripts/generate-cli-docs.test.ts @@ -0,0 +1,152 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { describeOption, formatDefault, GUIDE_PAGES, OUTPUT_DIR } from './generate-cli-docs' + +/** `describeOption` reads only these two fields; the rest of the Option is irrelevant. */ +function option(fields: { description?: string; defaultValue?: unknown }) { + return fields as Parameters[0] +} + +function guide(page: string): string { + return readFileSync(path.join(OUTPUT_DIR, `${page}.mdx`), 'utf8') +} + +describe('an option default rendered into the reference table', () => { + /** + * A repeatable flag's Commander default is `[]` — the accumulator its + * collector appends to, not a value anyone would type. `String([])` is the + * empty string, so the table carried a dangling "Defaults to ``." + */ + it('omits the clause for a repeatable flag with an empty accumulator', () => { + const cell = describeOption( + option({ + description: 'Only follow runs of this workflow (repeatable)', + defaultValue: [], + }) + ) + expect(cell).toBe('Only follow runs of this workflow (repeatable).') + expect(cell).not.toContain('Defaults to') + }) + + it('omits the clause for an empty-string default', () => { + expect(describeOption(option({ description: 'Folder path', defaultValue: '' }))).not.toContain( + 'Defaults to' + ) + }) + + /** + * The guard is on emptiness, not falsiness: `false` and `0` are defaults a + * caller has to be told about, and a `!value` check would drop both. + */ + it('still states a false default', () => { + expect( + describeOption(option({ description: 'Follow the stream', defaultValue: false })) + ).toContain('Defaults to `false`.') + }) + + it('still states a zero default', () => { + expect(describeOption(option({ description: 'Retry attempts', defaultValue: 0 }))).toContain( + 'Defaults to `0`.' + ) + }) + + it('states a scalar default', () => { + expect( + describeOption(option({ description: 'Response detail', defaultValue: 'full' })) + ).toContain('Defaults to `full`.') + }) + + it('joins a non-empty array default', () => { + expect(formatDefault(['a', 'b'])).toBe('a, b') + expect(describeOption(option({ description: 'Levels', defaultValue: ['a', 'b'] }))).toContain( + 'Defaults to `a, b`.' + ) + }) +}) + +describe('links between the CLI docs pages', () => { + /** + * `/cli/reference` is the only page documenting every flag, and nothing but + * itself linked to it — the index sent "every subcommand" readers to + * `/cli/commands`, which is the overview. + */ + it('links the complete reference from a page other than itself', () => { + expect(guide('index')).toContain('](/cli/reference)') + }) + + it('attaches the every-subcommand claim to the reference, not the overview', () => { + const claim = guide('index') + .split('\n\n') + .find((block) => block.includes('every\nsubcommand') || block.includes('every subcommand')) + expect(claim).toBeDefined() + expect(claim).toContain('/cli/reference') + }) +}) + +/** + * The id shapes the CLI states in its own root help (`HELP_EPILOGUE`, + * `packages/sim-cli/src/program.ts`): workflow, knowledge-base, workspace and + * run ids are bare UUIDs, table ids carry `tbl_`, file ids carry `wf_`. + * + * The guides are hand-written and the generator neither writes nor inspects + * them, so nothing else stops their placeholder ids from teaching a scheme the + * API does not use — which is how `wf_7Yb2` came to name a workflow ~10 times. + */ +const FOREIGN_ID_PREFIXES = ['ws_', 'kb_', 'run_', 'file_', 'doc_', 'org_'] as const + +describe('placeholder ids in the hand-written guides', () => { + it('uses no prefix the API never issues', () => { + // No `\b` before the prefix: `printf 'wf_…\nfile_2\n'` carries a literal + // `\n`, and a word-anchored sweep reads straight past the `file_2` after it. + const candidate = new RegExp(`(.?)(${FOREIGN_ID_PREFIXES.join('|')})([A-Za-z0-9]+)(.?)`, 'g') + const offenders: string[] = [] + for (const page of GUIDE_PAGES) { + guide(page) + .split('\n') + .forEach((line, index) => { + // A literal `\n` inside a quoted `printf` separates two ids, so it has + // to read as a boundary — otherwise the sweep sees `nfile_2`, takes + // the `n` for a word character, and skips the second id entirely. + const scan = line.replace(/\\[nrt]/g, ' ') + for (const [, before, prefix, rest, after] of scan.matchAll(candidate)) { + // A shell variable is read with `$` and written with `=`; the + // recipes name one `run_id`, which is not an id placeholder. + if (before === '$' || before === '{' || after === '=') continue + if (/[\w-]/.test(before)) continue + offenders.push(`${page}.mdx:${index + 1}: ${prefix}${rest}`) + } + }) + } + expect(offenders).toEqual([]) + }) + + /** `wf_` is the FILE prefix, so it must never sit in a workflow argument. */ + it('never spells a workflow id with the file prefix', () => { + const offenders: string[] = [] + for (const page of GUIDE_PAGES) { + const lines = guide(page).split('\n') + lines.forEach((line, index) => { + if (/(?:sim )?workflows? [a-z-]+ wf_|--workflow wf_/.test(line)) { + offenders.push(`${page}.mdx:${index + 1}: ${line.trim()}`) + } + }) + } + expect(offenders).toEqual([]) + }) + + /** A prefixed id shorter than the real thing teaches a shape that never appears. */ + it('writes prefixed ids at their real length', () => { + const offenders: string[] = [] + for (const page of GUIDE_PAGES) { + const text = guide(page) + for (const match of text.matchAll(/\b(tbl_|row_)([A-Za-z0-9]+)/g)) { + if (match[2].length !== 32) offenders.push(`${page}.mdx: ${match[0]}`) + } + for (const match of text.matchAll(/\bwf_([A-Za-z0-9_-]+)/g)) { + if (match[1].length < 20) offenders.push(`${page}.mdx: ${match[0]}`) + } + } + expect(offenders).toEqual([]) + }) +}) diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index ff4023cc476..150bdcf8a4b 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -24,7 +24,7 @@ import { V2_OPERATIONS } from '../packages/sim-cli/src/generated/v2-api' import { buildProgram } from '../packages/sim-cli/src/program' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const OUTPUT_DIR = path.join(ROOT, 'apps/docs/content/docs/en/cli') +export const OUTPUT_DIR = path.join(ROOT, 'apps/docs/content/docs/en/cli') /** Commander's synthetic help command is not part of the documented surface. */ const HELP_COMMAND = 'help' @@ -38,7 +38,7 @@ const HELP_COMMAND = 'help' * these, so they are listed — the same guard `scripts/generate-docs.ts` uses for * its hand-authored integration pages. */ -const GUIDE_PAGES = [ +export const GUIDE_PAGES = [ 'index', 'authentication', 'configuration', @@ -201,14 +201,32 @@ function asSentence(value: string): string { return /[.!?]$/.test(value) ? value : `${value}.` } +/** + * Renders a default value for prose, or `''` when there is nothing to state. + * + * A repeatable flag's Commander default is `[]` — the empty accumulator its + * collector appends to, not a value anyone would type — and `String([])` is the + * empty string, which rendered as a dangling "Defaults to ``." The check is on + * emptiness rather than falsiness: `false` and `0` are real defaults a caller + * needs stated, and a `!value` guard would silently drop both. + */ +export function formatDefault(value: unknown): string { + if (Array.isArray(value)) { + return value.length > 0 ? value.map(String).join(', ') : '' + } + const text = String(value) + return text.trim() ? text : '' +} + /** Returns a table-ready cell: escaped prose, with code spans left intact. */ -function describeOption(option: Command['options'][number]): string { +export function describeOption(option: Command['options'][number]): string { const parts = [asSentence(escapeCell(stripRequiredSuffix(option.description || '')))] if (option.argChoices && option.argChoices.length > 0) { parts.push(`Accepted values: ${option.argChoices.map(code).join(', ')}.`) } if (option.defaultValue !== undefined) { - parts.push(`Defaults to ${code(String(option.defaultValue))}.`) + const fallback = formatDefault(option.defaultValue) + if (fallback) parts.push(`Defaults to ${code(fallback)}.`) } const description = parts.filter(Boolean).join(' ') return description || '—' @@ -705,4 +723,6 @@ function main(): void { ) } -main() +// Guarded so the pure helpers above can be imported by tests without the +// generator rewriting the docs as a side effect of the import. +if (import.meta.main) main() diff --git a/scripts/generate-v2-cli-api.test.ts b/scripts/generate-v2-cli-api.test.ts index bc994851973..84fcfaabfbc 100644 --- a/scripts/generate-v2-cli-api.test.ts +++ b/scripts/generate-v2-cli-api.test.ts @@ -1,6 +1,11 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { z } from 'zod' -import { CLI_MANAGED_HEADERS, renderSlotMap } from './generate-v2-cli-api' +import { CLI_MANAGED_HEADERS, loadSummaries, renderSlotMap } from './generate-v2-cli-api' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') describe('a field the contract types as nullable', () => { /** @@ -51,3 +56,60 @@ describe('request headers reaching the CLI as flags', () => { expect(map).toContain('upload-token') }) }) + +/** + * Reads the denial sentences out of `openapi/shared.ts` as source text. + * + * The generator itself imports that module, but it resolves through the `@/` + * alias, which the root vitest run has no resolver for. Parsing the literal + * keeps the test bound to the same single source of truth: reword the sentence + * and this recomputes the expected set, so a generator holding a stale copy of + * it goes red instead of silently unmarking a family. + */ +function personalKeyMarkers(): string[] { + const source = readFileSync( + path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/shared.ts'), + 'utf8' + ) + const markers = [...source.matchAll(/export const (WORKSPACE_API_KEY_DENIED\w*) =\s*'([^']+)'/g)] + .filter(([, name]) => name.startsWith('WORKSPACE_API_KEY_DENIED')) + .map(([, , sentence]) => sentence) + expect(markers.length).toBeGreaterThan(0) + return markers +} + +function generatedSource(): string { + return readFileSync(path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts'), 'utf8') +} + +/** The body of one entry in the emitted `V2_OPERATIONS` table. */ +function generatedEntry(source: string, name: string): string { + const match = source.match(new RegExp(`\\n ${name}: \\{([\\s\\S]*?)\\n \\},`)) + if (!match) throw new Error(`${name} is not in the generated operation table`) + return match[1] +} + +describe('operations that refuse a workspace API key', () => { + /** + * A count, not just named operations: pinning two of them would let a reword + * confined to one contract family silently unmark every other one while the + * pinned pair stayed green. + */ + it('emits the marker for every operation the specs say refuses one', () => { + const marked = [...loadSummaries(personalKeyMarkers()).values()].filter( + (doc) => doc.personalKeyOnly + ) + expect(marked.length).toBeGreaterThan(0) + expect(generatedSource().match(/personalKeyOnly: true/g)?.length ?? 0).toBe(marked.length) + }) + + it('marks restricted operations and leaves workspace-key-capable siblings alone', () => { + const source = generatedSource() + for (const name of ['listMcpServerTools', 'listSecrets', 'undeployWorkflow']) { + expect(generatedEntry(source, name)).toContain('personalKeyOnly: true') + } + for (const name of ['listMcpServers', 'getMcpServer', 'listWorkflows']) { + expect(generatedEntry(source, name)).not.toContain('personalKeyOnly') + } + }) +}) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 608f47bb62c..d8edc0e1f36 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -62,17 +62,54 @@ function specFiles(): string[] { .sort() } +/** What the OpenAPI specs say about one operation, beyond its request shape. */ +export interface OperationDoc { + /** The spec's one-line summary, used as the command's `--help` description. */ + summary?: string + /** + * The operation refuses a workspace API key, per its `description`. + * + * Carried so `--help` can say so before the request goes out; without it the + * caller learns the restriction from a `403` after the fact. + */ + personalKeyOnly?: true +} + /** - * `METHOD /api/v2/{id}/…` → the spec's one-line summary. + * The description sentences that mark an operation as personal-key-only. + * + * Read out of `apps/sim/lib/api/contracts/v2/openapi/shared.ts` at generation + * time rather than restated here, so rewording the sentence there cannot leave + * the marker silently unemitted. The import is lazy because that module + * resolves through the `@/` alias, which exists under `bun` but not under the + * root `vitest` that imports this file's pure helpers. + */ +export async function loadPersonalKeyMarkers(): Promise { + const shared: Record = await import( + path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/shared.ts') + ) + const markers = [shared.WORKSPACE_API_KEY_DENIED, shared.WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND] + for (const marker of markers) { + if (typeof marker !== 'string' || !marker.trim()) { + throw new Error('openapi/shared.ts no longer exports the workspace-key denial sentences') + } + } + return markers as string[] +} + +/** + * `METHOD /api/v2/{id}/…` → what the specs document about that operation. * * The contracts carry validation, not prose, so `--help` text has to come from * somewhere else. The specs already hold a hand-written summary per operation * and `check:openapi` guarantees every contract has one, so reading them here * reuses documentation that is already written and already verified rather than - * inventing a second place to describe the same endpoint. + * inventing a second place to describe the same endpoint. The longer + * `description` is read for the same reason — it is where the workspace-key + * denial is already stated. */ -function loadSummaries(): Map { - const summaries = new Map() +export function loadSummaries(personalKeyMarkers: readonly string[]): Map { + const docs = new Map() for (const file of specFiles()) { let spec: Record @@ -86,15 +123,23 @@ function loadSummaries(): Map { for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { for (const [method, operation] of Object.entries(methods as Record)) { - const summary = operation?.summary - if (typeof summary === 'string') { - summaries.set(`${method.toUpperCase()} ${specPath}`, summary) + const doc: OperationDoc = {} + if (typeof operation?.summary === 'string') doc.summary = operation.summary + const description = operation?.description + if ( + typeof description === 'string' && + personalKeyMarkers.some((marker) => description.includes(marker)) + ) { + doc.personalKeyOnly = true + } + if (doc.summary || doc.personalKeyOnly) { + docs.set(`${method.toUpperCase()} ${specPath}`, doc) } } } } - return summaries + return docs } /** @@ -470,9 +515,8 @@ export function renderSlotMap( return `{\n${lines.join('\n')}\n${indent}}` } -function render(operations: Operation[]): string { +function render(operations: Operation[], docs: Map): string { const out: string[] = [] - const summaries = loadSummaries() out.push('/**') out.push(' * GENERATED FILE — DO NOT EDIT.') @@ -526,6 +570,9 @@ function render(operations: Operation[]): string { out.push(' *') out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI") out.push(' * specs so `--help` reuses prose that is already written and already checked.') + out.push(' *') + out.push(' * `personalKeyOnly` marks an operation whose spec description says a workspace') + out.push(' * API key is rejected, so `--help` can say so before the request is sent.') out.push(' */') out.push('export const V2_OPERATIONS = {') for (const op of operations) { @@ -544,10 +591,11 @@ function render(operations: Operation[]): string { } out.push(` responseMode: '${op.contract.response.mode}',`) // OpenAPI writes `{id}` where the contract writes `[id]`. - const summary = summaries.get( + const doc = docs.get( `${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}` ) - if (summary) out.push(` summary: ${JSON.stringify(summary)},`) + if (doc?.summary) out.push(` summary: ${JSON.stringify(doc.summary)},`) + if (doc?.personalKeyOnly) out.push(` personalKeyOnly: true,`) for (const slot of ['query', 'body'] as const) { const map = renderSlotMap(op.contract[slot], ' ') if (map) out.push(` ${slot}: ${map},`) @@ -608,7 +656,7 @@ async function main() { const args = new Set(process.argv.slice(2)) const operations = await collectOperations() - const generated = format(render(operations)) + const generated = format(render(operations, loadSummaries(await loadPersonalKeyMarkers()))) if (args.has('--check')) { let current = ''