mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
fix(cli): resolve blockers and majors from a full command-surface audit (#7083)
* fix(cli): resolve blockers and majors from a full command-surface audit Audit of all 222 CLI leaves against a live deployment, plus fixes for every defect it confirmed. Blockers: - An unrecognized --profile resolved to built-in defaults, so a typo silently targeted production and transmitted the API key there. - sim logs follow sent an undeclared query key and failed on every invocation. - sim workflows run exited 0 on a failed run, so CI reported success. - knowledge connectors documents update matched rows already in the target state, making exclude and restore permanent no-ops. - PDF text layers below the OCR threshold are transcribed by a model and stored verbatim with no record that it happened. Majors include: rollback --version was swallowed by the program-level flag and silently did nothing; nullable string flags could not send null despite their help promising it; six protocol commands discarded excess arguments, dropping files on upload; sim chat crashed with EPIPE when piped to head; tables import dropped malformed CSV rows without reporting them; audit-logs required an organization id no API surface exposed; secrets could not opt out of redaction or read a value from a file; bulk deletes and moves exited 0 having done nothing; and MCP registrations were destroyed by undeploy rather than restored. Adds extraction_method to documents so OCR output is distinguishable from parsed text, and reports the applied scope on billing logs so the two ledger questions are no longer indistinguishable. * fix(mcp): bound MCP restore by server and re-check uniqueness under the lock Two gaps in the archive/restore lifecycle this branch introduced. The candidate query bounded archived rows and deduplicated to one per server afterwards, so several archived generations stacked on one server consumed the whole budget and every other server the workflow had been published on fell out of the result with no warning. Deduplication moves into SQL so the bound applies to servers, preserving most-recently-updated-per-server. The live-registration check ran before the server lock was acquired, so a concurrent tool create could land in between and the restore would un-archive a second live row for the same server and workflow, violating the partial unique index and rolling back the whole deployment. That check now runs under the lock alongside the tool-name, capacity, and metadata-budget checks it belongs with. * fix(review): address round-three review findings across CLI and server Restore now picks candidate servers by recency: DISTINCT ON forces its own key to lead the sort, so bounding on that statement kept the lexicographically lowest server ids and left a workflow's most recently used servers archived. Deduplication and bounding are now separate stages. CLI: a total miss on tables move reported only in notFound exited 0; unsetting a key or removing a profile mutated the first duplicate INI block while reads merged later ones, so the removal appeared to succeed and did nothing; an import that rejected cells but no rows showed a clean progress line; and the --run-id help implied idempotency it does not provide. Server: a run with no recorded output projection let block-name selectors past the new validation; the billing window comparison still fired on a bound that parsed but failed the shared schema; CSV rejection accounting reached only the streaming path, so buffered and synchronous imports still dropped records silently, through to the Copilot tool that reports them; case-insensitive tag name uniqueness now serializes on the knowledge-base row the delete paths already lock; and a failed sync claim reports the lifecycle reason rather than always claiming a sync is in progress. Reverts an over-scrub from the previous commit: workspace-file-imports is consumed only by Copilot, so naming save_upload and glob there is the correct remediation rather than a leak, and the sweep that guards against leaks now exempts it explicitly. Corrects two contract descriptions that promised a bulk tag save would rename or relocate an occupied slot, which it deliberately no longer does. * fix(cli): remove flags a caller cannot use, and correct three that misled Removes surface that should not have shipped: - `files uploads get` is hidden. Its `--upload-token` was required, and the token is minted and consumed inside a single `files upload`, which completes or aborts its session before returning. Nothing in the CLI could produce the value, so the command answered every invocation by asking for something unobtainable. The same flag is dropped from the two table-import commands, where a CLI-created import is already queryable without it. - The `--no-<flag>` companion that sent JSON null is gone. `--no-X` means "send boolean false" on thirty-seven other flags, and one spelling should not carry two meanings. `--description ''` already clears the displayed value, and the help now warns that the literal word null is stored as text rather than suggesting a substitute, because on the OAuth client fields null revokes a stored grant and an empty string does not. - The document extraction method column and its contract field are reverted. Nothing read them, they were null for every existing document, and the name collided with the parser metadata field that already exists. Corrects flags that misled: the workflow move destination is `--to`, matching its two siblings rather than meaning the opposite of `--folder` one command over; `files list --recursive` is a bare flag like the four folder deletes rather than a twelve-alias string; the dispatch row cap takes a count instead of its wire object; `--yes` no longer claims to be required on commands that accept `--dry-run`; cancelling every run on a table is confirm-gated; and the retry-processing negation, which the route rejects, is suppressed. Extends the guard that missed all of this: it swept only `--x-` prefixes over generated commands, so a header spelled without one was invisible to it. It now derives every header name from the operation table and sweeps the assembled program. * fix(mcp): budget MCP restore against the workflow's live server fanout Restore bounded its candidates at the per-workflow server limit counting archived rows only, never subtracting the memberships the workflow already holds live. The fanout validation a few lines later in the same deploy transaction counts live servers against that same limit, so a workflow with both live and archived registrations could restore past it and roll the whole deployment back. The candidate query now bounds on the remaining headroom, and the budget is re-checked under the server locks and spent once per accepted candidate, so a create that lands between the count and the unarchive cannot push it over. Candidates that do not fit are dropped by recency, matching how the set is already selected, and stay archived with a warning naming the workflow, the server, and the reason — restore still never throws inside the deploy transaction. One residual is left open deliberately: a create on a server outside the candidate set is serialized by neither the locks nor the recount. Closing it would need a workflow-level lock, which would change the ordering every other writer here depends on, and the create path runs its own limit check. * fix(mcp): stop restore spending its budget on servers it cannot restore A server can hold both a live registration and archived ones for the same workflow: the partial unique index constrains only the live row. Such a server was counted twice — once shrinking the restore budget, once consuming one of its slots — before the liveness check under the lock skipped it. While the bound was the full server limit that waste was invisible; once the bound became the remaining headroom, every slot spent that way cost a registration that could have been restored. The candidate query now excludes servers the workflow is already live on, so the budget and the candidate set agree. The exclusion sits on the inner stage, before deduplication and the bound, and is a pre-lock optimisation only: the check under the server lock stays authoritative, because the query can go stale between reading and unarchiving. Candidates rejected for a tool-name collision, the per-server cap, or the metadata budget are still not replaced. That case is only knowable under the lock, so replacing it would mean fetching past the bound and locking servers outside the candidate set, widening an ordering every writer here relies on.
This commit is contained in:
@@ -31,7 +31,7 @@ sim audit-logs get <auditLogId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--organization <value>` | Yes | Organization ID (personal API key required). |
|
||||
| `--organization <value>` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -55,7 +55,7 @@ sim audit-logs list [options]
|
||||
| `--include-departed` | No | Include actions by users who have left the organization. |
|
||||
| `--no-include-departed` | No | Send --include-departed as false. |
|
||||
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
|
||||
| `--organization <value>` | Yes | Organization ID (personal API key required). |
|
||||
| `--organization <value>` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). |
|
||||
| `--actor-email <value>` | No | Filter by actor email address. |
|
||||
| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). |
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio
|
||||
sim billing status [options]
|
||||
```
|
||||
|
||||
Show billing status and current-period credit usage (credits and storage require a personal API key)
|
||||
|
||||
**Options**
|
||||
|
||||
<CommandTable>
|
||||
|
||||
@@ -95,7 +95,7 @@ sim credentials update <credentialId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--display-name <value>` | No | New name shown for the credential in Sim. |
|
||||
| `--description <value>` | No | New credential description. Send null to clear the stored one. |
|
||||
| `--description <value>` | No | New credential description. Send null to clear the stored one. (--description null sends the word, not JSON null). |
|
||||
| `--service-account-json <value>` | No | Write-only Google service-account JSON key. |
|
||||
| `--api-token <value>` | No | Write-only provider API token. |
|
||||
| `--domain <value>` | No | Provider account domain. |
|
||||
@@ -109,6 +109,7 @@ sim credentials update <credentialId> [options]
|
||||
| `--auth-method <value>` | No | Provider authentication method. |
|
||||
| `--private-key <value>` | No | Write-only PEM private key. |
|
||||
| `--username <value>` | No | Provider run-as username. |
|
||||
| `--name <displayName>` | No | Alias for --display-name. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
|
||||
@@ -243,22 +243,6 @@ sim files share set <fileId> [options]
|
||||
|
||||
</CommandTable>
|
||||
|
||||
## Get file upload
|
||||
|
||||
```bash
|
||||
sim files uploads get <uploadId>
|
||||
```
|
||||
|
||||
**Arguments**
|
||||
|
||||
<CommandTable>
|
||||
|
||||
| Argument | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `uploadId` | Yes | Upload session identifier. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
## List files
|
||||
|
||||
```bash
|
||||
@@ -272,7 +256,7 @@ sim files list [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--folder <value>` | No | Folder path as shown in the app; the leading / is optional. |
|
||||
| `--recursive <value>` | 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. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. |
|
||||
| `--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. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. |
|
||||
| `--scope <value>` | 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 <value>` | No | Case-insensitive substring match against the file name. |
|
||||
| `--sort-by <value>` | 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`. |
|
||||
|
||||
@@ -84,7 +84,7 @@ sim knowledge tags create <knowledgeBaseId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--display-name <value>` | Yes | Name tag filters and document reads use for this tag. |
|
||||
| `--field-type <value>` | No | Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3. Accepted values: `text`, `number`, `date`, `boolean`. |
|
||||
| `--field-type <value>` | No | Value type stored in the slot; it decides which slots are usable and which filter operators apply. Defaults to text, so a number, date, or boolean slot must name its type here. Slot capacity per type: text 7, number 5, date 2, boolean 3. Accepted values: `text`, `number`, `date`, `boolean`. |
|
||||
| `--tag-slot <value>` | No | Slot to store the tag in. Omit to take the next free slot for the field type; a slot that does not belong to the field type, or one already in use, is rejected. Accepted values: `tag1`, `tag2`, `tag3`, `tag4`, `tag5`, `tag6`, `tag7`, `number1`, `number2`, `number3`, `number4`, `number5`, `date1`, `date2`, `boolean1`, `boolean2`, `boolean3`. |
|
||||
|
||||
</CommandTable>
|
||||
@@ -138,7 +138,7 @@ sim knowledge tags cleanup <knowledgeBaseId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. |
|
||||
| `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. |
|
||||
| `--no-unused` | No | Send --unused as false. |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
@@ -546,7 +546,6 @@ sim knowledge documents update <knowledgeBaseId> <documentId> [options]
|
||||
| `--boolean3` | No | New value for boolean tag slot 3. |
|
||||
| `--no-boolean3` | No | Send --boolean3 as false. |
|
||||
| `--retry-processing` | No | Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document. |
|
||||
| `--no-retry-processing` | No | Send --retry-processing as false. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ sim mcp-servers create [options]
|
||||
| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. |
|
||||
| `--no-enabled` | No | Send --enabled as false. |
|
||||
| `--oauth-client-id <value>` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. |
|
||||
| `--oauth-client-secret <value>` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. |
|
||||
| `--oauth-client-secret <value>` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. (--oauth-client-secret null sends the word, not JSON null). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -157,6 +157,6 @@ sim mcp-servers update <mcpServerId> [options]
|
||||
| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. |
|
||||
| `--no-enabled` | No | Send --enabled as false. |
|
||||
| `--oauth-client-id <value>` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. |
|
||||
| `--oauth-client-secret <value>` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. |
|
||||
| `--oauth-client-secret <value>` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. (--oauth-client-secret null sends the word, not JSON null). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -197,7 +197,7 @@ sim audit-logs get <auditLogId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--organization <value>` | Yes | Organization ID (personal API key required). |
|
||||
| `--organization <value>` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -223,7 +223,7 @@ sim audit-logs list [options]
|
||||
| `--include-departed` | No | Include actions by users who have left the organization. |
|
||||
| `--no-include-departed` | No | Send --include-departed as false. |
|
||||
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
|
||||
| `--organization <value>` | Yes | Organization ID (personal API key required). |
|
||||
| `--organization <value>` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). |
|
||||
| `--actor-email <value>` | No | Filter by actor email address. |
|
||||
| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). |
|
||||
|
||||
@@ -233,7 +233,7 @@ sim audit-logs list [options]
|
||||
|
||||
### sim billing status
|
||||
|
||||
Show billing status and current-period credit usage
|
||||
Show billing status and current-period credit usage (credits and storage require a personal API key)
|
||||
|
||||
```bash
|
||||
sim billing status [options]
|
||||
@@ -459,7 +459,7 @@ sim credentials update <credentialId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--display-name <value>` | No | New name shown for the credential in Sim. |
|
||||
| `--description <value>` | No | New credential description. Send null to clear the stored one. |
|
||||
| `--description <value>` | No | New credential description. Send null to clear the stored one. (--description null sends the word, not JSON null). |
|
||||
| `--service-account-json <value>` | No | Write-only Google service-account JSON key. |
|
||||
| `--api-token <value>` | No | Write-only provider API token. |
|
||||
| `--domain <value>` | No | Provider account domain. |
|
||||
@@ -473,6 +473,7 @@ sim credentials update <credentialId> [options]
|
||||
| `--auth-method <value>` | No | Provider authentication method. |
|
||||
| `--private-key <value>` | No | Write-only PEM private key. |
|
||||
| `--username <value>` | No | Provider run-as username. |
|
||||
| `--name <displayName>` | No | Alias for --display-name. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -934,24 +935,6 @@ sim files share set <fileId> [options]
|
||||
|
||||
</CommandTable>
|
||||
|
||||
### sim files uploads get
|
||||
|
||||
Get File Upload
|
||||
|
||||
```bash
|
||||
sim files uploads get <uploadId>
|
||||
```
|
||||
|
||||
**Arguments**
|
||||
|
||||
<CommandTable>
|
||||
|
||||
| Argument | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `uploadId` | Yes | Upload session identifier. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
### sim files list
|
||||
|
||||
List Files
|
||||
@@ -967,7 +950,7 @@ sim files list [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--folder <value>` | No | Folder path as shown in the app; the leading / is optional. |
|
||||
| `--recursive <value>` | 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. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. |
|
||||
| `--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. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. |
|
||||
| `--scope <value>` | 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 <value>` | No | Case-insensitive substring match against the file name. |
|
||||
| `--sort-by <value>` | 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`. |
|
||||
@@ -1318,7 +1301,7 @@ sim knowledge tags create <knowledgeBaseId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--display-name <value>` | Yes | Name tag filters and document reads use for this tag. |
|
||||
| `--field-type <value>` | No | Value type stored in the slot; it decides which slots are usable and which filter operators apply. Slot capacity per type: text 7, number 5, date 2, boolean 3. Accepted values: `text`, `number`, `date`, `boolean`. |
|
||||
| `--field-type <value>` | No | Value type stored in the slot; it decides which slots are usable and which filter operators apply. Defaults to text, so a number, date, or boolean slot must name its type here. Slot capacity per type: text 7, number 5, date 2, boolean 3. Accepted values: `text`, `number`, `date`, `boolean`. |
|
||||
| `--tag-slot <value>` | No | Slot to store the tag in. Omit to take the next free slot for the field type; a slot that does not belong to the field type, or one already in use, is rejected. Accepted values: `tag1`, `tag2`, `tag3`, `tag4`, `tag5`, `tag6`, `tag7`, `number1`, `number2`, `number3`, `number4`, `number5`, `date1`, `date2`, `boolean1`, `boolean2`, `boolean3`. |
|
||||
|
||||
</CommandTable>
|
||||
@@ -1376,7 +1359,7 @@ sim knowledge tags cleanup <knowledgeBaseId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. |
|
||||
| `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. |
|
||||
| `--no-unused` | No | Send --unused as false. |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
@@ -1814,7 +1797,6 @@ sim knowledge documents update <knowledgeBaseId> <documentId> [options]
|
||||
| `--boolean3` | No | New value for boolean tag slot 3. |
|
||||
| `--no-boolean3` | No | Send --boolean3 as false. |
|
||||
| `--retry-processing` | No | Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document. |
|
||||
| `--no-retry-processing` | No | Send --retry-processing as false. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -2554,7 +2536,7 @@ sim mcp-servers create [options]
|
||||
| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. |
|
||||
| `--no-enabled` | No | Send --enabled as false. |
|
||||
| `--oauth-client-id <value>` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. |
|
||||
| `--oauth-client-secret <value>` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. |
|
||||
| `--oauth-client-secret <value>` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. (--oauth-client-secret null sends the word, not JSON null). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -2689,7 +2671,7 @@ sim mcp-servers update <mcpServerId> [options]
|
||||
| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. |
|
||||
| `--no-enabled` | No | Send --enabled as false. |
|
||||
| `--oauth-client-id <value>` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. |
|
||||
| `--oauth-client-secret <value>` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. |
|
||||
| `--oauth-client-secret <value>` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. (--oauth-client-secret null sends the word, not JSON null). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -2783,8 +2765,10 @@ sim secrets set <name> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--scope <scope>` | Yes | Secret ownership scope. Accepted values: `workspace`, `personal`. |
|
||||
| `--value <value>` | No | Secret value; visible to shell history when supplied directly. |
|
||||
| `--value <value\|@file>` | No | Secret value. Passing it inline exposes it to shell history and process listings; @path reads it from a file and @- from stdin, verbatim — a trailing newline is part of the value, so write the file with printf rather than echo. Prefix a literal leading @ with a second one. |
|
||||
| `--description <description>` | No | What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged. |
|
||||
| `--unredacted` | No | Opt the workspace secret out of redaction: its value then appears in plaintext in run logs, model-visible content, and files, including publicly shared log links. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave the current setting untouched. Pass --no-unredacted to restore redaction. |
|
||||
| `--no-unredacted` | No | Send --unredacted as false. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -3628,7 +3612,7 @@ sim tables dispatches create <tableId> [options]
|
||||
| `--row-ids <value...>` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). |
|
||||
| `--filter <json\|@file>` | 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 <value...>` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). |
|
||||
| `--limit <json\|@file>` | No | Optional cap on eligible rows to run. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--max-rows <n>` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -3818,6 +3802,7 @@ sim tables cancel-runs <tableId> [options]
|
||||
| `--row-id <value>` | No | Row whose runs should be canceled for row scope. |
|
||||
| `--filter <json\|@file>` | 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 <value...>` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -4183,7 +4168,7 @@ sim tables move [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--table-ids <json\|@file>` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--folder <value...>` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). |
|
||||
| `--folder <value...>` | 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). |
|
||||
| `--to <value>` | No | Destination folder path; omit for root. |
|
||||
|
||||
</CommandTable>
|
||||
@@ -4231,7 +4216,7 @@ sim tables update <tableId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--name <value>` | No | Identifier: letters, numbers, and underscores; cannot start with a number. |
|
||||
| `--description <value>` | No | Replacement table description, or null to clear it. |
|
||||
| `--description <value>` | No | Replacement table description, or null to clear it. (--description null sends the word, not JSON null). |
|
||||
| `--folder <value>` | No | Folder path as shown in the app; the leading / is optional. |
|
||||
|
||||
</CommandTable>
|
||||
@@ -4489,7 +4474,7 @@ sim workflow-mcp-servers tools create <serverId> [options]
|
||||
| `--workflow-id <value>` | Yes | Deployed workflow to publish. The workflow must already be deployed. |
|
||||
| `--tool-name <value>` | No | Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted. |
|
||||
| `--tool-description <value>` | No | Description shown to MCP clients. Derived from the workflow name when omitted. |
|
||||
| `--parameter-descriptions <json\|@file>` | No | Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--parameter-descriptions <json\|@file>` | No | Per-field description overrides applied to the schema generated from the deployed workflow inputs, as [{"name":"email","description":"Customer email address"}]. A name matching no input field is ignored (JSON, or @path / @- to read a file or stdin). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -4603,7 +4588,7 @@ sim workflow-mcp-servers update <serverId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--name <value>` | No | Server display name, shown to connecting MCP clients. |
|
||||
| `--description <value>` | No | New server description, or null to clear it. |
|
||||
| `--description <value>` | No | New server description, or null to clear it. (--description null sends the word, not JSON null). |
|
||||
| `--is-public` | No | Whether the server answers MCP clients without a Sim API key. |
|
||||
| `--no-is-public` | No | Send --is-public as false. |
|
||||
|
||||
@@ -4658,12 +4643,12 @@ sim workflows operations apply <workflowId> [options]
|
||||
| --- | --- | --- |
|
||||
| `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. |
|
||||
| `--no-dry-run` | No | Send --dry-run as false. |
|
||||
| `--operations <json\|@file>` | Yes | Edits to apply, in a single batch. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--operations <json\|@file>` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. |
|
||||
| `--no-atomic` | No | Send --atomic as false. |
|
||||
| `--layout <value>` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. |
|
||||
| `--set-block-enabled <json\|@file>` | No | Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
| `--set-block-enabled <json\|@file>` | No | Blocks to enable or disable, applied after --operations: [{"block_id":"<uuid>","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined (JSON, or @path / @- to read a file or stdin). |
|
||||
| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -4691,7 +4676,7 @@ sim workflows variables update <workflowId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--operations <json\|@file>` | Yes | Variable changes to apply, in order. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--operations <json\|@file>` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
</CommandTable>
|
||||
@@ -5154,6 +5139,7 @@ sim workflows run <workflowId> [options]
|
||||
| `--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 <value>` | 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. |
|
||||
| `--run-id <value>` | No | One-shot identifier for this run; NOT an idempotency key — reusing a claimed value fails with RUN_ID_CONFLICT instead of replaying the first result, and a fresh value starts another run. |
|
||||
| `--manual` | No | Run the current saved workflow state instead of the active deployment. |
|
||||
| `--trigger <blockId>` | No | Enter a manual run through this runnable trigger (requires --manual). |
|
||||
| `--mock-payload` | No | Use the selected trigger's server-derived mock payload (requires --manual). |
|
||||
@@ -5296,7 +5282,7 @@ sim workflows state replace <workflowId> [options]
|
||||
| `--loops <json\|@file>` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--parallels <json\|@file>` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--variables <json\|@file>` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -5373,7 +5359,7 @@ sim workflows versions update <workflowId> <version> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--name <value>` | No | New label for the deployment version. |
|
||||
| `--description <value>` | No | New release note for the deployment version, or null to clear it. |
|
||||
| `--description <value>` | No | New release note for the deployment version, or null to clear it. (--description null sends the word, not JSON null). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -5438,7 +5424,7 @@ sim workflows move [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--workflow <value...>` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). |
|
||||
| `--folder <value>` | Yes | Folder path as shown in the app; the leading / is optional. |
|
||||
| `--to <value>` | Yes | Destination folder path; / moves the workflows to the workspace root. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -5513,7 +5499,8 @@ sim workflows rollback <workflowId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--version <value>` | No | Deployment version to reactivate. Omit to select the previous active version. |
|
||||
| `--to-version <value>` | No | Deployment version to reactivate. Omit to select the previous active version. |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -5522,7 +5509,7 @@ sim workflows rollback <workflowId> [options]
|
||||
Take a workflow out of deployment
|
||||
|
||||
```bash
|
||||
sim workflows undeploy <workflowId>
|
||||
sim workflows undeploy <workflowId> [options]
|
||||
```
|
||||
|
||||
**Arguments**
|
||||
@@ -5535,6 +5522,16 @@ sim workflows undeploy <workflowId>
|
||||
|
||||
</CommandTable>
|
||||
|
||||
**Options**
|
||||
|
||||
<CommandTable>
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
### sim workflows update
|
||||
|
||||
Update Workflow
|
||||
@@ -5560,7 +5557,7 @@ sim workflows update <workflowId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--name <value>` | No | Replacement workflow name. |
|
||||
| `--description <value>` | No | Replacement workflow description; null clears it. |
|
||||
| `--description <value>` | No | Replacement workflow description; null clears it. (--description null sends the word, not JSON null). |
|
||||
| `--folder <value>` | No | Folder path as shown in the app; the leading / is optional. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -79,7 +79,9 @@ sim secrets set <name> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--scope <scope>` | Yes | Secret ownership scope. Accepted values: `workspace`, `personal`. |
|
||||
| `--value <value>` | No | Secret value; visible to shell history when supplied directly. |
|
||||
| `--value <value\|@file>` | No | Secret value. Passing it inline exposes it to shell history and process listings; @path reads it from a file and @- from stdin, verbatim — a trailing newline is part of the value, so write the file with printf rather than echo. Prefix a literal leading @ with a second one. |
|
||||
| `--description <description>` | No | What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged. |
|
||||
| `--unredacted` | No | Opt the workspace secret out of redaction: its value then appears in plaintext in run logs, model-visible content, and files, including publicly shared log links. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave the current setting untouched. Pass --no-unredacted to restore redaction. |
|
||||
| `--no-unredacted` | No | Send --unredacted as false. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -593,7 +593,7 @@ sim tables dispatches create <tableId> [options]
|
||||
| `--row-ids <value...>` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). |
|
||||
| `--filter <json\|@file>` | 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 <value...>` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). |
|
||||
| `--limit <json\|@file>` | No | Optional cap on eligible rows to run. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--max-rows <n>` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -765,6 +765,7 @@ sim tables cancel-runs <tableId> [options]
|
||||
| `--row-id <value>` | No | Row whose runs should be canceled for row scope. |
|
||||
| `--filter <json\|@file>` | 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 <value...>` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -1098,7 +1099,7 @@ sim tables move [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--table-ids <json\|@file>` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--folder <value...>` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). |
|
||||
| `--folder <value...>` | 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). |
|
||||
| `--to <value>` | No | Destination folder path; omit for root. |
|
||||
|
||||
</CommandTable>
|
||||
@@ -1142,7 +1143,7 @@ sim tables update <tableId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--name <value>` | No | Identifier: letters, numbers, and underscores; cannot start with a number. |
|
||||
| `--description <value>` | No | Replacement table description, or null to clear it. |
|
||||
| `--description <value>` | No | Replacement table description, or null to clear it. (--description null sends the word, not JSON null). |
|
||||
| `--folder <value>` | No | Folder path as shown in the app; the leading / is optional. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -78,7 +78,7 @@ sim workflow-mcp-servers tools create <serverId> [options]
|
||||
| `--workflow-id <value>` | Yes | Deployed workflow to publish. The workflow must already be deployed. |
|
||||
| `--tool-name <value>` | No | Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted. |
|
||||
| `--tool-description <value>` | No | Description shown to MCP clients. Derived from the workflow name when omitted. |
|
||||
| `--parameter-descriptions <json\|@file>` | No | Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--parameter-descriptions <json\|@file>` | No | Per-field description overrides applied to the schema generated from the deployed workflow inputs, as [{"name":"email","description":"Customer email address"}]. A name matching no input field is ignored (JSON, or @path / @- to read a file or stdin). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -182,7 +182,7 @@ sim workflow-mcp-servers update <serverId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--name <value>` | No | Server display name, shown to connecting MCP clients. |
|
||||
| `--description <value>` | No | New server description, or null to clear it. |
|
||||
| `--description <value>` | No | New server description, or null to clear it. (--description null sends the word, not JSON null). |
|
||||
| `--is-public` | No | Whether the server answers MCP clients without a Sim API key. |
|
||||
| `--no-is-public` | No | Send --is-public as false. |
|
||||
|
||||
|
||||
@@ -50,12 +50,12 @@ sim workflows operations apply <workflowId> [options]
|
||||
| --- | --- | --- |
|
||||
| `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. |
|
||||
| `--no-dry-run` | No | Send --dry-run as false. |
|
||||
| `--operations <json\|@file>` | Yes | Edits to apply, in a single batch. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--operations <json\|@file>` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. |
|
||||
| `--no-atomic` | No | Send --atomic as false. |
|
||||
| `--layout <value>` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. |
|
||||
| `--set-block-enabled <json\|@file>` | No | Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
| `--set-block-enabled <json\|@file>` | No | Blocks to enable or disable, applied after --operations: [{"block_id":"<uuid>","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined (JSON, or @path / @- to read a file or stdin). |
|
||||
| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -81,7 +81,7 @@ sim workflows variables update <workflowId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--operations <json\|@file>` | Yes | Variable changes to apply, in order. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--operations <json\|@file>` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
</CommandTable>
|
||||
@@ -514,6 +514,7 @@ sim workflows run <workflowId> [options]
|
||||
| `--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 <value>` | 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. |
|
||||
| `--run-id <value>` | No | One-shot identifier for this run; NOT an idempotency key — reusing a claimed value fails with RUN_ID_CONFLICT instead of replaying the first result, and a fresh value starts another run. |
|
||||
| `--manual` | No | Run the current saved workflow state instead of the active deployment. |
|
||||
| `--trigger <blockId>` | No | Enter a manual run through this runnable trigger (requires --manual). |
|
||||
| `--mock-payload` | No | Use the selected trigger's server-derived mock payload (requires --manual). |
|
||||
@@ -644,7 +645,7 @@ sim workflows state replace <workflowId> [options]
|
||||
| `--loops <json\|@file>` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--parallels <json\|@file>` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `--variables <json\|@file>` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -715,7 +716,7 @@ sim workflows versions update <workflowId> <version> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--name <value>` | No | New label for the deployment version. |
|
||||
| `--description <value>` | No | New release note for the deployment version, or null to clear it. |
|
||||
| `--description <value>` | No | New release note for the deployment version, or null to clear it. (--description null sends the word, not JSON null). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -774,7 +775,7 @@ sim workflows move [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--workflow <value...>` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). |
|
||||
| `--folder <value>` | Yes | Folder path as shown in the app; the leading / is optional. |
|
||||
| `--to <value>` | Yes | Destination folder path; / moves the workflows to the workspace root. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -843,14 +844,15 @@ sim workflows rollback <workflowId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--version <value>` | No | Deployment version to reactivate. Omit to select the previous active version. |
|
||||
| `--to-version <value>` | No | Deployment version to reactivate. Omit to select the previous active version. |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
## Take a workflow out of deployment
|
||||
|
||||
```bash
|
||||
sim workflows undeploy <workflowId>
|
||||
sim workflows undeploy <workflowId> [options]
|
||||
```
|
||||
|
||||
**Arguments**
|
||||
@@ -863,6 +865,16 @@ sim workflows undeploy <workflowId>
|
||||
|
||||
</CommandTable>
|
||||
|
||||
**Options**
|
||||
|
||||
<CommandTable>
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `-y, --yes` | Yes | Confirm this destructive operation. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
## Update workflow
|
||||
|
||||
```bash
|
||||
@@ -886,7 +898,7 @@ sim workflows update <workflowId> [options]
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--name <value>` | No | Replacement workflow name. |
|
||||
| `--description <value>` | No | Replacement workflow description; null clears it. |
|
||||
| `--description <value>` | No | Replacement workflow description; null clears it. (--description null sends the word, not JSON null). |
|
||||
| `--folder <value>` | No | Folder path as shown in the app; the leading / is optional. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -130,9 +130,9 @@
|
||||
"name": "workspaceId",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Restrict results to one workspace whose payer the caller can inspect.",
|
||||
"description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.",
|
||||
"schema": {
|
||||
"description": "Restrict results to one workspace whose payer the caller can inspect.",
|
||||
"description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
@@ -722,9 +722,14 @@
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["user", "workspace"],
|
||||
"description": "Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
"required": ["data", "nextCursor", "scope"],
|
||||
"additionalProperties": false,
|
||||
"title": "Billing log list response",
|
||||
"description": "A cursor-paginated page of credit-consuming usage events.",
|
||||
@@ -741,7 +746,8 @@
|
||||
"creditCost": 12
|
||||
}
|
||||
],
|
||||
"nextCursor": null
|
||||
"nextCursor": null,
|
||||
"scope": "workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1549,12 +1549,12 @@
|
||||
{
|
||||
"name": "organizationId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Organization whose audit trail should be queried.",
|
||||
"required": false,
|
||||
"description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.",
|
||||
"schema": {
|
||||
"description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Organization whose audit trail should be queried."
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1634,12 +1634,12 @@
|
||||
{
|
||||
"name": "organizationId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Organization whose audit-log entry should be returned.",
|
||||
"required": false,
|
||||
"description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.",
|
||||
"schema": {
|
||||
"description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Organization whose audit-log entry should be returned."
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1517,7 +1517,7 @@
|
||||
"put": {
|
||||
"operationId": "bulkSaveKnowledgeTagDefinitions",
|
||||
"summary": "Bulk Save Tag Definitions",
|
||||
"description": "Declare, in one request, several of the knowledge base's tag definitions. `POST` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in `originalDisplayName`; that is the only form that edits one in place. Without it the entry is a create, and a requested `tagSlot` another name already holds is not overwritten — the definition is created in the next free slot of its `fieldType` instead, so read the returned entry for the slot actually assigned. A create whose `displayName` already exists is refused in `errors`. Per-definition failures are reported in `errors` and still answer `200`. This writes the vocabulary, not one document's tag values — set those with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"description": "Declare, in one request, several of the knowledge base's tag definitions. `POST` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in `originalDisplayName`; that is the only form that edits one in place. Without it the entry is a create, and a requested `tagSlot` another name already holds is refused in `errors` — it is neither overwritten nor relocated to a different slot, so an explicitly requested slot always means that slot or an error. A create whose `displayName` already exists is refused in `errors`. Per-definition failures are reported in `errors` and still answer `200`. This writes the vocabulary, not one document's tag values — set those with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -8248,6 +8248,36 @@
|
||||
"title": "Upload table import source",
|
||||
"description": "CSV file uploaded through signed transfer instructions."
|
||||
},
|
||||
"V2TableImportRejectedSample": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "CSV parser error code, e.g. CSV_QUOTE_NOT_CLOSED."
|
||||
},
|
||||
"line": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "1-based source line the parser had reached when it gave up, or null."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Parser message for the dropped record."
|
||||
}
|
||||
},
|
||||
"required": ["code", "line", "message"],
|
||||
"additionalProperties": false,
|
||||
"title": "Rejected import record",
|
||||
"description": "One source record a table import could not read."
|
||||
},
|
||||
"V2UploadBackedTableImport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8334,6 +8364,25 @@
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Rows processed so far."
|
||||
},
|
||||
"rowsRejected": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Lower bound on the source records the CSV parser could not read and dropped, counted as one per parser failure. A single failure can discard more than one record — an unterminated quote swallows the rest of the file and is reported once — so the true loss may be larger. Non-zero means the import is partial even when the status is completed; zero is not a guarantee that nothing was dropped."
|
||||
},
|
||||
"cellsRejected": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Non-empty cell values the target column type could not represent. Their rows were imported with the cell left blank."
|
||||
},
|
||||
"rejectedSamples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/V2TableImportRejectedSample"
|
||||
},
|
||||
"description": "Bounded sample of the dropped records, for locating them in the source file."
|
||||
},
|
||||
"error": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8379,6 +8428,9 @@
|
||||
"target",
|
||||
"tableId",
|
||||
"rowsProcessed",
|
||||
"rowsRejected",
|
||||
"cellsRejected",
|
||||
"rejectedSamples",
|
||||
"error",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -8554,6 +8606,25 @@
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Rows processed so far."
|
||||
},
|
||||
"rowsRejected": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Lower bound on the source records the CSV parser could not read and dropped, counted as one per parser failure. A single failure can discard more than one record — an unterminated quote swallows the rest of the file and is reported once — so the true loss may be larger. Non-zero means the import is partial even when the status is completed; zero is not a guarantee that nothing was dropped."
|
||||
},
|
||||
"cellsRejected": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Non-empty cell values the target column type could not represent. Their rows were imported with the cell left blank."
|
||||
},
|
||||
"rejectedSamples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/V2TableImportRejectedSample"
|
||||
},
|
||||
"description": "Bounded sample of the dropped records, for locating them in the source file."
|
||||
},
|
||||
"error": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8599,6 +8670,9 @@
|
||||
"target",
|
||||
"tableId",
|
||||
"rowsProcessed",
|
||||
"rowsRejected",
|
||||
"cellsRejected",
|
||||
"rejectedSamples",
|
||||
"error",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -8876,6 +8950,25 @@
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Rows processed so far."
|
||||
},
|
||||
"rowsRejected": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Lower bound on the source records the CSV parser could not read and dropped, counted as one per parser failure. A single failure can discard more than one record — an unterminated quote swallows the rest of the file and is reported once — so the true loss may be larger. Non-zero means the import is partial even when the status is completed; zero is not a guarantee that nothing was dropped."
|
||||
},
|
||||
"cellsRejected": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Non-empty cell values the target column type could not represent. Their rows were imported with the cell left blank."
|
||||
},
|
||||
"rejectedSamples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/V2TableImportRejectedSample"
|
||||
},
|
||||
"description": "Bounded sample of the dropped records, for locating them in the source file."
|
||||
},
|
||||
"error": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8921,6 +9014,9 @@
|
||||
"target",
|
||||
"tableId",
|
||||
"rowsProcessed",
|
||||
"rowsRejected",
|
||||
"cellsRejected",
|
||||
"rejectedSamples",
|
||||
"error",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
|
||||
@@ -2787,9 +2787,9 @@
|
||||
"name": "selectedOutputs",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.",
|
||||
"description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a selector that is not headed by a block id answers `400` instead of an empty `blockOutputs`.",
|
||||
"schema": {
|
||||
"description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.",
|
||||
"description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a selector that is not headed by a block id answers `400` instead of an empty `blockOutputs`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -8962,7 +8962,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"selectedOutputs": {
|
||||
"description": "Block output references to include in a streamed response. Rejected when `async` is true.",
|
||||
"description": "Block output references to include in a streamed response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the live workflow). Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.",
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -65,8 +65,10 @@ describe('v2 audit-log routes', () => {
|
||||
mocks.get.mockResolvedValue({ log })
|
||||
})
|
||||
|
||||
it('authenticates and rate-limits before validating organization input', async () => {
|
||||
const response = await listLogs(new NextRequest('http://localhost:3000/api/v2/audit-logs'))
|
||||
it('authenticates and rate-limits before validating query input', async () => {
|
||||
const response = await listLogs(
|
||||
new NextRequest('http://localhost:3000/api/v2/audit-logs?organisationId=org-1')
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(v2RouteMocks.authenticate).toHaveBeenCalled()
|
||||
@@ -74,6 +76,23 @@ describe('v2 audit-log routes', () => {
|
||||
expect(mocks.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* No API-key-reachable surface publishes an organization id, so a required
|
||||
* `organizationId` made the resource unreachable from a key. The use case
|
||||
* derives it from the caller, which means the route has to let it through
|
||||
* unset rather than refusing the request itself.
|
||||
*/
|
||||
it('admits a request that names no organization', async () => {
|
||||
const response = await listLogs(new NextRequest('http://localhost:3000/api/v2/audit-logs'))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mocks.list).toHaveBeenCalledWith({
|
||||
principal: auth.principal,
|
||||
input: expect.objectContaining({ organizationId: undefined }),
|
||||
request: expect.anything(),
|
||||
})
|
||||
})
|
||||
|
||||
it('maps list filters into the authorized application operation', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com'
|
||||
@@ -204,6 +223,42 @@ describe('v2 audit-log routes', () => {
|
||||
expect(mocks.list).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* `organizationId` selects nothing: an account belongs to at most one
|
||||
* organization, so naming it and letting it be derived are two spellings of
|
||||
* one sequence. Binding the cursor to the spelling refused the genuine next
|
||||
* page the moment a caller started supplying the id mid-walk.
|
||||
*/
|
||||
it.each([
|
||||
['derived then named', '', 'organizationId=org-1&'],
|
||||
['named then derived', 'organizationId=org-1&', ''],
|
||||
])(
|
||||
'resumes a cursor across both spellings of the organization (%s)',
|
||||
async (_l, minting, replaying) => {
|
||||
const minted = await listLogs(
|
||||
new NextRequest(
|
||||
`http://localhost:3000/api/v2/audit-logs?${minting}actorEmail=ada%40example.com`
|
||||
)
|
||||
)
|
||||
const { nextCursor } = await minted.json()
|
||||
expect(nextCursor).toEqual(expect.any(String))
|
||||
|
||||
mocks.list.mockClear()
|
||||
const resumed = await listLogs(
|
||||
new NextRequest(
|
||||
`http://localhost:3000/api/v2/audit-logs?${replaying}actorEmail=ada%40example.com&cursor=${encodeURIComponent(nextCursor)}`
|
||||
)
|
||||
)
|
||||
|
||||
expect(resumed.status).toBe(200)
|
||||
expect(mocks.list).toHaveBeenCalledWith({
|
||||
principal: auth.principal,
|
||||
input: expect.objectContaining({ cursor: 'next-1' }),
|
||||
request: expect.anything(),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('still refuses a cursor replayed under a different resourceType set', async () => {
|
||||
const minted = await listLogs(
|
||||
new NextRequest(
|
||||
|
||||
@@ -16,9 +16,20 @@ import { auditLogOperations } from '@/lib/audit-logs/application/operations'
|
||||
import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format'
|
||||
import { encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response'
|
||||
|
||||
/** Every param that changes which audit entries, in which order, this list returns. */
|
||||
/**
|
||||
* Every param that changes which audit entries, in which order, this list
|
||||
* returns.
|
||||
*
|
||||
* `organizationId` is deliberately absent. It is asserted scope rather than a
|
||||
* filter: an account belongs to at most one organization, so the only value
|
||||
* that can be authorized for a given caller is that one organization, whether
|
||||
* the caller spells it out or lets it be derived. Fingerprinting the spelling
|
||||
* bound the cursor to it, so a walk that started with the param omitted was
|
||||
* refused the moment the caller added it — two names for one sequence. The
|
||||
* resolved id is not available here: it is decided inside the application use
|
||||
* case, and a route adapter must not resolve it itself.
|
||||
*/
|
||||
function auditLogCursorFilters(query: {
|
||||
organizationId: string
|
||||
includeDeparted: boolean
|
||||
action?: string
|
||||
resourceType?: string
|
||||
@@ -29,7 +40,6 @@ function auditLogCursorFilters(query: {
|
||||
endDate?: string
|
||||
}) {
|
||||
return cursorScopeKey(cursorRoute(v2ListAuditLogsContract), {
|
||||
organizationId: query.organizationId,
|
||||
includeDeparted: query.includeDeparted,
|
||||
action: query.action,
|
||||
resourceType: unorderedScopePart(query.resourceType),
|
||||
@@ -47,9 +57,9 @@ export const revalidate = 0
|
||||
/**
|
||||
* GET /api/v2/audit-logs
|
||||
*
|
||||
* Lists audit logs scoped to an explicitly selected organization. Audit logs
|
||||
* are personal-key-only because a workspace-scoped key must never expand into
|
||||
* organization-wide visibility.
|
||||
* Lists audit logs scoped to one organization — the one the caller named, or
|
||||
* its own when it named none. Audit logs are personal-key-only because a
|
||||
* workspace-scoped key must never expand into organization-wide visibility.
|
||||
*/
|
||||
export const GET = defineV2JsonRoute({
|
||||
contract: v2ListAuditLogsContract,
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('GET /api/v2/billing/logs', () => {
|
||||
pagination: { hasMore: false },
|
||||
},
|
||||
creditsByLogId: { 'log-1': 12 },
|
||||
scope: 'user',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,6 +94,7 @@ describe('GET /api/v2/billing/logs', () => {
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
scope: 'user',
|
||||
})
|
||||
expect(mocks.execute).toHaveBeenCalledWith({
|
||||
principal: auth.principal,
|
||||
@@ -104,6 +106,24 @@ describe('GET /api/v2/billing/logs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Two key kinds answer two different questions over the same query, and the
|
||||
* row sets are otherwise indistinguishable — a personal key's page is a strict
|
||||
* subset. `scope` is what lets a caller tell which one it received.
|
||||
*/
|
||||
it('publishes the scope the ledger read answered', async () => {
|
||||
mocks.execute.mockResolvedValueOnce({
|
||||
usage: { logs: [], summary: { totalCost: 0, bySource: {} }, pagination: { hasMore: false } },
|
||||
creditsByLogId: {},
|
||||
scope: 'workspace',
|
||||
})
|
||||
|
||||
const response = await GET(new NextRequest('http://localhost:3000/api/v2/billing/logs'))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ data: [], nextCursor: null, scope: 'workspace' })
|
||||
})
|
||||
|
||||
it('projects an unresolvable cursor as a 400 rather than an unpositioned first page', async () => {
|
||||
mocks.execute.mockRejectedValueOnce(
|
||||
new OrchestrationError('validation', UNKNOWN_CURSOR_MESSAGE)
|
||||
|
||||
@@ -60,7 +60,8 @@ export const GET = defineV2JsonRoute({
|
||||
}
|
||||
},
|
||||
useCase: listBillingLogs,
|
||||
present: ({ usage, creditsByLogId }, { query }) => ({
|
||||
present: ({ usage, creditsByLogId, scope }, { query }) => ({
|
||||
scope,
|
||||
data: usage.logs.map((log) => ({
|
||||
id: log.id,
|
||||
createdAt: log.createdAt,
|
||||
|
||||
@@ -62,6 +62,9 @@ describe('POST /api/v2/tables/imports/[importId]/complete', () => {
|
||||
target: { type: 'new', name: 'imported_data' },
|
||||
tableId: 'table-1',
|
||||
rowsProcessed: 2,
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 0,
|
||||
rejectedSamples: [],
|
||||
error: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
|
||||
@@ -63,6 +63,9 @@ describe('POST /api/v2/tables/imports', () => {
|
||||
target: { type: 'new', name: 'imported_data' },
|
||||
tableId: null,
|
||||
rowsProcessed: 0,
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 0,
|
||||
rejectedSamples: [],
|
||||
error: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
@@ -89,6 +92,9 @@ describe('POST /api/v2/tables/imports', () => {
|
||||
target: { type: 'new', name: 'imported_data' },
|
||||
tableId: 'table-1',
|
||||
rowsProcessed: 0,
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 0,
|
||||
rejectedSamples: [],
|
||||
error: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
|
||||
@@ -25,6 +25,9 @@ const importRecord = {
|
||||
tableId: 'table-1',
|
||||
status: 'running' as const,
|
||||
rowsProcessed: 2,
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 0,
|
||||
rejectedSamples: [],
|
||||
error: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
@@ -56,6 +59,9 @@ describe('v2 table presenters', () => {
|
||||
target: importRecord.target,
|
||||
tableId: 'table-1',
|
||||
rowsProcessed: 2,
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 0,
|
||||
rejectedSamples: [],
|
||||
error: null,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: createdAt.toISOString(),
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock('@/lib/mcp/queries', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/mcp/queries')>()),
|
||||
getWorkflowMcpServerById: mocks.getServer,
|
||||
getLiveWorkflowMcpTool: mocks.getLiveTool,
|
||||
getWorkflowMcpToolIncludingArchived: mocks.getLiveTool,
|
||||
listLiveWorkflowMcpTools: mocks.listTools,
|
||||
getWorkflowMcpPublishableWorkflow: mocks.getWorkflow,
|
||||
}))
|
||||
|
||||
@@ -590,6 +590,14 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
|
||||
expect(mockPreprocessExecution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects selectedOutputs on a sync request rather than ignoring it', async () => {
|
||||
const res = await callExecute({ selectedOutputs: ['agent_1.content'] })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error.message).toContain('selectedOutputs requires stream: true')
|
||||
expect(mockPreprocessExecution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['includeThinking', 'includeToolCalls'])(
|
||||
'rejects %s unless stream is true before checking the protocol header',
|
||||
async (option) => {
|
||||
|
||||
@@ -256,6 +256,19 @@ export const POST = withRouteHandler(
|
||||
'Async execution does not support streaming or output-shaping options'
|
||||
)
|
||||
}
|
||||
/**
|
||||
* `selectedOutputs` shapes the streamed envelope only — the sync path
|
||||
* returns the workflow's own final output and never reads it. Accepting
|
||||
* it silently answered a full, unselected body to a caller who believed
|
||||
* they had narrowed it, so the option is refused where it does nothing
|
||||
* and the two paths that honour selection are named instead.
|
||||
*/
|
||||
if (body.selectedOutputs?.length && !body.stream) {
|
||||
return v2Error(
|
||||
'BAD_REQUEST',
|
||||
'selectedOutputs requires stream: true. For a completed run, request the run resource with ?selectedOutputs= instead.'
|
||||
)
|
||||
}
|
||||
const hasAgentStreamOptions = hasAgentStreamPolicy({
|
||||
includeThinking: body.includeThinking,
|
||||
includeToolCalls: body.includeToolCalls,
|
||||
|
||||
@@ -951,6 +951,55 @@ describe('AgentBlockHandler', () => {
|
||||
expect(forceTool.usageControl).toBe('force')
|
||||
})
|
||||
|
||||
/**
|
||||
* `schema.function.name` has no uniqueness constraint — only the tool title
|
||||
* does — so two custom tools can declare the same one. The model-facing
|
||||
* function name is the tool's `id` (`custom_<title>`), never that field, so
|
||||
* the declarations stay distinguishable at the agent boundary.
|
||||
*/
|
||||
it('keeps two custom tools distinguishable when their schemas declare one name', async () => {
|
||||
const declaration = (name: string) => ({
|
||||
function: {
|
||||
name,
|
||||
description: 'Collides on the declared function name',
|
||||
parameters: { type: 'object', properties: { input: { type: 'string' } } },
|
||||
},
|
||||
})
|
||||
const inputs = {
|
||||
model: 'gpt-4o',
|
||||
userPrompt: 'Use the tools provided.',
|
||||
apiKey: 'test-api-key',
|
||||
tools: [
|
||||
{
|
||||
type: 'custom-tool',
|
||||
title: 'First Tool',
|
||||
code: 'return {}',
|
||||
schema: declaration('collides'),
|
||||
usageControl: 'auto' as const,
|
||||
},
|
||||
{
|
||||
type: 'custom-tool',
|
||||
title: 'Second Tool',
|
||||
code: 'return {}',
|
||||
schema: declaration('collides'),
|
||||
usageControl: 'auto' as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
const tools = mockExecuteProviderRequest.mock.calls[0][1].tools as Array<{ id: string }>
|
||||
expect(tools.length).toBe(2)
|
||||
expect(new Set(tools.map((tool) => tool.id)).size).toBe(2)
|
||||
expect(tools.map((tool) => tool.id).sort()).toEqual([
|
||||
'custom_First Tool',
|
||||
'custom_Second Tool',
|
||||
])
|
||||
})
|
||||
|
||||
it('should filter out tools with usageControl set to "none"', async () => {
|
||||
const inputs = {
|
||||
model: 'gpt-4o',
|
||||
|
||||
@@ -141,7 +141,6 @@ const FULL_SET_LISTS = [
|
||||
*/
|
||||
const CURSOR_BINDINGS: Record<string, readonly string[]> = {
|
||||
'GET /api/v2/audit-logs': [
|
||||
'organizationId',
|
||||
'includeDeparted',
|
||||
'action',
|
||||
'resourceType',
|
||||
@@ -300,6 +299,10 @@ const CURSOR_BOUND_PATH_PARAMS: Record<string, readonly string[]> = {
|
||||
* correctness gain.
|
||||
*/
|
||||
const UNBOUND_PARAMS: Record<string, Record<string, string>> = {
|
||||
'GET /api/v2/audit-logs': {
|
||||
organizationId:
|
||||
'Asserted scope, not a filter: an account belongs to at most one organization, so naming it and omitting it select the same sequence. The resolved id is decided inside the application use case, so the route cannot stamp it without resolving an authorization decision itself.',
|
||||
},
|
||||
'GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks': {
|
||||
workspaceId:
|
||||
'Asserted scope, not a filter: the sequence is one document, named by the path. A mismatched workspace is refused by authorization before paging.',
|
||||
@@ -744,6 +747,21 @@ describe('v2 list pagination split', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* A param declared in both maps reads as bound while the route exempts it —
|
||||
* exactly the drift this pair of declarations exists to prevent, and the one
|
||||
* shape the accepted-param checks above cannot see, since both maps are
|
||||
* compared only against what the contract accepts.
|
||||
*/
|
||||
it('never both binds and exempts the same param', () => {
|
||||
for (const [key, bound] of Object.entries(CURSOR_BINDINGS)) {
|
||||
expect(
|
||||
bound.filter((param) => param in (UNBOUND_PARAMS[key] ?? {})),
|
||||
`${key} declares a param as cursor-bound and as deliberately unbound at once.`
|
||||
).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every unbound param a non-empty reason', () => {
|
||||
for (const [key, exemptions] of Object.entries(UNBOUND_PARAMS)) {
|
||||
for (const [param, reason] of Object.entries(exemptions)) {
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
/**
|
||||
* v2 audit-logs contracts. These are org-scoped enterprise endpoints. The
|
||||
* filters are inherited from v1, with an explicit organization selector added
|
||||
* so callers never depend on whichever membership happens to be returned
|
||||
* first. The response uses the canonical v2 envelope and drops the v1 `limits`
|
||||
* body — usage limits live on their dedicated endpoint.
|
||||
* so a caller that already knows its organization id can name it rather than
|
||||
* rely on the derivation. The selector stays optional so a caller can reach the
|
||||
* resource without an id no API-key surface publishes. The response uses the
|
||||
* canonical v2 envelope and drops the v1 `limits` body — usage limits live on
|
||||
* their dedicated endpoint.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -120,9 +122,22 @@ export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema
|
||||
.optional()
|
||||
.default(false),
|
||||
...v2PaginationFields({ description: 'Maximum audit entries to return per page.' }),
|
||||
organizationId: organizationIdSchema.describe(
|
||||
'Organization whose audit trail should be queried.'
|
||||
),
|
||||
/**
|
||||
* Optional, because nothing an API key can reach publishes an organization
|
||||
* id: the audit rows themselves carry none, `GET /api/organizations` is
|
||||
* session-gated, and the admin organization list needs an admin key. A
|
||||
* required-and-undiscoverable param made the whole resource unreachable
|
||||
* from a key, so it is derived from the caller the way v1 has always
|
||||
* derived it. An account belongs to at most one organization, so the
|
||||
* derivation has exactly one candidate; a caller in none is refused with a
|
||||
* 403, and one that names an organization it does not belong to is refused
|
||||
* with a 403 as well.
|
||||
*/
|
||||
organizationId: organizationIdSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403."
|
||||
),
|
||||
actorEmail: z.email().optional().describe('Filter by actor email address.'),
|
||||
})
|
||||
.strict()
|
||||
@@ -133,9 +148,12 @@ export const v2AuditLogParamsSchema = v1AuditLogParamsSchema.omit({ id: true }).
|
||||
|
||||
export const v2GetAuditLogQuerySchema = z
|
||||
.object({
|
||||
organizationId: organizationIdSchema.describe(
|
||||
'Organization whose audit-log entry should be returned.'
|
||||
),
|
||||
/** Derived from the caller when omitted — see {@link v2ListAuditLogsQuerySchema}. */
|
||||
organizationId: organizationIdSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403."
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { v2BillingLogsQuerySchema } from '@/lib/api/contracts/v2/billing'
|
||||
|
||||
function issuesFor(query: Record<string, unknown>): string[] {
|
||||
const parsed = v2BillingLogsQuerySchema.safeParse(query)
|
||||
if (parsed.success) return []
|
||||
return parsed.error.issues.map((issue) => issue.message)
|
||||
}
|
||||
|
||||
describe('v2 billing logs query schema', () => {
|
||||
/**
|
||||
* The ordering rule is a question about two instants. A bound that is not a
|
||||
* timestamp at all parses to `NaN`, which compares false against everything,
|
||||
* so the caller used to be told its window was inverted on top of the issue
|
||||
* naming the value it actually has to fix.
|
||||
*/
|
||||
it('does not add an ordering issue to a bound that is not a timestamp', () => {
|
||||
const messages = issuesFor({
|
||||
period: 'custom',
|
||||
startDate: 'not-a-date',
|
||||
endDate: '2026-08-01T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(messages).not.toContain('startDate must be before or equal to endDate')
|
||||
expect(messages.some((message) => message.includes('UTC ISO 8601'))).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* `Date.parse` is wider than the bound schema: it happily reads a UTC offset
|
||||
* instead of `Z`, and year `0000`. Such a bound is rejected by its own format
|
||||
* check yet still yields a number, so the ordering rule has to gate on the
|
||||
* bound schema rather than on parseability.
|
||||
*/
|
||||
it.each([
|
||||
['a UTC offset instead of Z', '2026-08-01T00:00:00+02:00'],
|
||||
['year 0000', '0000-01-01T00:00:00Z'],
|
||||
])('does not add an ordering issue to a bound rejected for %s', (_label, endDate) => {
|
||||
const messages = issuesFor({
|
||||
period: 'custom',
|
||||
startDate: '2026-08-06T00:00:00Z',
|
||||
endDate,
|
||||
})
|
||||
|
||||
expect(messages).not.toContain('startDate must be before or equal to endDate')
|
||||
expect(messages).not.toEqual([])
|
||||
})
|
||||
|
||||
it('still rejects an inverted window whose bounds are both real instants', () => {
|
||||
expect(
|
||||
issuesFor({
|
||||
period: 'custom',
|
||||
startDate: '2026-08-02T00:00:00Z',
|
||||
endDate: '2026-08-01T00:00:00Z',
|
||||
})
|
||||
).toContain('startDate must be before or equal to endDate')
|
||||
})
|
||||
|
||||
it('accepts a window in order', () => {
|
||||
expect(
|
||||
issuesFor({
|
||||
period: 'custom',
|
||||
startDate: '2026-08-01T00:00:00Z',
|
||||
endDate: '2026-08-02T00:00:00Z',
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -145,13 +145,35 @@ export const v2GetBillingStatusContract = defineRouteContract({
|
||||
* holding a cursor from another environment or a wiped ledger fails loudly instead
|
||||
* of looping over page 1 and counting the same credits on every lap.
|
||||
*/
|
||||
/**
|
||||
* The ledger's window bounds, hoisted so the ordering refinement below can ask
|
||||
* the *same* schema whether a bound is a usable instant. `Date.parse` is a
|
||||
* strictly wider parser than this one — it accepts a UTC offset instead of `Z`,
|
||||
* and it accepts year `0000` — so a bound this schema has already rejected can
|
||||
* still yield a number and drag the ordering comparison into answering a
|
||||
* question about a value the caller was just told is not acceptable.
|
||||
*/
|
||||
const billingWindowStartSchema = v2RunWindowBoundSchema('startDate').describe(
|
||||
'Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. 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.'
|
||||
)
|
||||
const billingWindowEndSchema = v2RunWindowBoundSchema('endDate').describe(
|
||||
'Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. 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.'
|
||||
)
|
||||
|
||||
export const v2BillingLogsQuerySchema = z
|
||||
.object({
|
||||
source: usageLogSourceSchema.optional().describe('Restrict results to one usage source.'),
|
||||
/** See {@link v2BillingStatusQuerySchema}'s `workspaceId` — same pinning rules. */
|
||||
/**
|
||||
* See {@link v2BillingStatusQuerySchema}'s `workspaceId` — same pinning rules.
|
||||
*
|
||||
* This narrows the rows; it does not change *whose* rows they are. That is
|
||||
* decided by the kind of key, and the response reports it as `scope`.
|
||||
*/
|
||||
workspaceId: workspaceIdSchema
|
||||
.optional()
|
||||
.describe('Restrict results to one workspace whose payer the caller can inspect.'),
|
||||
.describe(
|
||||
"Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers."
|
||||
),
|
||||
period: usageLogPeriodSchema
|
||||
.optional()
|
||||
.default('30d')
|
||||
@@ -159,17 +181,9 @@ export const v2BillingLogsQuerySchema = z
|
||||
'Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.'
|
||||
),
|
||||
/** Required when `period` is `'custom'`, and rejected otherwise. */
|
||||
startDate: v2RunWindowBoundSchema('startDate')
|
||||
.describe(
|
||||
'Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. 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.'
|
||||
)
|
||||
.optional(),
|
||||
startDate: billingWindowStartSchema.optional(),
|
||||
/** Defaults to now when omitted for `'custom'`; rejected for every other period. */
|
||||
endDate: v2RunWindowBoundSchema('endDate')
|
||||
.describe(
|
||||
'Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. 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.'
|
||||
)
|
||||
.optional(),
|
||||
endDate: billingWindowEndSchema.optional(),
|
||||
...v2PaginationFields({ description: 'Maximum usage events per page.' }),
|
||||
})
|
||||
.strict()
|
||||
@@ -203,10 +217,26 @@ export const v2BillingLogsQuerySchema = z
|
||||
* `createdAt >= start AND createdAt <= end` produces.
|
||||
*/
|
||||
.refine(
|
||||
(query) =>
|
||||
!query.startDate ||
|
||||
!query.endDate ||
|
||||
Date.parse(query.startDate) <= Date.parse(query.endDate),
|
||||
(query) => {
|
||||
if (!query.startDate || !query.endDate) return true
|
||||
/**
|
||||
* A bound that already failed its own format check still reaches this
|
||||
* comparison — an object refinement runs whatever its shape reported.
|
||||
* Ordering is a question about two instants, so it can only be asked once
|
||||
* both bounds *are* instants by this contract's definition; otherwise the
|
||||
* caller is told its window is inverted on top of the issue naming the
|
||||
* value that was not acceptable in the first place. The gate is the bound
|
||||
* schema itself rather than `Date.parse`, which accepts shapes this
|
||||
* contract rejects — an offset instead of `Z`, or year `0000`.
|
||||
*/
|
||||
if (
|
||||
!billingWindowStartSchema.safeParse(query.startDate).success ||
|
||||
!billingWindowEndSchema.safeParse(query.endDate).success
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return Date.parse(query.startDate) <= Date.parse(query.endDate)
|
||||
},
|
||||
{
|
||||
error: 'startDate must be before or equal to endDate',
|
||||
path: ['startDate'],
|
||||
@@ -252,12 +282,27 @@ export const v2BillingLogEntrySchema = z
|
||||
})
|
||||
export type V2BillingLogEntry = z.output<typeof v2BillingLogEntrySchema>
|
||||
|
||||
/**
|
||||
* Which question the page answers, reported because the two are otherwise
|
||||
* indistinguishable on the wire. The same workspace, window, and filters return
|
||||
* a strict subset of the rows on `user` scope that they return on `workspace`
|
||||
* scope, and nothing else in the response says which set arrived — a caller
|
||||
* auditing a workspace's spend with a personal key would silently undercount.
|
||||
*/
|
||||
export const v2BillingLogsScopeSchema = z
|
||||
.enum(['user', 'workspace'])
|
||||
.describe(
|
||||
"Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to."
|
||||
)
|
||||
|
||||
export const v2ListBillingLogsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/v2/billing/logs',
|
||||
query: v2BillingLogsQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: v2CursorListResponse(v2BillingLogEntrySchema),
|
||||
schema: v2CursorListResponse(v2BillingLogEntrySchema).extend({
|
||||
scope: v2BillingLogsScopeSchema,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -221,9 +221,12 @@ export const v2KnowledgeTagUsageSchema = z
|
||||
* One tag definition in a bulk save.
|
||||
*
|
||||
* `originalDisplayName` names the definition being renamed. It is how a bulk
|
||||
* save distinguishes "rename the tag in this slot" from "define a new one", and
|
||||
* a slot already holding a definition under a different name is updated rather
|
||||
* than duplicated.
|
||||
* save distinguishes "rename the tag in this slot" from "define a new one". A
|
||||
* slot already holding a definition under a different name is refused in
|
||||
* `errors` rather than having its occupant silently renamed: every document
|
||||
* already tagged through that slot keeps its values, so a rename that was not
|
||||
* asked for would relabel existing data and break saved filters keyed on the
|
||||
* old name. Rename it by naming its current name in `originalDisplayName`.
|
||||
*/
|
||||
export const v2BulkSaveKnowledgeTagDefinitionSchema = z
|
||||
.object({
|
||||
|
||||
@@ -55,6 +55,7 @@ const BILLING_LOGS_EXAMPLE = {
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
scope: 'workspace',
|
||||
} as const
|
||||
|
||||
function billingOperation(
|
||||
|
||||
@@ -192,7 +192,7 @@ export const knowledgeTagOpenApiRoutes = [
|
||||
knowledgeOperation({
|
||||
operationId: 'bulkSaveKnowledgeTagDefinitions',
|
||||
summary: 'Bulk Save Tag Definitions',
|
||||
description: `Declare, in one request, several of the knowledge base's tag definitions. \`POST\` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in \`originalDisplayName\`; that is the only form that edits one in place. Without it the entry is a create, and a requested \`tagSlot\` another name already holds is not overwritten — the definition is created in the next free slot of its \`fieldType\` instead, so read the returned entry for the slot actually assigned. A create whose \`displayName\` already exists is refused in \`errors\`. Per-definition failures are reported in \`errors\` and still answer \`200\`. This writes the vocabulary, not one document's tag values — set those with \`PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`,
|
||||
description: `Declare, in one request, several of the knowledge base's tag definitions. \`POST\` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in \`originalDisplayName\`; that is the only form that edits one in place. Without it the entry is a create, and a requested \`tagSlot\` another name already holds is refused in \`errors\` — it is neither overwritten nor relocated to a different slot, so an explicitly requested slot always means that slot or an error. A create whose \`displayName\` already exists is refused in \`errors\`. Per-definition failures are reported in \`errors\` and still answer \`200\`. This writes the vocabulary, not one document's tag values — set those with \`PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`,
|
||||
errors: RESOURCE_ERRORS,
|
||||
success: { description: 'Definitions created and updated by the save.' },
|
||||
}),
|
||||
|
||||
@@ -2215,6 +2215,28 @@ export const v2TableImportStatusSchema = z.enum([
|
||||
])
|
||||
export type V2TableImportStatus = z.output<typeof v2TableImportStatusSchema>
|
||||
|
||||
/**
|
||||
* One record the CSV parser dropped. `line` locates it in the source file so the
|
||||
* caller can fix the file rather than diff the imported table against it.
|
||||
*/
|
||||
const v2TableImportRejectedSampleSchema = z
|
||||
.object({
|
||||
code: z.string().describe('CSV parser error code, e.g. CSV_QUOTE_NOT_CLOSED.'),
|
||||
line: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.nullable()
|
||||
.describe('1-based source line the parser had reached when it gave up, or null.'),
|
||||
message: z.string().describe('Parser message for the dropped record.'),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
id: 'V2TableImportRejectedSample',
|
||||
title: 'Rejected import record',
|
||||
description: 'One source record a table import could not read.',
|
||||
})
|
||||
|
||||
export const v2TableImportSchema = z
|
||||
.object({
|
||||
id: z.string().describe('Unique table-import identifier.'),
|
||||
@@ -2224,6 +2246,23 @@ export const v2TableImportSchema = z
|
||||
target: v2TableImportTargetSchema.describe('New or existing table import target.'),
|
||||
tableId: z.string().nullable().describe('Resulting or target table identifier.'),
|
||||
rowsProcessed: z.number().int().nonnegative().describe('Rows processed so far.'),
|
||||
rowsRejected: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
'Lower bound on the source records the CSV parser could not read and dropped, counted as one per parser failure. A single failure can discard more than one record — an unterminated quote swallows the rest of the file and is reported once — so the true loss may be larger. Non-zero means the import is partial even when the status is completed; zero is not a guarantee that nothing was dropped.'
|
||||
),
|
||||
cellsRejected: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
'Non-empty cell values the target column type could not represent. Their rows were imported with the cell left blank.'
|
||||
),
|
||||
rejectedSamples: z
|
||||
.array(v2TableImportRejectedSampleSchema)
|
||||
.describe('Bounded sample of the dropped records, for locating them in the source file.'),
|
||||
error: z.string().nullable().describe('Terminal failure reason, or null.'),
|
||||
createdAt: v2TimestampSchema.describe('ISO 8601 creation timestamp.'),
|
||||
updatedAt: v2TimestampSchema.describe('ISO 8601 last-update timestamp.'),
|
||||
|
||||
@@ -1291,7 +1291,7 @@ export const v2ExecuteWorkflowBodySchema = z
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe(
|
||||
'Block output references to include in a streamed response. Rejected when `async` is true.'
|
||||
'Block output references to include in a streamed response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the live workflow). Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.'
|
||||
),
|
||||
includeThinking: z
|
||||
.boolean()
|
||||
@@ -1789,10 +1789,11 @@ export const v2GetWorkflowRunContract = defineRouteContract({
|
||||
* Block *ids*, unlike the execute request's `selectedOutputs`, which also
|
||||
* accepts `BlockName.path` and resolves it against the live workflow. This
|
||||
* resource reads a recorded run and never loads the workflow's blocks, so
|
||||
* a name has no id to resolve to and selects nothing.
|
||||
* a name has no id to resolve to and is refused rather than silently
|
||||
* selecting nothing.
|
||||
*/
|
||||
selectedOutputs: workflowExecutionStatusQuerySchema.shape.selectedOutputs.describe(
|
||||
'Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.'
|
||||
'Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a selector that is not headed by a block id answers `400` instead of an empty `blockOutputs`.'
|
||||
),
|
||||
/**
|
||||
* Allowed here but not on the async execute request, whose rejection is
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveAccess: vi.fn(),
|
||||
resolveDefaultOrganization: vi.fn(),
|
||||
getOrgWorkspaceIds: vi.fn(),
|
||||
buildOrgScopeCondition: vi.fn(),
|
||||
buildFilterConditions: vi.fn(),
|
||||
@@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/lib/audit-logs/authorization', () => ({
|
||||
resolveEnterpriseAuditAccess: mocks.resolveAccess,
|
||||
resolveDefaultAuditOrganization: mocks.resolveDefaultOrganization,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/audit-logs/query', () => ({
|
||||
@@ -53,6 +55,10 @@ describe('audit-log application use cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mocks.resolveDefaultOrganization.mockResolvedValue({
|
||||
kind: 'resolved',
|
||||
organizationId: 'organization-1',
|
||||
})
|
||||
mocks.resolveAccess.mockResolvedValue({
|
||||
success: true,
|
||||
context: { organizationId: 'organization-1', orgMemberIds: ['admin-1'] },
|
||||
@@ -91,6 +97,46 @@ describe('audit-log application use cases', () => {
|
||||
expect(mocks.recordAudit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Nothing an API key can reach publishes an organization id, so a required
|
||||
* `organizationId` made the whole resource unreachable from a key. It is
|
||||
* derived from the caller, which `member`'s per-user unique index keeps to a
|
||||
* single candidate.
|
||||
*/
|
||||
it('derives the organization when the caller named none', async () => {
|
||||
await expect(
|
||||
listAuditLogs.execute({
|
||||
principal: sessionPrincipal,
|
||||
input: { ...listInput, organizationId: undefined },
|
||||
})
|
||||
).resolves.toEqual({ data: [], nextCursor: undefined })
|
||||
|
||||
expect(mocks.resolveDefaultOrganization).toHaveBeenCalledWith('admin-1')
|
||||
expect(mocks.resolveAccess).toHaveBeenCalledWith('admin-1', 'organization-1')
|
||||
})
|
||||
|
||||
it('does not derive an organization the caller named itself', async () => {
|
||||
await listAuditLogs.execute({ principal: sessionPrincipal, input: listInput })
|
||||
|
||||
expect(mocks.resolveDefaultOrganization).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the membership refusal for a caller in no organization', async () => {
|
||||
mocks.resolveDefaultOrganization.mockResolvedValueOnce({ kind: 'none' })
|
||||
|
||||
await expect(
|
||||
getAuditLog.execute({
|
||||
principal: sessionPrincipal,
|
||||
input: { id: 'audit-1' },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
detailCode: 'ORGANIZATION_MEMBERSHIP_REQUIRED',
|
||||
})
|
||||
|
||||
expect(mocks.resolveAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a workspace filter outside the authorized organization', async () => {
|
||||
await expect(
|
||||
listAuditLogs.execute({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Principal } from '@sim/auth/principal'
|
||||
import type { AuditLogOperation, AuditLogPrincipal } from '@/lib/audit-logs/application/operations'
|
||||
import { resolveEnterpriseAuditAccess } from '@/lib/audit-logs/authorization'
|
||||
import {
|
||||
resolveDefaultAuditOrganization,
|
||||
resolveEnterpriseAuditAccess,
|
||||
} from '@/lib/audit-logs/authorization'
|
||||
import { ForbiddenOperationError, type OperationUseCase } from '@/lib/core/application'
|
||||
|
||||
export interface AuthorizedAuditLogContext {
|
||||
@@ -11,7 +14,7 @@ export interface AuthorizedAuditLogContext {
|
||||
|
||||
interface AuthorizedAuditLogDefinition<O extends AuditLogOperation, I, R> {
|
||||
operation: O
|
||||
organizationId(input: I): string
|
||||
organizationId(input: I): string | undefined
|
||||
execute(args: {
|
||||
principal: AuditLogPrincipal
|
||||
input: I
|
||||
@@ -35,6 +38,32 @@ function auditActorUserId(principal: AuditLogPrincipal): string {
|
||||
return principal.userId
|
||||
}
|
||||
|
||||
/**
|
||||
* The organization the read applies to: the one the caller named, or its single
|
||||
* membership when it named none.
|
||||
*
|
||||
* The derivation has no ambiguous case to refuse. `member` carries
|
||||
* `uniqueIndex('member_user_id_unique').on(member.userId)`, so an actor holds
|
||||
* at most one membership row; the caller either has one organization or none.
|
||||
* The lookup is keyed on the caller's own user id, so it can only ever resolve
|
||||
* an organization the caller is already a member of.
|
||||
*/
|
||||
async function resolveOperationOrganizationId(
|
||||
actorUserId: string,
|
||||
requestedOrganizationId: string | undefined
|
||||
): Promise<string> {
|
||||
if (requestedOrganizationId) return requestedOrganizationId
|
||||
|
||||
const resolved = await resolveDefaultAuditOrganization(actorUserId)
|
||||
if (resolved.kind === 'none') {
|
||||
throw new ForbiddenOperationError(
|
||||
'ORGANIZATION_MEMBERSHIP_REQUIRED',
|
||||
'Not a member of any organization'
|
||||
)
|
||||
}
|
||||
return resolved.organizationId
|
||||
}
|
||||
|
||||
export function defineAuthorizedAuditLogUseCase<const O extends AuditLogOperation, I, R>(
|
||||
definition: AuthorizedAuditLogDefinition<O, I, R>
|
||||
): OperationUseCase<O, I, R> {
|
||||
@@ -43,10 +72,11 @@ export function defineAuthorizedAuditLogUseCase<const O extends AuditLogOperatio
|
||||
async execute({ principal, input }) {
|
||||
requireAuditLogPrincipal(principal, definition.operation)
|
||||
const actorUserId = auditActorUserId(principal)
|
||||
const access = await resolveEnterpriseAuditAccess(
|
||||
const organizationId = await resolveOperationOrganizationId(
|
||||
actorUserId,
|
||||
definition.organizationId(input)
|
||||
)
|
||||
const access = await resolveEnterpriseAuditAccess(actorUserId, organizationId)
|
||||
if (!access.success) throw new ForbiddenOperationError(access.code, access.message)
|
||||
return definition.execute({
|
||||
principal,
|
||||
|
||||
@@ -7,7 +7,8 @@ import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/que
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
|
||||
export interface GetAuditLogInput {
|
||||
organizationId: string
|
||||
/** Omitted when the caller belongs to exactly one organization and let it be derived. */
|
||||
organizationId?: string
|
||||
id: string
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
|
||||
export interface ListAuditLogsInput {
|
||||
organizationId: string
|
||||
/** Omitted when the caller belongs to exactly one organization and let it be derived. */
|
||||
organizationId?: string
|
||||
includeDeparted: boolean
|
||||
filters: AuditLogFilterParams
|
||||
limit: number
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/billing/core/access', () => ({ isOrganizationBillingBlocked: vi.fn() }))
|
||||
vi.mock('@/lib/billing/subscriptions/utils', () => ({ USABLE_SUBSCRIPTION_STATUSES: ['active'] }))
|
||||
|
||||
import { resolveDefaultAuditOrganization } from '@/lib/audit-logs/authorization'
|
||||
|
||||
describe('resolveDefaultAuditOrganization', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('resolves the single organization the actor belongs to', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ organizationId: 'organization-1' }])
|
||||
|
||||
await expect(resolveDefaultAuditOrganization('user-1')).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
organizationId: 'organization-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no organization when the actor holds no membership', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
|
||||
await expect(resolveDefaultAuditOrganization('user-1')).resolves.toEqual({ kind: 'none' })
|
||||
})
|
||||
|
||||
/**
|
||||
* The derivation may only ever name the caller's own organization, so the
|
||||
* membership lookup stays keyed on the caller's user id and nothing else.
|
||||
*/
|
||||
it('keys the membership lookup on the caller alone', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ organizationId: 'organization-1' }])
|
||||
|
||||
await resolveDefaultAuditOrganization('user-1')
|
||||
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledWith({
|
||||
type: 'eq',
|
||||
left: schemaMock.member.userId,
|
||||
right: 'user-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,37 @@ export type EnterpriseAuditAccessResult =
|
||||
| { success: true; context: EnterpriseAuditContext }
|
||||
| { success: false; status: 403; code: ForbiddenDetailCode; message: string }
|
||||
|
||||
/**
|
||||
* The organization an actor belongs to, when it did not name one.
|
||||
*
|
||||
* `organizationId` is the only input these endpoints cannot be called without,
|
||||
* and no API-key-reachable surface publishes one — the v1 audit-log rows carry
|
||||
* no organization id, `GET /api/organizations` is session-gated, and the admin
|
||||
* organization list needs an admin key. Deriving it from the caller, the way v1
|
||||
* always has, is what makes the resource reachable at all.
|
||||
*
|
||||
* There is no ambiguous case to represent: `member` carries
|
||||
* `uniqueIndex('member_user_id_unique').on(member.userId)`, so an actor holds
|
||||
* at most one membership row and the derivation is either that row or nothing.
|
||||
*/
|
||||
export type DefaultAuditOrganization =
|
||||
| { kind: 'resolved'; organizationId: string }
|
||||
| { kind: 'none' }
|
||||
|
||||
/** Resolves the organization an actor's audit-log read applies to when it named none. */
|
||||
export async function resolveDefaultAuditOrganization(
|
||||
userId: string
|
||||
): Promise<DefaultAuditOrganization> {
|
||||
const [membership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) return { kind: 'none' }
|
||||
return { kind: 'resolved', organizationId: membership.organizationId }
|
||||
}
|
||||
|
||||
/** Resolves transport-neutral enterprise audit-log access for an organization administrator. */
|
||||
export async function resolveEnterpriseAuditAccess(
|
||||
userId: string,
|
||||
|
||||
@@ -385,7 +385,7 @@ describe('billing application use cases', () => {
|
||||
})
|
||||
|
||||
it('lists the complete workspace ledger for a workspace key', async () => {
|
||||
await listBillingLogs.execute({
|
||||
const result = await listBillingLogs.execute({
|
||||
principal: workspacePrincipal,
|
||||
input: {
|
||||
startDate: new Date('2026-01-01T00:00:00Z'),
|
||||
@@ -401,10 +401,19 @@ describe('billing application use cases', () => {
|
||||
expect(mocks.getUsageLogs).not.toHaveBeenCalled()
|
||||
expect(mocks.resolvePermission).not.toHaveBeenCalled()
|
||||
expect(mocks.recordAudit).not.toHaveBeenCalled()
|
||||
expect(result.scope).toBe('workspace')
|
||||
})
|
||||
|
||||
/**
|
||||
* A personal key reports the person holding it, so naming a workspace narrows
|
||||
* that person's own events rather than opening the whole workspace ledger. The
|
||||
* ledger carries Wand, Chat, voice, enrichment, and knowledge-base spend that
|
||||
* no other surface publishes at the workspace `read` role, so widening this to
|
||||
* the resolved scope would be a privilege expansion, not a fix. The reported
|
||||
* `scope` is what tells the caller which of the two sets it received.
|
||||
*/
|
||||
it('keeps personal-key billing logs actor-scoped and workspace-filtered', async () => {
|
||||
await listBillingLogs.execute({
|
||||
const result = await listBillingLogs.execute({
|
||||
principal: personalPrincipal,
|
||||
input: {
|
||||
workspaceId: 'workspace-1',
|
||||
@@ -419,6 +428,26 @@ describe('billing application use cases', () => {
|
||||
expect.objectContaining({ workspaceId: 'workspace-1', includeSummary: false, limit: 50 })
|
||||
)
|
||||
expect(mocks.getWorkspaceUsageLogs).not.toHaveBeenCalled()
|
||||
expect(result.scope).toBe('user')
|
||||
})
|
||||
|
||||
it('keeps an unscoped personal-key ledger to the calling account', async () => {
|
||||
const result = await listBillingLogs.execute({
|
||||
principal: personalPrincipal,
|
||||
input: {
|
||||
startDate: new Date('2026-01-01T00:00:00Z'),
|
||||
endDate: new Date('2026-02-01T00:00:00Z'),
|
||||
limit: 50,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.getUsageLogs).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ includeSummary: false, limit: 50 })
|
||||
)
|
||||
expect(mocks.getWorkspaceUsageLogs).not.toHaveBeenCalled()
|
||||
expect(mocks.loadWorkspace).not.toHaveBeenCalled()
|
||||
expect(result.scope).toBe('user')
|
||||
})
|
||||
|
||||
it('apportions credits only across the bounded page', async () => {
|
||||
|
||||
@@ -16,16 +16,43 @@ export interface ListBillingLogsInput {
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Which question the returned rows answer. `'workspace'` is the workspace's
|
||||
* whole ledger — every member's events, because the workspace is the payer.
|
||||
* `'user'` is the calling person's own events, optionally narrowed to one
|
||||
* workspace. It is reported on the wire because the two are otherwise
|
||||
* indistinguishable: the same workspace and window can legitimately return
|
||||
* fewer rows on one scope than the other, and a caller reconciling spend needs
|
||||
* to know which set it received.
|
||||
*/
|
||||
export type BillingLogsScope = 'user' | 'workspace'
|
||||
|
||||
export interface ListBillingLogsResult {
|
||||
usage: Awaited<ReturnType<typeof getUserUsageLogs>>
|
||||
creditsByLogId: Record<string, number>
|
||||
scope: BillingLogsScope
|
||||
}
|
||||
|
||||
function apportionLogCredits(usage: ListBillingLogsResult['usage']): Record<string, number> {
|
||||
return apportionCredits(usage.logs.map((log) => ({ key: log.id, dollars: log.cost })))
|
||||
}
|
||||
|
||||
/**
|
||||
* A personal API key reports the person holding it: their own usage events,
|
||||
* narrowed to the workspace they named when they named one. A workspace API key
|
||||
* has no actor behind it, so it reports the workspace itself: every member's
|
||||
* events for the workspace the key is pinned to.
|
||||
*
|
||||
* The two are deliberately different questions, and the answer says which one it
|
||||
* answered via `scope`. Harmonizing them onto the resolved scope would hand any
|
||||
* workspace member holding a personal key every other member's Wand, Chat,
|
||||
* voice, enrichment, and knowledge-base spend, none of which is exposed by any
|
||||
* other surface at this role.
|
||||
*/
|
||||
export const listBillingLogs = defineAuthorizedBillingReadUseCase({
|
||||
operation: billingOperations.listLogs,
|
||||
requestedWorkspaceId: (input: ListBillingLogsInput) => input.workspaceId,
|
||||
execute: async ({ principal, input, scope }): Promise<ListBillingLogsResult> => {
|
||||
const workspaceId = scope.kind === 'workspace' ? scope.workspace.workspaceId : undefined
|
||||
const query = {
|
||||
source: input.source,
|
||||
startDate: input.startDate,
|
||||
@@ -34,18 +61,17 @@ export const listBillingLogs = defineAuthorizedBillingReadUseCase({
|
||||
cursor: input.cursor,
|
||||
includeSummary: false,
|
||||
}
|
||||
let usage: ListBillingLogsResult['usage']
|
||||
if (principal.kind === 'personal_api_key') {
|
||||
usage = await getUserUsageLogs(principal.userId, { ...query, workspaceId })
|
||||
} else {
|
||||
if (scope.kind !== 'workspace') {
|
||||
throw new Error('Workspace API key billing logs require a workspace scope')
|
||||
}
|
||||
usage = await getWorkspaceUsageLogs(scope.workspace.workspaceId, query)
|
||||
const workspaceId = scope.kind === 'workspace' ? scope.workspace.workspaceId : undefined
|
||||
const usage = await getUserUsageLogs(principal.userId, { ...query, workspaceId })
|
||||
return { usage, creditsByLogId: apportionLogCredits(usage), scope: 'user' }
|
||||
}
|
||||
const creditsByLogId = apportionCredits(
|
||||
usage.logs.map((log) => ({ key: log.id, dollars: log.cost }))
|
||||
)
|
||||
return { usage, creditsByLogId }
|
||||
/**
|
||||
* `resolveBillingReadScope` pins a workspace API key to `principal.workspaceId`
|
||||
* whatever the query asked for, and has already loaded and validated that
|
||||
* workspace, so this is the same id the resolved scope carries.
|
||||
*/
|
||||
const usage = await getWorkspaceUsageLogs(principal.workspaceId, query)
|
||||
return { usage, creditsByLogId: apportionLogCredits(usage), scope: 'workspace' }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -639,6 +639,57 @@ describe('userTableServerTool.import_file', () => {
|
||||
expect(result.message).toMatch(/File not found: "files\/typo\.csv"/)
|
||||
})
|
||||
|
||||
/**
|
||||
* A malformed CSV silently loses records; the tool used to report the survivors
|
||||
* as a clean import, so the model had no signal that the file did not land whole.
|
||||
*/
|
||||
it('surfaces rejected records in the message and payload of an append import', async () => {
|
||||
mockDownloadWorkspaceFile.mockResolvedValueOnce(
|
||||
Buffer.from('name,age\nAlice,30\nBroken,"unterminated\nBob,40\n')
|
||||
)
|
||||
|
||||
const result = await userTableServerTool.execute(
|
||||
{ operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } },
|
||||
buildToolContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toMatch(/dropped at least 1 unreadable row/i)
|
||||
expect(result.message).toMatch(/CSV_QUOTE_NOT_CLOSED/)
|
||||
expect(result.data?.rejections).toMatchObject({ rowsRejected: 1 })
|
||||
expect(result.data?.rejections.rejectedSamples[0]).toMatchObject({
|
||||
code: 'CSV_QUOTE_NOT_CLOSED',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces rejected records in the message and payload of a replace import', async () => {
|
||||
mockDownloadWorkspaceFile.mockResolvedValueOnce(
|
||||
Buffer.from('name,age\nAlice,30\nBroken,"unterminated\nBob,40\n')
|
||||
)
|
||||
mockReplaceTableRows.mockResolvedValueOnce({ deletedCount: 3, insertedCount: 1 })
|
||||
|
||||
const result = await userTableServerTool.execute(
|
||||
{ operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' } },
|
||||
buildToolContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toMatch(/dropped at least 1 unreadable row/i)
|
||||
expect(result.data?.rejections).toMatchObject({ rowsRejected: 1 })
|
||||
})
|
||||
|
||||
it("leaves a clean import's message and payload untouched", async () => {
|
||||
const result = await userTableServerTool.execute(
|
||||
{ operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } },
|
||||
buildToolContext()
|
||||
)
|
||||
|
||||
expect(result.message).toBe(
|
||||
'Imported 2 rows into "People" from "people.csv" (2 columns matched)'
|
||||
)
|
||||
expect(result.data).not.toHaveProperty('rejections')
|
||||
})
|
||||
|
||||
it('rejects a background import while another job holds the table slot', async () => {
|
||||
mockResolveWorkspaceFileReference.mockResolvedValueOnce({
|
||||
id: 'file-1',
|
||||
@@ -761,6 +812,34 @@ describe('userTableServerTool.create_from_file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces rejected records alongside the created table', async () => {
|
||||
mockDownloadWorkspaceFile.mockResolvedValueOnce(
|
||||
Buffer.from('name,age\nAlice,30\nBroken,"unterminated\nBob,40\n')
|
||||
)
|
||||
|
||||
const result = await userTableServerTool.execute(
|
||||
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
|
||||
buildToolContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toMatch(/dropped at least 1 unreadable row/i)
|
||||
expect(result.message).toMatch(/CSV_QUOTE_NOT_CLOSED/)
|
||||
expect(result.data?.rejections).toMatchObject({ rowsRejected: 1 })
|
||||
})
|
||||
|
||||
it('leaves a clean create_from_file message and payload untouched', async () => {
|
||||
const result = await userTableServerTool.execute(
|
||||
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
|
||||
buildToolContext()
|
||||
)
|
||||
|
||||
expect(result.message).toBe(
|
||||
'Created table "people" with 2 columns and 2 rows from "people.csv"'
|
||||
)
|
||||
expect(result.data).not.toHaveProperty('rejections')
|
||||
})
|
||||
|
||||
it('rejects unknown workspace-file provenance before creating a table', async () => {
|
||||
mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValueOnce({ status: 'unknown' })
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { readTableViewUseCase } from '@/lib/table/application/views'
|
||||
import { namedRowMapper } from '@/lib/table/cell-format'
|
||||
import { isSupportedCurrencyCode } from '@/lib/table/currency'
|
||||
import type { TableImportRejectionSummary } from '@/lib/table/jobs/service'
|
||||
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
|
||||
import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance'
|
||||
import { normalizeSelectOptionsInput } from '@/lib/table/select-options'
|
||||
@@ -79,6 +80,38 @@ type UserTableResult = {
|
||||
data?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the sentence an import appends when it lost data, mirroring the
|
||||
* `droppedRows` phrasing so the model reads one shape for "the file did not
|
||||
* land whole".
|
||||
*
|
||||
* `rowsRejected` is a FLOOR — one parser failure can discard many source
|
||||
* records and is counted once — so the wording says "at least". Only the first
|
||||
* retained sample is named; the rest ride on `data.rejections` rather than
|
||||
* bloating a model-facing string.
|
||||
*
|
||||
* Returns `''` when nothing was lost, keeping a clean import's message
|
||||
* byte-identical to what it has always been.
|
||||
*/
|
||||
function rejectionSentence(rejections: TableImportRejectionSummary | undefined): string {
|
||||
if (!rejections) return ''
|
||||
const losses: string[] = []
|
||||
if (rejections.rowsRejected > 0) {
|
||||
losses.push(`at least ${rejections.rowsRejected.toLocaleString()} unreadable row(s)`)
|
||||
}
|
||||
if (rejections.cellsRejected > 0) {
|
||||
losses.push(
|
||||
`${rejections.cellsRejected.toLocaleString()} cell value(s) the column type could not store (kept as null)`
|
||||
)
|
||||
}
|
||||
if (losses.length === 0) return ''
|
||||
const sample = rejections.rejectedSamples[0]
|
||||
const firstFailure = sample
|
||||
? ` First failure: ${sample.code}${sample.line === null ? '' : ` at line ${sample.line}`}.`
|
||||
: ''
|
||||
return `. Dropped ${losses.join(' and ')} from the file.${firstFailure} See data.rejections for details.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot's own batch ceiling, deliberately looser than the 1000 the internal
|
||||
* and v2 batch-update contracts declare: Copilot parses no contract, so this is
|
||||
@@ -797,7 +830,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
|
||||
assertNotAborted,
|
||||
})
|
||||
if (result.kind === 'empty') {
|
||||
return { success: false, message: 'File contains no data rows' }
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'File yielded no readable data rows — it is either empty or every record failed to parse.',
|
||||
}
|
||||
}
|
||||
const record = result.sourceFile
|
||||
if (result.kind === 'background') {
|
||||
@@ -814,10 +851,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
|
||||
}
|
||||
|
||||
const createdMessage = `Created table "${result.table.name}" with ${result.columns.length} columns and ${result.insertedCount.toLocaleString()} rows from "${record.name}"`
|
||||
const message =
|
||||
const limitMessage =
|
||||
result.droppedRows > 0
|
||||
? `${createdMessage}. Dropped ${result.droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${result.maxRowsPerTable.toLocaleString()} rows per table.`
|
||||
: createdMessage
|
||||
const message = `${limitMessage}${rejectionSentence(result.rejections)}`
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -831,6 +869,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
|
||||
})),
|
||||
rowCount: result.insertedCount,
|
||||
sourceFile: record.name,
|
||||
...(result.rejections ? { rejections: result.rejections } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -882,14 +921,18 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
|
||||
}
|
||||
}
|
||||
if (result.kind === 'empty') {
|
||||
return { success: false, message: 'File contains no data rows' }
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'File yielded no readable data rows — it is either empty or every record failed to parse.',
|
||||
}
|
||||
}
|
||||
if (result.kind !== 'inline')
|
||||
throw new Error('Inline table import returned a background job')
|
||||
if (result.mode === 'replace') {
|
||||
return {
|
||||
success: true,
|
||||
message: `Replaced rows in "${result.table.name}" from "${result.sourceFileName}": deleted ${result.deletedCount}, inserted ${result.insertedCount}`,
|
||||
message: `Replaced rows in "${result.table.name}" from "${result.sourceFileName}": deleted ${result.deletedCount}, inserted ${result.insertedCount}${rejectionSentence(result.rejections)}`,
|
||||
data: {
|
||||
tableId: result.table.id,
|
||||
tableName: result.table.name,
|
||||
@@ -899,12 +942,13 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
|
||||
deletedCount: result.deletedCount,
|
||||
insertedCount: result.insertedCount,
|
||||
sourceFile: result.sourceFileName,
|
||||
...(result.rejections ? { rejections: result.rejections } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Imported ${result.insertedCount} rows into "${result.table.name}" from "${result.sourceFileName}" (${result.matchedColumns.length} columns matched)`,
|
||||
message: `Imported ${result.insertedCount} rows into "${result.table.name}" from "${result.sourceFileName}" (${result.matchedColumns.length} columns matched)${rejectionSentence(result.rejections)}`,
|
||||
data: {
|
||||
tableId: result.table.id,
|
||||
tableName: result.table.name,
|
||||
@@ -913,6 +957,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
|
||||
skippedColumns: result.skippedColumns,
|
||||
rowCount: result.insertedCount,
|
||||
sourceFile: result.sourceFileName,
|
||||
...(result.rejections ? { rejections: result.rejections } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { customToolDelegationPolicy } from '@/lib/custom-tools/application/authorization'
|
||||
import { customToolOperations } from '@/lib/custom-tools/application/operations'
|
||||
import { assertStorableCustomToolSchema } from '@/lib/custom-tools/schema'
|
||||
import {
|
||||
assertStorableCustomToolSchema,
|
||||
assertValidCustomToolDeclaration,
|
||||
} from '@/lib/custom-tools/schema'
|
||||
import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace'
|
||||
import {
|
||||
type CustomToolSortBy,
|
||||
@@ -271,6 +274,7 @@ export const updateWorkspaceCustomToolUseCase = defineAuthorizedWorkspaceUseCase
|
||||
* write makes that error true.
|
||||
*/
|
||||
assertStorableCustomToolSchema(schema)
|
||||
if (input.schema !== undefined) assertValidCustomToolDeclaration(input.schema)
|
||||
try {
|
||||
const tool = await updateWorkspaceCustomTool({
|
||||
workspaceId: context.workspaceId,
|
||||
@@ -319,7 +323,7 @@ export const updateAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase
|
||||
* schema still succeeds, while a caller-supplied one is held to the shape
|
||||
* the public API has to publish.
|
||||
*/
|
||||
if (input.schema !== undefined) assertStorableCustomToolSchema(input.schema)
|
||||
if (input.schema !== undefined) assertValidCustomToolDeclaration(input.schema)
|
||||
try {
|
||||
const tool = await updateCustomTool({
|
||||
workspaceId: context.workspaceId,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertValidCustomToolDeclaration,
|
||||
isValidCustomToolDeclaration,
|
||||
} from '@/lib/custom-tools/schema'
|
||||
|
||||
function declaration(overrides: {
|
||||
name?: string
|
||||
parametersType?: string
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: overrides.name ?? 'lookup_order',
|
||||
description: 'Look an order up',
|
||||
parameters: {
|
||||
type: overrides.parametersType ?? 'object',
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('assertValidCustomToolDeclaration', () => {
|
||||
it('accepts a declaration a provider can carry', () => {
|
||||
expect(() => assertValidCustomToolDeclaration(declaration({}))).not.toThrow()
|
||||
expect(() =>
|
||||
assertValidCustomToolDeclaration(declaration({ name: 'lookup-order-2' }))
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['has spaces!', 'ünïcode', 'dots.in.name', 'a'.repeat(65)])(
|
||||
'refuses the unusable function name %j',
|
||||
(name) => {
|
||||
expect(() => assertValidCustomToolDeclaration(declaration({ name }))).toThrow(
|
||||
/function\.name/
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses a parameters type that is not an object', () => {
|
||||
expect(() =>
|
||||
assertValidCustomToolDeclaration(declaration({ parametersType: 'banana' }))
|
||||
).toThrow(/function\.parameters\.type/)
|
||||
})
|
||||
|
||||
it('still refuses a declaration the response cannot publish', () => {
|
||||
expect(() => assertValidCustomToolDeclaration({ type: 'function' })).toThrow(
|
||||
/Invalid custom tool schema/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the same verdict as a predicate', () => {
|
||||
expect(isValidCustomToolDeclaration(declaration({}))).toBe(true)
|
||||
expect(isValidCustomToolDeclaration(declaration({ name: 'has spaces!' }))).toBe(false)
|
||||
expect(isValidCustomToolDeclaration(declaration({ parametersType: 'banana' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -13,13 +13,9 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
*
|
||||
* Every writer is therefore held to the read shape here, against the same
|
||||
* schema the response is built from, rather than each write path restating the
|
||||
* check — which is how the two drifted apart in the first place.
|
||||
* check — which is how the two drifted apart in the first place. Raised as a
|
||||
* caller-fixable validation failure.
|
||||
*/
|
||||
export function isStorableCustomToolSchema(schema: unknown): boolean {
|
||||
return customToolSchemaSchema.safeParse(schema).success
|
||||
}
|
||||
|
||||
/** {@link isStorableCustomToolSchema} as a caller-fixable validation failure. */
|
||||
export function assertStorableCustomToolSchema(schema: unknown): void {
|
||||
const parsed = customToolSchemaSchema.safeParse(schema)
|
||||
if (parsed.success) return
|
||||
@@ -30,3 +26,63 @@ export function assertStorableCustomToolSchema(schema: unknown): void {
|
||||
`Invalid custom tool schema${path ? ` at ${path}` : ''}: ${issue?.message ?? 'does not match the published function declaration'}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The charset OpenAI, Anthropic, and MCP all accept for a tool's function name:
|
||||
* letters, digits, underscores and hyphens, 1-64 characters.
|
||||
*/
|
||||
const FUNCTION_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/
|
||||
|
||||
/**
|
||||
* Write-time invariant for a caller-supplied custom tool declaration, layered on
|
||||
* top of {@link assertStorableCustomToolSchema}'s storage/serialization shape.
|
||||
*
|
||||
* `customToolSchemaSchema` only requires a non-empty `function.name` and a
|
||||
* string `parameters.type`, so `"has spaces!"` and `"banana"` were accepted and
|
||||
* stored verbatim — a name no provider will accept in a tool declaration, and a
|
||||
* JSON Schema type that is not an object. That schema is also the shape the v2
|
||||
* surface parses on the way *out*, so it cannot be tightened without failing
|
||||
* reads of rows the old validation already admitted; the stricter rule belongs
|
||||
* here, on the write, and is applied only to a declaration the caller actually
|
||||
* supplied so a legacy row stays editable.
|
||||
*
|
||||
* Rejects rather than normalizes, unlike the workflow MCP tool path's
|
||||
* `sanitizeToolName`. That name is *derived* from a workflow's human-authored
|
||||
* title, which was never an identifier, so coercing it is the only way to get
|
||||
* one. A custom tool's `function.name` is written by the caller as an
|
||||
* identifier and is what the model calls, so silently rewriting it would
|
||||
* desync the stored tool from the caller's own references to it.
|
||||
*/
|
||||
export function assertValidCustomToolDeclaration(schema: unknown): void {
|
||||
assertStorableCustomToolSchema(schema)
|
||||
|
||||
const declaration = (schema as { function: { name: string; parameters: { type: string } } })
|
||||
.function
|
||||
|
||||
if (!FUNCTION_NAME_PATTERN.test(declaration.name)) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
`Invalid custom tool schema at function.name: "${declaration.name}" must be 1-64 characters of letters, digits, underscores, or hyphens`
|
||||
)
|
||||
}
|
||||
|
||||
if (declaration.parameters.type !== 'object') {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
`Invalid custom tool schema at function.parameters.type: expected "object", received "${declaration.parameters.type}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link assertValidCustomToolDeclaration} as a predicate, for the import path
|
||||
* that skips an unusable declaration per tool rather than failing the batch.
|
||||
*/
|
||||
export function isValidCustomToolDeclaration(schema: unknown): boolean {
|
||||
try {
|
||||
assertValidCustomToolDeclaration(schema)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,4 +619,46 @@ describe('knowledge connector application use cases', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ operation: 'exclude' as const, matchesUserExcluded: false, setsUserExcluded: true },
|
||||
{ operation: 'restore' as const, matchesUserExcluded: true, setsUserExcluded: false },
|
||||
])(
|
||||
'targets rows in the opposite state for $operation',
|
||||
async ({ operation, matchesUserExcluded, setsUserExcluded }) => {
|
||||
const sameWorkspaceContext = {
|
||||
...connectorContext,
|
||||
workspaceId: 'workspace-a',
|
||||
knowledgeBaseId: 'knowledge-a',
|
||||
knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' },
|
||||
connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' },
|
||||
}
|
||||
mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext)
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1' }])
|
||||
|
||||
await updateKnowledgeConnectorDocuments.execute({
|
||||
principal: delegatedPrincipal,
|
||||
input: {
|
||||
knowledgeBaseId: 'knowledge-a',
|
||||
connectorId: 'connector-b',
|
||||
assertedWorkspaceId: 'workspace-a',
|
||||
operation,
|
||||
documentIds: ['document-1'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith({
|
||||
userExcluded: setsUserExcluded,
|
||||
enabled: !setsUserExcluded,
|
||||
})
|
||||
const where = dbChainMockFns.where.mock.calls.at(-1)?.[0]
|
||||
expect(where).toEqual(
|
||||
expect.objectContaining({
|
||||
conditions: expect.arrayContaining([
|
||||
{ type: 'eq', left: document.userExcluded, right: matchesUserExcluded },
|
||||
]),
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -581,7 +581,7 @@ export const updateKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCas
|
||||
and(
|
||||
eq(document.connectorId, context.connectorId),
|
||||
inArray(document.id, documentIds),
|
||||
eq(document.userExcluded, !restoring),
|
||||
eq(document.userExcluded, restoring),
|
||||
isNull(document.archivedAt),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({
|
||||
updateTagDefinition: mocks.updateTag,
|
||||
deleteTagDefinition: mocks.deleteTag,
|
||||
getTagUsageStats: mocks.readUsage,
|
||||
normalizeDisplayName: (displayName: string) => displayName.trim().toLowerCase(),
|
||||
createOrUpdateTagDefinitionsBulk: mocks.saveTags,
|
||||
cleanupUnusedTagDefinitions: mocks.cleanupTags,
|
||||
deleteAllTagDefinitions: mocks.deleteAllTags,
|
||||
@@ -127,6 +128,7 @@ describe('knowledge tag application use cases', () => {
|
||||
mocks.resolveDocument.mockResolvedValue(documentContext)
|
||||
mocks.saveTags.mockResolvedValue({ created: [], updated: [], errors: [] })
|
||||
mocks.listAllTags.mockResolvedValue([])
|
||||
mocks.listTags.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -253,6 +255,39 @@ describe('knowledge tag application use cases', () => {
|
||||
expect(mocks.recordAudit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* The unique index on display name is case-sensitive, so it admits names that
|
||||
* differ only in case. Two such tags are indistinguishable in every surface
|
||||
* that filters by name.
|
||||
*/
|
||||
it('rejects a create whose display name differs from an existing one only in case', async () => {
|
||||
mocks.listTags.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'tag-1',
|
||||
knowledgeBaseId: 'knowledge-b',
|
||||
tagSlot: 'tag1',
|
||||
displayName: 'clitest-cat',
|
||||
fieldType: 'text',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
await expect(
|
||||
createKnowledgeTag.execute({
|
||||
principal: sessionPrincipal,
|
||||
input: {
|
||||
knowledgeBaseId: 'knowledge-b',
|
||||
displayName: 'CLITEST-CAT',
|
||||
fieldType: 'text',
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'conflict' })
|
||||
|
||||
expect(mocks.createTag).not.toHaveBeenCalled()
|
||||
expect(mocks.recordAudit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Neither uniqueness invariant can be checked before the write: the read that
|
||||
* would check it and the insert that depends on the answer are separate
|
||||
@@ -342,6 +377,65 @@ describe('knowledge tag application use cases', () => {
|
||||
expect(mocks.updateTag).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* The display-name index is case-sensitive, so it admits on rename exactly the
|
||||
* pair create rejects.
|
||||
*/
|
||||
it('rejects a rename whose display name differs from an existing one only in case', async () => {
|
||||
mocks.listTags.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'tag-other',
|
||||
knowledgeBaseId: 'knowledge-b',
|
||||
tagSlot: 'tag2',
|
||||
displayName: 'clitest-cat',
|
||||
fieldType: 'text',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
await expect(
|
||||
updateKnowledgeTag.execute({
|
||||
principal: sessionPrincipal,
|
||||
input: {
|
||||
knowledgeBaseId: 'knowledge-b',
|
||||
tagDefinitionId: 'tag-b',
|
||||
updates: { displayName: 'CLITEST-CAT' },
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'conflict' })
|
||||
|
||||
expect(mocks.updateTag).not.toHaveBeenCalled()
|
||||
expect(mocks.recordAudit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/** Re-casing a tag's own name is not a collision with itself. */
|
||||
it('allows a tag to be renamed to a different casing of its own name', async () => {
|
||||
mocks.listTags.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'tag-b',
|
||||
knowledgeBaseId: 'knowledge-b',
|
||||
tagSlot: 'tag1',
|
||||
displayName: 'Region',
|
||||
fieldType: 'text',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
mocks.updateTag.mockResolvedValueOnce({ ...tagContext.tagDefinition, displayName: 'region' })
|
||||
|
||||
await updateKnowledgeTag.execute({
|
||||
principal: sessionPrincipal,
|
||||
input: {
|
||||
knowledgeBaseId: 'knowledge-b',
|
||||
tagDefinitionId: 'tag-b',
|
||||
updates: { displayName: 'region' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.updateTag).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports a rename onto a taken name as a conflict', async () => {
|
||||
mocks.updateTag.mockRejectedValueOnce(
|
||||
Object.assign(new Error('duplicate key value violates unique constraint'), {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getTagDefinitions,
|
||||
getTagUsage,
|
||||
getTagUsageStats,
|
||||
normalizeDisplayName,
|
||||
updateTagDefinition,
|
||||
} from '@/lib/knowledge/tags/service'
|
||||
import type { BulkTagDefinitionsData } from '@/lib/knowledge/tags/types'
|
||||
@@ -95,12 +96,13 @@ const TAG_DISPLAY_NAME_UNIQUE_INDEX = 'kb_tag_definitions_kb_display_name_idx'
|
||||
/**
|
||||
* Reports a tag uniqueness violation as a conflict rather than a fault.
|
||||
*
|
||||
* Neither slot occupancy nor display-name uniqueness is checked before the
|
||||
* write, and neither can be: the read that would check them and the insert that
|
||||
* depends on the answer are separate statements, so two concurrent creates both
|
||||
* pass and one loses at the index. Even single-threaded, `tagSlot` is a caller
|
||||
* Slot occupancy is not checked before the write: `tagSlot` is a caller
|
||||
* parameter and the next-free-slot search only runs when it is omitted, so a
|
||||
* caller naming an occupied slot reaches the index directly.
|
||||
* caller naming an occupied slot reaches the index directly. Display-name
|
||||
* uniqueness is checked, but under the knowledge base's row lock inside the
|
||||
* writing transaction rather than here — the pre-checks below are only an early
|
||||
* rejection, and the index remains the backstop for an exact-case duplicate
|
||||
* racing in through a path that does not take that lock.
|
||||
*
|
||||
* The index name distinguishes the two so the message says which value to
|
||||
* change. Anything else propagates — a foreign-key or not-null violation is a
|
||||
@@ -150,8 +152,35 @@ export const createKnowledgeTag = defineAuthorizedKnowledgeUseCase({
|
||||
if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(fieldType)) {
|
||||
throw new OrchestrationError('validation', 'Invalid field type')
|
||||
}
|
||||
const existingDefinitions = await getDocumentTagDefinitions(context.knowledgeBaseId)
|
||||
/**
|
||||
* The unique index on display name is case-sensitive, so it lets through
|
||||
* names that differ only in case. Two such tags are indistinguishable in
|
||||
* every surface that filters by name, so reject them.
|
||||
*
|
||||
* This is the early rejection, off the read this path already needed for
|
||||
* slot allocation. It is not what makes the rule hold — a read here and the
|
||||
* insert that depends on it are separate statements, so the authoritative
|
||||
* check runs again inside `createTagDefinition`, under the row lock that
|
||||
* serializes it against every competing writer.
|
||||
*/
|
||||
if (
|
||||
existingDefinitions.some(
|
||||
(definition) =>
|
||||
normalizeDisplayName(definition.displayName) === normalizeDisplayName(input.displayName)
|
||||
)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'conflict',
|
||||
'A tag with that name already exists in this knowledge base'
|
||||
)
|
||||
}
|
||||
const existingBySlot = new Map(
|
||||
existingDefinitions.map((definition) => [definition.tagSlot, definition])
|
||||
)
|
||||
const tagSlot =
|
||||
input.tagSlot ?? (await getNextAvailableSlot(context.knowledgeBaseId, fieldType))
|
||||
input.tagSlot ??
|
||||
(await getNextAvailableSlot(context.knowledgeBaseId, fieldType, existingBySlot))
|
||||
if (!tagSlot) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
@@ -211,6 +240,31 @@ export const updateKnowledgeTag = defineAuthorizedKnowledgeUseCase({
|
||||
* every read of it would then interpret text as the wrong type. The same
|
||||
* two checks as create, in the same order.
|
||||
*/
|
||||
/**
|
||||
* A rename reaches the same case-sensitive display-name index create does,
|
||||
* so it can land `CLITEST-CAT` beside an existing `clitest-cat` — the state
|
||||
* create rejects. The same check, against the definitions of the same
|
||||
* knowledge base, excluding the one being renamed, and — like create's — an
|
||||
* early rejection whose authoritative repeat runs under the row lock in
|
||||
* `updateTagDefinition`.
|
||||
*/
|
||||
const { displayName } = input.updates
|
||||
if (displayName !== undefined) {
|
||||
const existingDefinitions = await getDocumentTagDefinitions(context.knowledgeBaseId)
|
||||
if (
|
||||
existingDefinitions.some(
|
||||
(definition) =>
|
||||
definition.id !== context.tagDefinitionId &&
|
||||
normalizeDisplayName(definition.displayName) === normalizeDisplayName(displayName)
|
||||
)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'conflict',
|
||||
'A tag with that name already exists in this knowledge base'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { fieldType } = input.updates
|
||||
if (fieldType !== undefined) {
|
||||
if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(fieldType)) {
|
||||
@@ -228,6 +282,7 @@ export const updateKnowledgeTag = defineAuthorizedKnowledgeUseCase({
|
||||
return {
|
||||
tagDefinition: await updateTagDefinition(
|
||||
context.tagDefinitionId,
|
||||
context.knowledgeBaseId,
|
||||
input.updates,
|
||||
generateRequestId()
|
||||
).catch(tagUniquenessConflict),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/knowledge/embeddings', () => ({
|
||||
generateEmbeddings: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/knowledge/embedding-models', () => ({
|
||||
getEmbeddingModelInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/knowledge/secret-provenance', () => ({
|
||||
replaceKnowledgeEmbeddingSecretProvenanceInTx: vi.fn(),
|
||||
}))
|
||||
|
||||
import { batchChunkOperation } from '@/lib/knowledge/chunks/service'
|
||||
|
||||
describe('batchChunkOperation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('reports the count of rows actually updated, not the count requested', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chunk-1' }, { id: 'chunk-2' }])
|
||||
|
||||
const result = await batchChunkOperation(
|
||||
'document-1',
|
||||
'disable',
|
||||
['chunk-1', 'chunk-2', 'chunk-missing'],
|
||||
'request-1'
|
||||
)
|
||||
|
||||
expect(result.processed).toBe(2)
|
||||
expect(result.errors).toEqual(['No matching chunks found to disable: chunk-missing'])
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('reports zero processed when no requested chunk exists', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([])
|
||||
|
||||
const result = await batchChunkOperation(
|
||||
'document-1',
|
||||
'enable',
|
||||
['missing-1', 'missing-2'],
|
||||
'request-2'
|
||||
)
|
||||
|
||||
expect(result.processed).toBe(0)
|
||||
expect(result.errors).toEqual(['No matching chunks found to enable: missing-1, missing-2'])
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('reports success with no errors when every requested chunk matched', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chunk-1' }, { id: 'chunk-2' }])
|
||||
|
||||
const result = await batchChunkOperation(
|
||||
'document-1',
|
||||
'enable',
|
||||
['chunk-1', 'chunk-2'],
|
||||
'request-3'
|
||||
)
|
||||
|
||||
expect(result).toEqual({ success: true, processed: 2, errors: [] })
|
||||
})
|
||||
})
|
||||
@@ -380,16 +380,22 @@ export async function batchChunkOperation(
|
||||
} else {
|
||||
const enabled = operation === 'enable'
|
||||
|
||||
await db
|
||||
const updatedChunks = await db
|
||||
.update(embedding)
|
||||
.set({
|
||||
enabled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(embedding.documentId, documentId), inArray(embedding.id, chunkIds)))
|
||||
.returning({ id: embedding.id })
|
||||
|
||||
// For enable/disable, we assume all chunks were processed successfully
|
||||
successCount = chunkIds.length
|
||||
successCount = updatedChunks.length
|
||||
|
||||
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(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -212,6 +212,137 @@ describe('connector sync queue', () => {
|
||||
expect(mockExecuteSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Every guard here used to return `void`, so a caller could not tell a queued
|
||||
* sync from a skipped one and reported — and audited — work that never
|
||||
* started.
|
||||
*/
|
||||
it('reports the queue outcome to the caller', async () => {
|
||||
const result = await dispatchSync('connector-1', {
|
||||
billingAttribution: BILLING_ATTRIBUTION,
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ queued: true })
|
||||
})
|
||||
|
||||
it('reports why an automatic dispatch on a paused connector was not queued', async () => {
|
||||
resetDbChainMock()
|
||||
queueTableRows(schemaMock.knowledgeConnector, [
|
||||
{
|
||||
knowledgeBaseId: 'knowledge-base-1',
|
||||
connectorStatus: 'paused',
|
||||
connectorArchivedAt: null,
|
||||
connectorDeletedAt: null,
|
||||
connectorNextSyncAt: NEXT_SYNC_AT,
|
||||
workspaceId: 'workspace-paid',
|
||||
kbDeletedAt: null,
|
||||
},
|
||||
])
|
||||
|
||||
const result = await dispatchSync('connector-1', {
|
||||
billingAttribution: BILLING_ATTRIBUTION,
|
||||
expectedNextSyncAt: NEXT_SYNC_AT,
|
||||
requireRunnable: true,
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(result.queued).toBe(false)
|
||||
expect(result.reason).toContain('paused')
|
||||
})
|
||||
|
||||
it('reports why a connector that is not accepting a queued sync was skipped', async () => {
|
||||
/** `markSyncPending` took nothing: the row already carries a queue entry. */
|
||||
dbChainMockFns.returning.mockResolvedValue([])
|
||||
queueTableRows(schemaMock.knowledgeConnector, [
|
||||
{ status: 'pending', archivedAt: null, deletedAt: null },
|
||||
])
|
||||
|
||||
const result = await dispatchSync('connector-1', {
|
||||
billingAttribution: BILLING_ATTRIBUTION,
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
queued: false,
|
||||
reason: 'A sync is already queued or running for this connector',
|
||||
})
|
||||
expect(mockTrigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the lifecycle reason when the connector is archived under the queue write', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValue([])
|
||||
queueTableRows(schemaMock.knowledgeConnector, [
|
||||
{
|
||||
status: 'active',
|
||||
archivedAt: new Date('2026-08-21T00:00:00.000Z'),
|
||||
deletedAt: null,
|
||||
},
|
||||
])
|
||||
|
||||
const result = await dispatchSync('connector-1', {
|
||||
billingAttribution: BILLING_ATTRIBUTION,
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
queued: false,
|
||||
reason: 'Connector has been archived or deleted',
|
||||
})
|
||||
expect(mockTrigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the lifecycle reason when the connector is deleted under the queue write', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValue([])
|
||||
queueTableRows(schemaMock.knowledgeConnector, [])
|
||||
|
||||
const result = await dispatchSync('connector-1', {
|
||||
billingAttribution: BILLING_ATTRIBUTION,
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ queued: false, reason: 'Connector no longer exists' })
|
||||
expect(mockTrigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the status when it left the set a run may start from', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValue([])
|
||||
queueTableRows(schemaMock.knowledgeConnector, [
|
||||
{ status: 'paused', archivedAt: null, deletedAt: null },
|
||||
])
|
||||
|
||||
const result = await dispatchSync('connector-1', {
|
||||
billingAttribution: BILLING_ATTRIBUTION,
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
queued: false,
|
||||
reason: 'Connector is paused and cannot start a sync',
|
||||
})
|
||||
expect(mockTrigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports why a connector whose knowledge base is gone was skipped', async () => {
|
||||
resetDbChainMock()
|
||||
queueTableRows(schemaMock.knowledgeConnector, [
|
||||
{
|
||||
knowledgeBaseId: 'knowledge-base-1',
|
||||
connectorArchivedAt: null,
|
||||
connectorDeletedAt: null,
|
||||
workspaceId: 'workspace-paid',
|
||||
kbDeletedAt: new Date('2026-08-20T00:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await dispatchSync('connector-1', {
|
||||
billingAttribution: BILLING_ATTRIBUTION,
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ queued: false, reason: 'Knowledge base has been deleted' })
|
||||
})
|
||||
|
||||
it('releases the lock when it errors a connector whose knowledge base is gone', async () => {
|
||||
resetDbChainMock()
|
||||
queueTableRows(schemaMock.knowledgeConnector, [
|
||||
|
||||
@@ -99,6 +99,23 @@ export function assertConnectorSyncPayload(value: unknown): ConnectorSyncPayload
|
||||
|
||||
export const SYNC_DISPATCH_FAILED_ERROR = 'Sync could not be queued'
|
||||
|
||||
/** The row already carries a queued or running sync. */
|
||||
const SYNC_ALREADY_QUEUED_REASON = 'A sync is already queued or running for this connector'
|
||||
|
||||
/**
|
||||
* Whether a dispatch actually put a run on the queue.
|
||||
*
|
||||
* Every guard in {@link dispatchSync} used to return `void`, so a caller could
|
||||
* not tell a queued sync from one that was silently skipped, and reported — and
|
||||
* audited — work that was never started. `reason` is worded for whoever reads
|
||||
* it in an API response or a log, not as an internal token.
|
||||
*/
|
||||
export interface SyncDispatchResult {
|
||||
queued: boolean
|
||||
/** Present only when the sync was not queued. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the connector as having a sync queued, and returns the token that owns
|
||||
* that queued sync.
|
||||
@@ -158,6 +175,42 @@ async function markSyncPending(connectorId: string): Promise<string | null> {
|
||||
return taken.length > 0 ? dispatchToken : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Explains a queue entry {@link markSyncPending} did not take.
|
||||
*
|
||||
* Its condition matches on three independent things — the connector is live, it
|
||||
* holds no token, and its status is one a run may start from — so the write
|
||||
* failing does not say which of them refused. Reporting the queued-or-running
|
||||
* reason for all three told a caller whose connector was archived or deleted in
|
||||
* the window after the dispatch guards read the row that a sync was already
|
||||
* running: false, and unactionable. This re-read costs one query on a path that
|
||||
* is already queueing nothing, and the row it sees may have moved again — so it
|
||||
* reports the lifecycle verdicts from the row as it stands now and falls back to
|
||||
* the queue reason for everything else.
|
||||
*/
|
||||
async function describeUnacceptedSync(connectorId: string): Promise<string> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
status: knowledgeConnector.status,
|
||||
archivedAt: knowledgeConnector.archivedAt,
|
||||
deletedAt: knowledgeConnector.deletedAt,
|
||||
})
|
||||
.from(knowledgeConnector)
|
||||
.where(eq(knowledgeConnector.id, connectorId))
|
||||
.limit(1)
|
||||
|
||||
if (!row) return 'Connector no longer exists'
|
||||
if (row.archivedAt || row.deletedAt) return 'Connector has been archived or deleted'
|
||||
if (row.status !== 'syncing' && !isLockableConnectorStatus(row.status)) {
|
||||
return `Connector is ${row.status} and cannot start a sync`
|
||||
}
|
||||
return SYNC_ALREADY_QUEUED_REASON
|
||||
}
|
||||
|
||||
function isLockableConnectorStatus(status: string): boolean {
|
||||
return (LOCKABLE_CONNECTOR_STATUSES as readonly string[]).includes(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a queued sync whose hand-off threw.
|
||||
*
|
||||
@@ -217,7 +270,7 @@ async function releaseFailedDispatch(
|
||||
export async function dispatchSync(
|
||||
connectorId: string,
|
||||
options: DispatchSyncOptions
|
||||
): Promise<void> {
|
||||
): Promise<SyncDispatchResult> {
|
||||
if (!isNonEmptyString(connectorId)) {
|
||||
throw new Error('Connector sync dispatch requires a connector ID')
|
||||
}
|
||||
@@ -257,7 +310,7 @@ export async function dispatchSync(
|
||||
const row = connectorRows[0]
|
||||
if (!row) {
|
||||
logger.warn('Skipping sync dispatch: connector not found', { connectorId, requestId })
|
||||
return
|
||||
return { queued: false, reason: 'Connector no longer exists' }
|
||||
}
|
||||
if (row.kbDeletedAt) {
|
||||
logger.warn('Skipping sync dispatch: knowledge base is deleted', {
|
||||
@@ -287,14 +340,14 @@ export async function dispatchSync(
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(knowledgeConnector.id, connectorId))
|
||||
return
|
||||
return { queued: false, reason: 'Knowledge base has been deleted' }
|
||||
}
|
||||
if (row.connectorArchivedAt || row.connectorDeletedAt) {
|
||||
logger.warn('Skipping sync dispatch: connector is archived or deleted', {
|
||||
connectorId,
|
||||
requestId,
|
||||
})
|
||||
return
|
||||
return { queued: false, reason: 'Connector has been archived or deleted' }
|
||||
}
|
||||
if (payload.requireRunnable && !isConnectorRunnableStatus(row.connectorStatus)) {
|
||||
logger.info('Skipping automatic sync dispatch: connector is not runnable', {
|
||||
@@ -302,7 +355,10 @@ export async function dispatchSync(
|
||||
status: row.connectorStatus,
|
||||
requestId,
|
||||
})
|
||||
return
|
||||
return {
|
||||
queued: false,
|
||||
reason: `Connector is ${row.connectorStatus} and is not synced automatically`,
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.expectedNextSyncAt &&
|
||||
@@ -312,7 +368,10 @@ export async function dispatchSync(
|
||||
connectorId,
|
||||
requestId,
|
||||
})
|
||||
return
|
||||
return {
|
||||
queued: false,
|
||||
reason: 'The connector sync schedule changed after this run was scheduled',
|
||||
}
|
||||
}
|
||||
if (!row.workspaceId) {
|
||||
throw new Error(`Connector ${connectorId} is missing workspace billing context`)
|
||||
@@ -333,11 +392,13 @@ export async function dispatchSync(
|
||||
if (isTriggerAvailable()) {
|
||||
const dispatchToken = await markSyncPending(connectorId)
|
||||
if (!dispatchToken) {
|
||||
const reason = await describeUnacceptedSync(connectorId)
|
||||
logger.info('Skipping sync dispatch: connector is not accepting a queued sync', {
|
||||
connectorId,
|
||||
reason,
|
||||
requestId,
|
||||
})
|
||||
return
|
||||
return { queued: false, reason }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,16 +429,18 @@ export async function dispatchSync(
|
||||
throw error
|
||||
}
|
||||
logger.info('Dispatched connector sync to Trigger.dev', { connectorId, requestId })
|
||||
return
|
||||
return { queued: true }
|
||||
}
|
||||
|
||||
const dispatchToken = await markSyncPending(connectorId)
|
||||
if (!dispatchToken) {
|
||||
const reason = await describeUnacceptedSync(connectorId)
|
||||
logger.info('Skipping sync execution: connector is not accepting a queued sync', {
|
||||
connectorId,
|
||||
reason,
|
||||
requestId,
|
||||
})
|
||||
return
|
||||
return { queued: false, reason }
|
||||
}
|
||||
|
||||
executeSync(connectorId, {
|
||||
@@ -398,4 +461,6 @@ export async function dispatchSync(
|
||||
*/
|
||||
await releaseFailedDispatch(connectorId, dispatchToken, error)
|
||||
})
|
||||
|
||||
return { queued: true }
|
||||
}
|
||||
|
||||
@@ -47,8 +47,17 @@ vi.mock('@/lib/knowledge/tags/service', () => ({
|
||||
createTagDefinition: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent }))
|
||||
vi.mock('@/connectors/registry.server', () => ({
|
||||
CONNECTOR_REGISTRY: {
|
||||
notion: {
|
||||
auth: { mode: 'apiKey', optional: true },
|
||||
validateConfig: vi.fn().mockResolvedValue({ valid: true }),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
performCreateKnowledgeConnector,
|
||||
performDeleteKnowledgeConnector,
|
||||
performSyncKnowledgeConnector,
|
||||
performUpdateKnowledgeConnector,
|
||||
@@ -59,6 +68,82 @@ const ACTOR = { userId: 'user-1', source: 'agent' as const, requestId: 'req-1' }
|
||||
const BILLING = { actorUserId: 'user-1', workspaceId: 'ws-1' } as never
|
||||
const resolveBillingAttribution = vi.fn().mockResolvedValue(BILLING)
|
||||
|
||||
describe('performCreateKnowledgeConnector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true)
|
||||
mockDispatchSync.mockResolvedValue({ queued: true })
|
||||
})
|
||||
|
||||
afterAll(resetDbChainMock)
|
||||
|
||||
function queueSuccessfulInsert() {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'kb-1' }])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{ id: 'conn-1', connectorType: 'notion', encryptedApiKey: null },
|
||||
])
|
||||
}
|
||||
|
||||
const createParams = {
|
||||
...ACTOR,
|
||||
knowledgeBase: KB,
|
||||
connectorType: 'notion',
|
||||
sourceConfig: {},
|
||||
syncIntervalMinutes: 60,
|
||||
resolveBillingAttribution,
|
||||
resolveAccessToken: vi.fn(),
|
||||
}
|
||||
|
||||
/**
|
||||
* A detached dispatch settles after the caller has already been handed the
|
||||
* connector, so its outcome could never reach the response.
|
||||
*/
|
||||
it('waits for the initial sync dispatch before returning', async () => {
|
||||
queueSuccessfulInsert()
|
||||
let dispatchSettled = false
|
||||
mockDispatchSync.mockImplementationOnce(() =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1)
|
||||
}).then(() => {
|
||||
dispatchSettled = true
|
||||
return { queued: true }
|
||||
})
|
||||
)
|
||||
|
||||
const outcome = await performCreateKnowledgeConnector(createParams)
|
||||
|
||||
expect(dispatchSettled).toBe(true)
|
||||
expect(outcome).toMatchObject({ success: true, initialSyncQueued: true })
|
||||
})
|
||||
|
||||
it('reports a failed initial dispatch instead of claiming the sync was queued', async () => {
|
||||
queueSuccessfulInsert()
|
||||
mockDispatchSync.mockRejectedValueOnce(new Error('queue unavailable'))
|
||||
|
||||
const outcome = await performCreateKnowledgeConnector(createParams)
|
||||
|
||||
expect(outcome).toMatchObject({ success: true, initialSyncQueued: false })
|
||||
})
|
||||
|
||||
/**
|
||||
* A dispatch that declines to queue is as invisible to the caller as one that
|
||||
* throws: the connector is created either way, so the only signal that its
|
||||
* first sync never started is this flag.
|
||||
*/
|
||||
it('reports a skipped initial dispatch the same as a failed one', async () => {
|
||||
queueSuccessfulInsert()
|
||||
mockDispatchSync.mockResolvedValueOnce({
|
||||
queued: false,
|
||||
reason: 'A sync is already queued or running for this connector',
|
||||
})
|
||||
|
||||
const outcome = await performCreateKnowledgeConnector(createParams)
|
||||
|
||||
expect(outcome).toMatchObject({ success: true, initialSyncQueued: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('performDeleteKnowledgeConnector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -143,7 +228,7 @@ describe('performUpdateKnowledgeConnector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockDispatchSync.mockResolvedValue(undefined)
|
||||
mockDispatchSync.mockResolvedValue({ queued: true })
|
||||
resolveBillingAttribution.mockResolvedValue(BILLING)
|
||||
})
|
||||
|
||||
@@ -656,7 +741,7 @@ describe('performSyncKnowledgeConnector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockDispatchSync.mockResolvedValue(undefined)
|
||||
mockDispatchSync.mockResolvedValue({ queued: true })
|
||||
})
|
||||
|
||||
afterAll(resetDbChainMock)
|
||||
@@ -785,4 +870,52 @@ describe('performSyncKnowledgeConnector', () => {
|
||||
expect(mockRecordAudit).not.toHaveBeenCalled()
|
||||
expect(mockCaptureServerEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed dispatch instead of claiming the sync was queued', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ id: 'conn-1', connectorType: 'notion', status: 'active' },
|
||||
])
|
||||
mockDispatchSync.mockRejectedValueOnce(new Error('queue unavailable'))
|
||||
|
||||
const outcome = await performSyncKnowledgeConnector({
|
||||
...ACTOR,
|
||||
knowledgeBase: KB,
|
||||
connectorId: 'conn-1',
|
||||
resolveBillingAttribution,
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({ success: false })
|
||||
expect(mockRecordAudit).not.toHaveBeenCalled()
|
||||
expect(mockCaptureServerEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* The dispatch guards run after this operation's own, so they see state that
|
||||
* changed underneath it. Reporting their verdict is what stops a sync that was
|
||||
* never queued from being audited and reported as one that was.
|
||||
*/
|
||||
it('reports a skipped dispatch, and records neither the audit nor the product event', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ id: 'conn-1', connectorType: 'notion', status: 'active' },
|
||||
])
|
||||
mockDispatchSync.mockResolvedValueOnce({
|
||||
queued: false,
|
||||
reason: 'A sync is already queued or running for this connector',
|
||||
})
|
||||
|
||||
const outcome = await performSyncKnowledgeConnector({
|
||||
...ACTOR,
|
||||
knowledgeBase: KB,
|
||||
connectorId: 'conn-1',
|
||||
resolveBillingAttribution,
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
success: false,
|
||||
errorCode: 'conflict',
|
||||
error: 'A sync is already queued or running for this connector',
|
||||
})
|
||||
expect(mockRecordAudit).not.toHaveBeenCalled()
|
||||
expect(mockCaptureServerEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,6 +106,12 @@ export interface PerformCreateKnowledgeConnectorParams extends KnowledgeOperatio
|
||||
|
||||
export type PerformConnectorResult = KnowledgeOrchestrationResult<{
|
||||
connector: ConnectorWithoutSecret
|
||||
/**
|
||||
* Creation only: whether the connector's first sync was actually enqueued.
|
||||
* Creation succeeds either way, so the caller reports the sync from this
|
||||
* rather than assuming it.
|
||||
*/
|
||||
initialSyncQueued?: boolean
|
||||
}>
|
||||
|
||||
/**
|
||||
@@ -357,15 +363,33 @@ export async function performCreateKnowledgeConnector(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaited, like the update and manual-sync dispatches: detaching it let the
|
||||
* enqueue fail after the caller had already been told the connector was ready,
|
||||
* so the failure surfaced nowhere and the row was left waiting on a run that
|
||||
* was never queued. The connector itself is created either way — only the
|
||||
* initial sync is at stake — so a failed enqueue is reported on the connector,
|
||||
* not by failing the creation.
|
||||
*/
|
||||
const dispatchSync = await loadDispatchSync()
|
||||
dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => {
|
||||
let initialSyncQueued = true
|
||||
try {
|
||||
const dispatch = await dispatchSync(connectorId, { billingAttribution, requestId })
|
||||
if (!dispatch.queued) {
|
||||
initialSyncQueued = false
|
||||
logger.warn(
|
||||
`[${requestId}] Initial sync for connector ${connectorId} was not queued: ${dispatch.reason}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
initialSyncQueued = false
|
||||
logger.error(
|
||||
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, connector: withoutSecret(created) }
|
||||
return { success: true, connector: withoutSecret(created), initialSyncQueued }
|
||||
}
|
||||
|
||||
export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperationContext {
|
||||
@@ -854,6 +878,38 @@ export async function performSyncKnowledgeConnector(
|
||||
`[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}`
|
||||
)
|
||||
|
||||
/**
|
||||
* The dispatch is awaited, and it only enqueues: it takes the pending lock and
|
||||
* hands the run to the queue, it does not run the sync. Detaching it made a
|
||||
* failed enqueue invisible — the caller was told the sync was queued while the
|
||||
* connector sat `pending` with nothing behind it — and recorded an audit and a
|
||||
* product event for work that never started. Awaiting first makes the reported
|
||||
* outcome and both records describe what actually happened.
|
||||
*/
|
||||
const dispatchSync = await loadDispatchSync()
|
||||
try {
|
||||
const dispatch = await dispatchSync(connectorId, { billingAttribution, requestId, rehydrate })
|
||||
/**
|
||||
* A guard inside the dispatch declining to queue is reported as a failure
|
||||
* rather than a queued sync. Every one of them means the connector's state
|
||||
* changed between this operation's own guards and the queue write, so the
|
||||
* caller is told the sync did not start instead of being handed a success
|
||||
* with an audit and a product event behind it.
|
||||
*/
|
||||
if (!dispatch.queued) {
|
||||
logger.warn(
|
||||
`[${requestId}] Manual sync for connector ${connectorId} was not queued: ${dispatch.reason}`
|
||||
)
|
||||
return fail(dispatch.reason ?? 'Sync could not be queued', 'conflict')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
|
||||
error
|
||||
)
|
||||
return classifyKnowledgeFailure(error, requestId, `Sync connector ${connectorId}`)
|
||||
}
|
||||
|
||||
if (params.recordProductAnalytics !== false) {
|
||||
captureServerEvent(
|
||||
params.userId,
|
||||
@@ -888,13 +944,5 @@ export async function performSyncKnowledgeConnector(
|
||||
})
|
||||
}
|
||||
|
||||
const dispatchSync = await loadDispatchSync()
|
||||
dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
|
||||
error
|
||||
)
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { knowledgeBaseTagDefinitions } from '@sim/db/schema'
|
||||
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/utils/id', () => ({
|
||||
generateId: vi.fn(() => 'generated-tag-id'),
|
||||
generateShortId: vi.fn(() => 'short-id'),
|
||||
}))
|
||||
|
||||
import {
|
||||
createOrUpdateTagDefinitionsBulk,
|
||||
createTagDefinition,
|
||||
updateTagDefinition,
|
||||
} from '@/lib/knowledge/tags/service'
|
||||
|
||||
const NOW = new Date('2026-01-01T00:00:00.000Z')
|
||||
|
||||
function existingDefinition(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: 'tag-def-1',
|
||||
knowledgeBaseId: 'kb-1',
|
||||
tagSlot: 'tag1',
|
||||
displayName: 'clitest-score',
|
||||
fieldType: 'text',
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createOrUpdateTagDefinitionsBulk', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('honors an explicitly requested slot instead of relocating it', async () => {
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [existingDefinition({ tagSlot: 'tag1' })])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{ definitions: [{ tagSlot: 'tag4', displayName: 'clitest-saved', fieldType: 'text' }] },
|
||||
'request-1'
|
||||
)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.created).toHaveLength(1)
|
||||
expect(result.created[0].tagSlot).toBe('tag4')
|
||||
})
|
||||
|
||||
it('rejects a slot that does not belong to the declared field type', async () => {
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{ definitions: [{ tagSlot: 'tag6', displayName: 'clitest-bad', fieldType: 'number' }] },
|
||||
'request-2'
|
||||
)
|
||||
|
||||
expect(result.created).toEqual([])
|
||||
expect(result.errors).toEqual(['Tag slot "tag6" is not valid for field type "number"'])
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a free slot whose family does not match the declared field type', async () => {
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{ definitions: [{ tagSlot: 'number4', displayName: 'clitest-wrong', fieldType: 'text' }] },
|
||||
'request-3'
|
||||
)
|
||||
|
||||
expect(result.created).toEqual([])
|
||||
expect(result.errors).toEqual(['Tag slot "number4" is not valid for field type "text"'])
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an identical re-declaration as a no-op instead of a duplicate error', async () => {
|
||||
const existing = existingDefinition({ tagSlot: 'number1', fieldType: 'number' })
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [existing])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{
|
||||
definitions: [{ tagSlot: 'number1', displayName: 'clitest-score', fieldType: 'number' }],
|
||||
},
|
||||
'request-4'
|
||||
)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.created).toEqual([])
|
||||
expect(result.updated).toEqual([existing])
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an identical re-declaration without a slot as a no-op', async () => {
|
||||
const existing = existingDefinition({ tagSlot: 'tag1', fieldType: 'text' })
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [existing])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{ definitions: [{ displayName: 'clitest-score', fieldType: 'text' }] },
|
||||
'request-5'
|
||||
)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.created).toEqual([])
|
||||
expect(result.updated).toEqual([existing])
|
||||
})
|
||||
|
||||
it('refuses to rename the tag occupying a declared slot without originalDisplayName', async () => {
|
||||
const existing = existingDefinition({ tagSlot: 'tag1', fieldType: 'text' })
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [existing])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{ definitions: [{ tagSlot: 'tag1', displayName: 'clitest-renamed', fieldType: 'text' }] },
|
||||
'request-6'
|
||||
)
|
||||
|
||||
expect(result.created).toEqual([])
|
||||
expect(result.updated).toEqual([])
|
||||
expect(result.errors).toEqual([
|
||||
'Tag slot "tag1" is already in use by "clitest-score"; supply originalDisplayName to rename it',
|
||||
])
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renames the tag a declared slot holds when originalDisplayName says so', async () => {
|
||||
const existing = existingDefinition({ tagSlot: 'tag1', fieldType: 'text' })
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [existing])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{
|
||||
definitions: [
|
||||
{
|
||||
tagSlot: 'tag1',
|
||||
displayName: 'clitest-renamed',
|
||||
fieldType: 'text',
|
||||
originalDisplayName: 'clitest-score',
|
||||
},
|
||||
],
|
||||
},
|
||||
'request-8'
|
||||
)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.created).toEqual([])
|
||||
expect(result.updated).toHaveLength(1)
|
||||
expect(result.updated[0]).toMatchObject({
|
||||
id: 'tag-def-1',
|
||||
tagSlot: 'tag1',
|
||||
displayName: 'clitest-renamed',
|
||||
})
|
||||
expect(dbChainMockFns.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to move an existing tag into a different declared slot', async () => {
|
||||
const existing = existingDefinition({ tagSlot: 'tag1', fieldType: 'text' })
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [existing])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{
|
||||
definitions: [
|
||||
{
|
||||
tagSlot: 'tag5',
|
||||
displayName: 'clitest-renamed',
|
||||
fieldType: 'text',
|
||||
originalDisplayName: 'clitest-score',
|
||||
},
|
||||
],
|
||||
},
|
||||
'request-9'
|
||||
)
|
||||
|
||||
expect(result.updated).toEqual([])
|
||||
expect(result.errors).toEqual([
|
||||
`Tag "clitest-score" occupies slot "tag1"; a tag's slot cannot change`,
|
||||
])
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a display name that differs from an existing one only in case', async () => {
|
||||
const existing = existingDefinition({ tagSlot: 'tag1', displayName: 'clitest-cat' })
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [existing])
|
||||
|
||||
const result = await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{ definitions: [{ tagSlot: 'tag2', displayName: 'CLITEST-CAT', fieldType: 'text' }] },
|
||||
'request-7'
|
||||
)
|
||||
|
||||
expect(result.created).toEqual([])
|
||||
expect(result.errors).toEqual(['Display name "CLITEST-CAT" already exists'])
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the snapshot it compares against under the knowledge base row lock', async () => {
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [])
|
||||
|
||||
await createOrUpdateTagDefinitionsBulk(
|
||||
'kb-1',
|
||||
{ definitions: [{ tagSlot: 'tag1', displayName: 'clitest-locked', fieldType: 'text' }] },
|
||||
'request-10'
|
||||
)
|
||||
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.for).toHaveBeenCalledWith('update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTagDefinition', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('refuses a display name an existing definition holds in another case', async () => {
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [
|
||||
existingDefinition({ displayName: 'clitest-cat' }),
|
||||
])
|
||||
|
||||
await expect(
|
||||
createTagDefinition(
|
||||
{
|
||||
knowledgeBaseId: 'kb-1',
|
||||
tagSlot: 'tag2',
|
||||
displayName: 'CLITEST-CAT',
|
||||
fieldType: 'text',
|
||||
},
|
||||
'request-11'
|
||||
)
|
||||
).rejects.toThrow('A tag with that name already exists in this knowledge base')
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks and inserts inside one transaction holding the knowledge base row lock', async () => {
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [])
|
||||
|
||||
await createTagDefinition(
|
||||
{
|
||||
knowledgeBaseId: 'kb-1',
|
||||
tagSlot: 'tag2',
|
||||
displayName: 'clitest-free',
|
||||
fieldType: 'text',
|
||||
},
|
||||
'request-12'
|
||||
)
|
||||
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.for).toHaveBeenCalledWith('update')
|
||||
expect(dbChainMockFns.insert).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTagDefinition', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('refuses a rename onto a sibling name that differs only in case', async () => {
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [
|
||||
existingDefinition({ id: 'tag-def-1', displayName: 'clitest-cat' }),
|
||||
existingDefinition({ id: 'tag-def-2', tagSlot: 'tag2', displayName: 'clitest-dog' }),
|
||||
])
|
||||
|
||||
await expect(
|
||||
updateTagDefinition('tag-def-2', 'kb-1', { displayName: 'CLITEST-CAT' }, 'request-13')
|
||||
).rejects.toThrow('A tag with that name already exists in this knowledge base')
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows a definition to be renamed to another casing of its own name', async () => {
|
||||
queueTableRows(knowledgeBaseTagDefinitions, [
|
||||
existingDefinition({ id: 'tag-def-1', displayName: 'clitest-cat' }),
|
||||
])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
existingDefinition({ id: 'tag-def-1', displayName: 'CLITEST-CAT' }),
|
||||
])
|
||||
|
||||
const updated = await updateTagDefinition(
|
||||
'tag-def-1',
|
||||
'kb-1',
|
||||
{ displayName: 'CLITEST-CAT' },
|
||||
'request-14'
|
||||
)
|
||||
|
||||
expect(updated.displayName).toBe('CLITEST-CAT')
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.for).toHaveBeenCalledWith('update')
|
||||
})
|
||||
})
|
||||
@@ -7,11 +7,16 @@ import {
|
||||
knowledgeBaseTagDefinitions,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNotNull, isNull, or, sql } from 'drizzle-orm'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
|
||||
import { getSlotsForFieldType, SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
|
||||
import {
|
||||
getSlotsForFieldType,
|
||||
isValidSlotForFieldType,
|
||||
SUPPORTED_FIELD_TYPES,
|
||||
} from '@/lib/knowledge/constants'
|
||||
import type { BulkTagDefinitionsData, DocumentTagDefinition } from '@/lib/knowledge/tags/types'
|
||||
import type {
|
||||
CreateTagDefinitionData,
|
||||
@@ -161,7 +166,7 @@ function getFieldTypeForSlot(tagSlot: string): string | null {
|
||||
export async function getNextAvailableSlot(
|
||||
knowledgeBaseId: string,
|
||||
fieldType: string,
|
||||
existingBySlot?: Map<string, any>
|
||||
existingBySlot?: Map<string, { fieldType: string }>
|
||||
): Promise<string | null> {
|
||||
const availableSlots = getSlotsForFieldType(fieldType)
|
||||
let usedSlots: Set<string>
|
||||
@@ -199,9 +204,10 @@ export async function getNextAvailableSlot(
|
||||
* Get all tag definitions for a knowledge base
|
||||
*/
|
||||
export async function getDocumentTagDefinitions(
|
||||
knowledgeBaseId: string
|
||||
knowledgeBaseId: string,
|
||||
txDb?: DbOrTx
|
||||
): Promise<DocumentTagDefinition[]> {
|
||||
const definitions = await db
|
||||
const definitions = await (txDb ?? db)
|
||||
.select({
|
||||
id: knowledgeBaseTagDefinitions.id,
|
||||
knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId,
|
||||
@@ -245,7 +251,71 @@ export async function getTagDefinitions(knowledgeBaseId: string): Promise<TagDef
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update tag definitions in bulk
|
||||
* Normalizes a tag display name for uniqueness comparison.
|
||||
*
|
||||
* Display names are the user-facing filter keys, so two tags differing only in
|
||||
* case are indistinguishable in every surface that reads them. The stored value
|
||||
* keeps the caller's casing; only the comparison is case-insensitive.
|
||||
*/
|
||||
export function normalizeDisplayName(displayName: string): string {
|
||||
return displayName.trim().toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a display name a sibling definition already holds, ignoring case.
|
||||
*
|
||||
* The unique index behind display names is case-sensitive, so it cannot refuse
|
||||
* `FOO` beside `foo`; only this comparison can. A comparison alone is not an
|
||||
* invariant, though — an unlocked read followed by a write lets two concurrent
|
||||
* callers both pass it and both insert. What makes it hold is that the caller
|
||||
* runs it inside the transaction that already took
|
||||
* {@link lockKnowledgeBaseForTagMutation}: every write below that establishes or
|
||||
* changes a display name takes that same knowledge-base row lock first, so this
|
||||
* read sees every sibling a competing writer has committed and the loser blocks
|
||||
* until it can.
|
||||
*
|
||||
* The one writer outside that guarantee is connector provisioning, which passes
|
||||
* its own transaction to {@link createTagDefinition} and names tags from
|
||||
* connector configuration rather than caller input; it holds the same row lock
|
||||
* but does not run this check.
|
||||
*/
|
||||
async function assertDisplayNameAvailableInTx(
|
||||
tx: DbOrTx,
|
||||
knowledgeBaseId: string,
|
||||
displayName: string,
|
||||
excludeTagDefinitionId?: string
|
||||
): Promise<void> {
|
||||
const normalized = normalizeDisplayName(displayName)
|
||||
const existingDefinitions = await getDocumentTagDefinitions(knowledgeBaseId, tx)
|
||||
const taken = existingDefinitions.some(
|
||||
(definition) =>
|
||||
definition.id !== excludeTagDefinitionId &&
|
||||
normalizeDisplayName(definition.displayName) === normalized
|
||||
)
|
||||
if (taken) {
|
||||
throw new OrchestrationError(
|
||||
'conflict',
|
||||
'A tag with that name already exists in this knowledge base'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare tag definitions in bulk.
|
||||
*
|
||||
* The call is declarative and idempotent: a definition that already matches the
|
||||
* declaration is reported in `updated` without a write, an explicitly requested
|
||||
* `tagSlot` is honored or reported as an error (it is never silently relocated),
|
||||
* a slot already held by a differently named tag is a collision rather than a
|
||||
* rename (renaming says so through `originalDisplayName`), and a slot whose
|
||||
* family does not match `fieldType` is rejected the same way single-tag
|
||||
* creation rejects it.
|
||||
*
|
||||
* The whole declaration is one transaction under the knowledge base's row lock,
|
||||
* because its case-insensitive display-name comparisons are read-before-write
|
||||
* and hold only against a snapshot no competing writer can move under them —
|
||||
* see {@link assertDisplayNameAvailableInTx}. Per-definition failures still land
|
||||
* in `errors` and still commit the definitions that succeeded.
|
||||
*/
|
||||
export async function createOrUpdateTagDefinitionsBulk(
|
||||
knowledgeBaseId: string,
|
||||
@@ -261,56 +331,133 @@ export async function createOrUpdateTagDefinitionsBulk(
|
||||
const updated: DocumentTagDefinition[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
const existingDefinitions = await getDocumentTagDefinitions(knowledgeBaseId)
|
||||
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot, def]))
|
||||
const existingByDisplayName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
|
||||
await db.transaction(async (tx) => {
|
||||
await setTagMutationTransactionTimeouts(tx)
|
||||
await lockKnowledgeBaseForTagMutation(tx, knowledgeBaseId)
|
||||
|
||||
for (const defData of definitions) {
|
||||
try {
|
||||
const { tagSlot, displayName, fieldType, originalDisplayName } = defData
|
||||
const existingDefinitions = await getDocumentTagDefinitions(knowledgeBaseId, tx)
|
||||
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot, def]))
|
||||
const existingByDisplayName = new Map(
|
||||
existingDefinitions.map((def) => [normalizeDisplayName(def.displayName), def])
|
||||
)
|
||||
|
||||
if (!SUPPORTED_FIELD_TYPES.includes(fieldType as (typeof SUPPORTED_FIELD_TYPES)[number])) {
|
||||
errors.push(`Invalid field type: ${fieldType}`)
|
||||
continue
|
||||
}
|
||||
for (const defData of definitions) {
|
||||
try {
|
||||
const { tagSlot, displayName, fieldType, originalDisplayName } = defData
|
||||
|
||||
const isUpdate = !!originalDisplayName
|
||||
if (!SUPPORTED_FIELD_TYPES.includes(fieldType as (typeof SUPPORTED_FIELD_TYPES)[number])) {
|
||||
errors.push(`Invalid field type: ${fieldType}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
const existingDef = existingByDisplayName.get(originalDisplayName!)
|
||||
if (!existingDef) {
|
||||
const normalizedDisplayName = normalizeDisplayName(displayName)
|
||||
|
||||
const slotOccupant = tagSlot ? existingBySlot.get(tagSlot) : undefined
|
||||
|
||||
/**
|
||||
* A definition is identified by its slot when one is declared, and by its
|
||||
* display name otherwise. `originalDisplayName` renames the tag that
|
||||
* currently carries that name.
|
||||
*/
|
||||
const target = originalDisplayName
|
||||
? existingByDisplayName.get(normalizeDisplayName(originalDisplayName))
|
||||
: tagSlot
|
||||
? slotOccupant
|
||||
: existingByDisplayName.get(normalizedDisplayName)
|
||||
|
||||
if (originalDisplayName && !target) {
|
||||
errors.push(`Tag definition with display name "${originalDisplayName}" not found`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (displayName !== originalDisplayName && existingByDisplayName.has(displayName)) {
|
||||
if (tagSlot && !isValidSlotForFieldType(tagSlot, fieldType)) {
|
||||
errors.push(`Tag slot "${tagSlot}" is not valid for field type "${fieldType}"`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (target) {
|
||||
/**
|
||||
* A declared slot is an identity assertion, never a move order: the tag
|
||||
* values already written into the old slot on every document stay where
|
||||
* they are, so honouring a relocation would relabel data that never
|
||||
* moved.
|
||||
*/
|
||||
if (tagSlot && target.tagSlot !== tagSlot) {
|
||||
errors.push(
|
||||
`Tag "${target.displayName}" occupies slot "${target.tagSlot}"; a tag's slot cannot change`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
/**
|
||||
* An occupied slot declared with a different name is a collision, not a
|
||||
* rename. Document values are keyed by slot and are untouched by a
|
||||
* rename, so silently relabelling the occupant would give every document
|
||||
* a new label for old data and break saved filters keyed on the old
|
||||
* name. A genuine rename says so through `originalDisplayName`.
|
||||
*/
|
||||
if (
|
||||
!originalDisplayName &&
|
||||
slotOccupant &&
|
||||
normalizeDisplayName(slotOccupant.displayName) !== normalizedDisplayName
|
||||
) {
|
||||
errors.push(
|
||||
`Tag slot "${tagSlot}" is already in use by "${slotOccupant.displayName}"; supply originalDisplayName to rename it`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (target.fieldType !== fieldType) {
|
||||
errors.push(
|
||||
`Tag "${target.displayName}" occupies slot "${target.tagSlot}", which is not valid for field type "${fieldType}"`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const nameOwner = existingByDisplayName.get(normalizedDisplayName)
|
||||
if (nameOwner && nameOwner.id !== target.id) {
|
||||
errors.push(`Display name "${displayName}" already exists`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (target.displayName === displayName) {
|
||||
updated.push(target)
|
||||
continue
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await tx
|
||||
.update(knowledgeBaseTagDefinitions)
|
||||
.set({
|
||||
displayName,
|
||||
fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, target.id))
|
||||
|
||||
const renamed: DocumentTagDefinition = {
|
||||
id: target.id,
|
||||
knowledgeBaseId,
|
||||
tagSlot: target.tagSlot,
|
||||
displayName,
|
||||
fieldType,
|
||||
createdAt: target.createdAt,
|
||||
updatedAt: now,
|
||||
}
|
||||
existingBySlot.set(target.tagSlot, renamed)
|
||||
existingByDisplayName.delete(normalizeDisplayName(target.displayName))
|
||||
existingByDisplayName.set(normalizedDisplayName, renamed)
|
||||
updated.push(renamed)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existingByDisplayName.has(normalizedDisplayName)) {
|
||||
errors.push(`Display name "${displayName}" already exists`)
|
||||
continue
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(knowledgeBaseTagDefinitions)
|
||||
.set({
|
||||
displayName,
|
||||
fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, existingDef.id))
|
||||
|
||||
updated.push({
|
||||
id: existingDef.id,
|
||||
knowledgeBaseId,
|
||||
tagSlot: existingDef.tagSlot,
|
||||
displayName,
|
||||
fieldType,
|
||||
createdAt: existingDef.createdAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
} else {
|
||||
let finalTagSlot = tagSlot
|
||||
|
||||
if (!finalTagSlot || existingBySlot.has(finalTagSlot)) {
|
||||
if (!finalTagSlot) {
|
||||
const nextSlot = await getNextAvailableSlot(knowledgeBaseId, fieldType, existingBySlot)
|
||||
if (!nextSlot) {
|
||||
errors.push(`No available slots for field type "${fieldType}"`)
|
||||
@@ -319,41 +466,29 @@ export async function createOrUpdateTagDefinitionsBulk(
|
||||
finalTagSlot = nextSlot
|
||||
}
|
||||
|
||||
if (existingBySlot.has(finalTagSlot)) {
|
||||
errors.push(`Tag slot "${finalTagSlot}" is already in use`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existingByDisplayName.has(displayName)) {
|
||||
errors.push(`Display name "${displayName}" already exists`)
|
||||
continue
|
||||
}
|
||||
|
||||
const id = generateId()
|
||||
const now = new Date()
|
||||
|
||||
const newDefinition = {
|
||||
id,
|
||||
id: generateId(),
|
||||
knowledgeBaseId,
|
||||
tagSlot: finalTagSlot as ValidTagSlot,
|
||||
displayName,
|
||||
fieldType,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
await db.insert(knowledgeBaseTagDefinitions).values(newDefinition)
|
||||
await tx.insert(knowledgeBaseTagDefinitions).values(newDefinition)
|
||||
|
||||
// Add to maps to track for subsequent definitions in this batch
|
||||
existingBySlot.set(finalTagSlot, newDefinition)
|
||||
existingByDisplayName.set(displayName, newDefinition)
|
||||
existingByDisplayName.set(normalizedDisplayName, newDefinition)
|
||||
|
||||
created.push(newDefinition as DocumentTagDefinition)
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Error processing definition "${defData.displayName}": ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Error processing definition "${defData.displayName}": ${error}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Bulk tag definitions processed: ${created.length} created, ${updated.length} updated, ${errors.length} errors`
|
||||
@@ -540,14 +675,34 @@ export async function deleteTagDefinition(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag definition
|
||||
* Create a new tag definition.
|
||||
*
|
||||
* Opens its own transaction so the case-insensitive display-name check and the
|
||||
* insert that depends on it are one atomic step under the knowledge base's row
|
||||
* lock — see {@link assertDisplayNameAvailableInTx}. A caller that supplies
|
||||
* `txDb` already holds that lock and owns its own checks, so this only inserts.
|
||||
*/
|
||||
export async function createTagDefinition(
|
||||
data: CreateTagDefinitionData,
|
||||
requestId: string,
|
||||
txDb?: DbOrTx
|
||||
): Promise<TagDefinition> {
|
||||
const dbInstance = txDb ?? db
|
||||
if (!txDb) {
|
||||
return db.transaction(async (tx) => {
|
||||
await setTagMutationTransactionTimeouts(tx)
|
||||
await lockKnowledgeBaseForTagMutation(tx, data.knowledgeBaseId)
|
||||
await assertDisplayNameAvailableInTx(tx, data.knowledgeBaseId, data.displayName)
|
||||
return insertTagDefinition(tx, data, requestId)
|
||||
})
|
||||
}
|
||||
return insertTagDefinition(txDb, data, requestId)
|
||||
}
|
||||
|
||||
async function insertTagDefinition(
|
||||
dbInstance: DbOrTx,
|
||||
data: CreateTagDefinitionData,
|
||||
requestId: string
|
||||
): Promise<TagDefinition> {
|
||||
const tagDefinitionId = generateId()
|
||||
const now = new Date()
|
||||
|
||||
@@ -578,10 +733,17 @@ export async function createTagDefinition(
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tag definition
|
||||
* Update an existing tag definition.
|
||||
*
|
||||
* A rename reaches the same case-sensitive index a create does, so it runs the
|
||||
* case-insensitive check of {@link assertDisplayNameAvailableInTx} under the
|
||||
* knowledge base's row lock, in the transaction that then writes.
|
||||
* `knowledgeBaseId` is the base the caller resolved the definition through, and
|
||||
* is both what the lock is taken on and what the check is scoped to.
|
||||
*/
|
||||
export async function updateTagDefinition(
|
||||
tagDefinitionId: string,
|
||||
knowledgeBaseId: string,
|
||||
data: UpdateTagDefinitionData,
|
||||
requestId: string
|
||||
): Promise<TagDefinition> {
|
||||
@@ -603,18 +765,25 @@ export async function updateTagDefinition(
|
||||
updateData.fieldType = data.fieldType
|
||||
}
|
||||
|
||||
const updatedRows = await db
|
||||
.update(knowledgeBaseTagDefinitions)
|
||||
.set(updateData)
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, tagDefinitionId))
|
||||
.returning({
|
||||
id: knowledgeBaseTagDefinitions.id,
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
fieldType: knowledgeBaseTagDefinitions.fieldType,
|
||||
createdAt: knowledgeBaseTagDefinitions.createdAt,
|
||||
updatedAt: knowledgeBaseTagDefinitions.updatedAt,
|
||||
})
|
||||
const updatedRows = await db.transaction(async (tx) => {
|
||||
await setTagMutationTransactionTimeouts(tx)
|
||||
await lockKnowledgeBaseForTagMutation(tx, knowledgeBaseId)
|
||||
if (data.displayName !== undefined) {
|
||||
await assertDisplayNameAvailableInTx(tx, knowledgeBaseId, data.displayName, tagDefinitionId)
|
||||
}
|
||||
return tx
|
||||
.update(knowledgeBaseTagDefinitions)
|
||||
.set(updateData)
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, tagDefinitionId))
|
||||
.returning({
|
||||
id: knowledgeBaseTagDefinitions.id,
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
fieldType: knowledgeBaseTagDefinitions.fieldType,
|
||||
createdAt: knowledgeBaseTagDefinitions.createdAt,
|
||||
updatedAt: knowledgeBaseTagDefinitions.updatedAt,
|
||||
})
|
||||
})
|
||||
|
||||
if (updatedRows.length === 0) {
|
||||
throw new Error(`Tag definition ${tagDefinitionId} not found`)
|
||||
|
||||
@@ -11,6 +11,7 @@ const { mocks } = vi.hoisted(() => ({
|
||||
permission: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
deleteTool: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -44,7 +45,7 @@ vi.mock('@/lib/mcp/orchestration', () => ({
|
||||
performCreateWorkflowMcpServer: vi.fn(),
|
||||
performCreateWorkflowMcpTool: vi.fn(),
|
||||
performDeleteWorkflowMcpServer: vi.fn(),
|
||||
performDeleteWorkflowMcpTool: vi.fn(),
|
||||
performDeleteWorkflowMcpTool: mocks.deleteTool,
|
||||
performUpdateWorkflowMcpServer: mocks.updateServer,
|
||||
performUpdateWorkflowMcpTool: vi.fn(),
|
||||
}))
|
||||
@@ -63,7 +64,11 @@ vi.mock('@/lib/mcp/workflow-tool-schema', () => ({
|
||||
sanitizeToolName: vi.fn((name: string) => name),
|
||||
}))
|
||||
|
||||
import { updateWorkflowMcpDeploymentServer } from '@/lib/mcp/application/workflow-deployments'
|
||||
import {
|
||||
deployWorkflowMcpTool,
|
||||
undeployWorkflowMcpTool,
|
||||
updateWorkflowMcpDeploymentServer,
|
||||
} from '@/lib/mcp/application/workflow-deployments'
|
||||
|
||||
const principal = {
|
||||
kind: 'delegated' as const,
|
||||
@@ -103,6 +108,52 @@ describe('workflow MCP deployment application commands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* `deploy_as_api` is a Copilot tool name. A CLI or HTTP caller reading this
|
||||
* error has no such command, so the remediation has to name the action.
|
||||
*/
|
||||
it('states the remediation without naming an agent-only tool', async () => {
|
||||
queueTableRows(schemaMock.workflowMcpServer, [server])
|
||||
queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Orders', isDeployed: false }])
|
||||
|
||||
const rejection = await deployWorkflowMcpTool
|
||||
.execute({ principal, input: { serverId: server.id, workflowId: 'wf-1' } })
|
||||
.catch((error: Error) => error)
|
||||
|
||||
expect(rejection).toBeInstanceOf(Error)
|
||||
expect((rejection as Error).message).not.toMatch(/deploy_as_api|_as_api/)
|
||||
expect((rejection as Error).message).toContain('Deploy the workflow first')
|
||||
})
|
||||
|
||||
/**
|
||||
* Undeploying a workflow archives its registrations so a redeploy can restore
|
||||
* them — which makes an explicit tool delete the only way to withdraw one for
|
||||
* good. Resolving only live rows would block that while the workflow is
|
||||
* undeployed, and the archived row would then come back on the next deploy.
|
||||
*/
|
||||
it('withdraws a registration that an undeployed workflow left archived', async () => {
|
||||
const archivedTool = {
|
||||
id: 'tool-1',
|
||||
serverId: server.id,
|
||||
workflowId: 'wf-1',
|
||||
toolName: 'orders',
|
||||
archivedAt: new Date('2026-01-02T00:00:00Z'),
|
||||
}
|
||||
queueTableRows(schemaMock.workflowMcpServer, [server])
|
||||
queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Orders', isDeployed: false }])
|
||||
queueTableRows(schemaMock.workflowMcpTool, [])
|
||||
queueTableRows(schemaMock.workflowMcpTool, [archivedTool])
|
||||
mocks.deleteTool.mockResolvedValue({ success: true, tool: archivedTool })
|
||||
|
||||
const result = await undeployWorkflowMcpTool.execute({
|
||||
principal,
|
||||
input: { serverId: server.id, workflowId: 'wf-1' },
|
||||
})
|
||||
|
||||
expect(result.tool.id).toBe('tool-1')
|
||||
expect(mocks.deleteTool).toHaveBeenCalledWith(expect.objectContaining({ toolId: 'tool-1' }))
|
||||
})
|
||||
|
||||
it('derives workspace authorization canonically from the server id', async () => {
|
||||
queueTableRows(schemaMock.workflowMcpServer, [{ ...server, workspaceId: 'workspace-2' }])
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getLiveWorkflowMcpTool,
|
||||
getWorkflowMcpPublishableWorkflow,
|
||||
getWorkflowMcpServerById,
|
||||
getWorkflowMcpToolIncludingArchived,
|
||||
listLiveWorkflowMcpTools,
|
||||
listWorkflowMcpToolNames,
|
||||
listWorkspaceWorkflowMcpServers,
|
||||
@@ -318,7 +319,7 @@ export const deployWorkflowMcpTool = defineAuthorizedWorkspaceUseCase({
|
||||
if (!context.workflow.isDeployed) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
'Workflow must be deployed before adding as an MCP tool. Use deploy_as_api first.'
|
||||
'Workflow must be deployed before adding as an MCP tool. Deploy the workflow first.'
|
||||
)
|
||||
}
|
||||
if (
|
||||
@@ -411,7 +412,7 @@ export const undeployWorkflowMcpTool = defineAuthorizedWorkspaceUseCase({
|
||||
resolveWorkflowToolContext(input.serverId, input.workflowId),
|
||||
authorizationOptions,
|
||||
async execute({ principal, context }) {
|
||||
const tool = await getLiveWorkflowMcpTool(context.server.id, context.workflow.id)
|
||||
const tool = await getWorkflowMcpToolIncludingArchived(context.server.id, context.workflow.id)
|
||||
if (!tool) {
|
||||
throw new OrchestrationError('not_found', 'Workflow is not deployed to this MCP server')
|
||||
}
|
||||
|
||||
@@ -9,3 +9,16 @@ export const MAX_MCP_TOOLS_LIST_RESPONSE_BYTES = 10 * 1024 * 1024
|
||||
export const MAX_MCP_WORKFLOW_RESPONSE_BYTES = 10 * 1024 * 1024
|
||||
export const MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES = MAX_MCP_PARAMETER_SCHEMA_BYTES
|
||||
export const MAX_MCP_SERVER_TOOLS_METADATA_BYTES = MAX_MCP_TOOLS_LIST_RESPONSE_BYTES
|
||||
|
||||
/**
|
||||
* Cap on the persisted `mcp_servers.last_error` text.
|
||||
*
|
||||
* The failure message can embed a remote response body verbatim — a URL that is
|
||||
* not an MCP endpoint answers a `tools/list` POST with whatever it serves, so an
|
||||
* entire HTML document would otherwise be stored and re-served by every `list`
|
||||
* and `get` of that server. 500 characters holds the whole diagnostic prefix
|
||||
* (`Failed to connect to "<name>": <transport error>`) plus the opening of any
|
||||
* upstream text, which is the part that identifies the misconfiguration, while
|
||||
* bounding the column at half a kilobyte per row.
|
||||
*/
|
||||
export const MAX_MCP_LAST_ERROR_LENGTH = 500
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { and, asc, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm'
|
||||
import {
|
||||
type CursorKey,
|
||||
type KeysetKey,
|
||||
@@ -277,6 +277,38 @@ export async function getLiveWorkflowMcpTool(
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration for a workflow on a server, archived or not.
|
||||
*
|
||||
* Undeploying a workflow archives its registrations rather than destroying them
|
||||
* so a redeploy can restore them. A user withdrawing a registration on purpose
|
||||
* must still be able to do so while the workflow is undeployed — and that hard
|
||||
* delete is exactly what stops the next deploy from resurrecting it — so the
|
||||
* delete path resolves the row through this instead of
|
||||
* {@link getLiveWorkflowMcpTool}.
|
||||
*/
|
||||
export async function getWorkflowMcpToolIncludingArchived(
|
||||
serverId: string,
|
||||
workflowId: string
|
||||
): Promise<WorkflowMcpToolRow | null> {
|
||||
const live = await getLiveWorkflowMcpTool(serverId, workflowId)
|
||||
if (live) return live
|
||||
|
||||
const [archivedRow] = await db
|
||||
.select()
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.serverId, serverId),
|
||||
eq(workflowMcpTool.workflowId, workflowId),
|
||||
isNotNull(workflowMcpTool.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workflowMcpTool.archivedAt))
|
||||
.limit(1)
|
||||
return archivedRow ?? null
|
||||
}
|
||||
|
||||
export type WorkflowMcpPublishableWorkflow = {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -120,6 +120,7 @@ vi.mock('@/lib/mcp/storage', () => ({
|
||||
getMcpCacheType: () => 'memory',
|
||||
}))
|
||||
|
||||
import { MAX_MCP_LAST_ERROR_LENGTH } from '@/lib/mcp/constants'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
|
||||
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
@@ -609,6 +610,30 @@ describe('McpService.discoverTools per-server caching', () => {
|
||||
expectSqlSideFailureIncrement(failureStatusWrite('Connection refused'))
|
||||
})
|
||||
|
||||
/**
|
||||
* A URL that is not an MCP endpoint answers the discovery POST with whatever
|
||||
* it serves, and the transport folds that body verbatim into the error. The
|
||||
* unbounded message used to land in `last_error`, which both `list` and `get`
|
||||
* republish, so one misconfigured URL could persist and re-serve an entire
|
||||
* remote document.
|
||||
*/
|
||||
it('bounds the persisted lastError instead of storing a whole remote body', async () => {
|
||||
const remoteBody = `<!doctype html><html><body>${'x'.repeat(20000)}</body></html>`
|
||||
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
|
||||
mockListTools.mockRejectedValueOnce(new Error(remoteBody))
|
||||
|
||||
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow()
|
||||
|
||||
const write = dbChainMockFns.set.mock.calls
|
||||
.map(([values]) => values as Record<string, unknown> | undefined)
|
||||
.find((values) => typeof values?.lastError === 'string' && values.lastError !== null)
|
||||
expect(write, 'no status write carried a lastError').toBeDefined()
|
||||
const lastError = write?.lastError as string
|
||||
expect(lastError.length).toBeLessThanOrEqual(MAX_MCP_LAST_ERROR_LENGTH + 3)
|
||||
expect(lastError.endsWith('...')).toBe(true)
|
||||
expect(lastError).toContain('<!doctype html>')
|
||||
})
|
||||
|
||||
/**
|
||||
* `updatedAt` is one of the public list's keyset sorts. A background discovery
|
||||
* stamping it moves rows to the head of `sortBy=updatedAt` mid-walk, so a
|
||||
|
||||
@@ -7,11 +7,13 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { backoffWithJitter } from '@sim/utils/retry'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { and, eq, isNull, lte, or, sql } from 'drizzle-orm'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
|
||||
import { mcpConnectionPool } from '@/lib/mcp/connection-pool'
|
||||
import { MAX_MCP_LAST_ERROR_LENGTH } from '@/lib/mcp/constants'
|
||||
import {
|
||||
isMcpDomainAllowed,
|
||||
validateMcpDomain,
|
||||
@@ -711,7 +713,7 @@ class McpService {
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
connectionStatus: sql`CASE WHEN ${nextFailures} >= ${MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES} THEN 'error' ELSE 'disconnected' END`,
|
||||
lastError: update.error || 'Unknown error',
|
||||
lastError: truncate(update.error || 'Unknown error', MAX_MCP_LAST_ERROR_LENGTH),
|
||||
statusConfig: sql`jsonb_build_object('consecutiveFailures', ${nextFailures}, 'lastSuccessfulDiscovery', ${mcpServers.statusConfig} -> 'lastSuccessfulDiscovery')`,
|
||||
})
|
||||
.where(liveServerScope)
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { flattenMockConditions, hasMockCondition } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* The global `@sim/db` mock carries the chain fns but no table objects, and
|
||||
* this module reaches for `workflowMcpTool` directly.
|
||||
*/
|
||||
vi.mock('@sim/db', async () => {
|
||||
const { databaseMock } = await import('@sim/testing')
|
||||
const column = (name: string) => `workflow_mcp_tool.${name}`
|
||||
return {
|
||||
...databaseMock,
|
||||
workflowMcpServer: { id: 'workflow_mcp_server.id', workspaceId: 'workflow_mcp_server.ws' },
|
||||
workflowMcpTool: {
|
||||
id: column('id'),
|
||||
serverId: column('server_id'),
|
||||
workflowId: column('workflow_id'),
|
||||
toolName: column('tool_name'),
|
||||
toolDescription: column('tool_description'),
|
||||
parameterSchema: column('parameter_schema'),
|
||||
parameterDescriptionOverrides: column('parameter_description_overrides'),
|
||||
archivedAt: column('archived_at'),
|
||||
createdAt: column('created_at'),
|
||||
updatedAt: column('updated_at'),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const { mocks } = vi.hoisted(() => ({
|
||||
mocks: {
|
||||
acquireLock: vi.fn(),
|
||||
hasValidStartBlock: vi.fn(),
|
||||
loadDeployedState: vi.fn(),
|
||||
usageRows: vi.fn(),
|
||||
exceedsBudget: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/mcp/server-locks', () => ({
|
||||
acquireWorkflowMcpServerLock: mocks.acquireLock,
|
||||
}))
|
||||
vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
|
||||
hasValidStartBlockInState: mocks.hasValidStartBlock,
|
||||
}))
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
loadDeployedWorkflowState: mocks.loadDeployedState,
|
||||
}))
|
||||
vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: null }))
|
||||
vi.mock('@/lib/mcp/workflow-tool-schema', () => ({
|
||||
applyDescriptionOverrides: (schema: unknown) => schema,
|
||||
extractInputFormatFromBlocks: () => [],
|
||||
generateToolInputSchema: () => ({ type: 'object', properties: {} }),
|
||||
pruneOverridesToSchema: (overrides: unknown) => overrides,
|
||||
}))
|
||||
vi.mock('@/lib/mcp/tool-limits', () => ({
|
||||
addMcpToolMetadataUsageRow: () => ({ bytes: 0 }),
|
||||
createMcpToolMetadataUsageRow: (row: unknown) => row,
|
||||
exceedsMcpServerToolMetadataBudget: mocks.exceedsBudget,
|
||||
getMcpServerToolMetadataUsageRows: mocks.usageRows,
|
||||
getMcpToolMetadataUsageFromRows: () => ({ bytes: 0 }),
|
||||
subtractMcpToolMetadataUsageRow: () => ({ bytes: 0 }),
|
||||
validateMcpToolMetadataForStorage: () => null,
|
||||
}))
|
||||
|
||||
import { workflowMcpTool } from '@sim/db'
|
||||
import { MAX_MCP_SERVERS_PER_WORKFLOW, MAX_MCP_TOOLS_PER_SERVER } from '@/lib/mcp/constants'
|
||||
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
|
||||
const WORKFLOW_ID = 'wf-1'
|
||||
const REQUEST_ID = 'req-1'
|
||||
|
||||
interface RecordedWrite {
|
||||
op: 'update' | 'delete'
|
||||
table: unknown
|
||||
values?: Record<string, unknown>
|
||||
where?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued read is either a fixed row set or a thunk evaluated at read time, so
|
||||
* a test can model a row another transaction commits partway through the sync.
|
||||
*/
|
||||
type QueuedRead = unknown[] | (() => unknown[])
|
||||
|
||||
/** Marks the handle `.as(alias)` returns so `.from()` can recognise a subquery. */
|
||||
const FAKE_SUBQUERY = Symbol('fakeSubquery')
|
||||
|
||||
interface FakeSelect {
|
||||
projection: Record<string, unknown>
|
||||
distinctOn: unknown[]
|
||||
order: { type: string; column: unknown }[]
|
||||
limit: number | null
|
||||
source: FakeSelect | null
|
||||
where: unknown
|
||||
}
|
||||
|
||||
interface FakeOrderNode {
|
||||
type: string
|
||||
column: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal drizzle chain over an ordered queue of results, so the sync's exact
|
||||
* statement sequence — and whether a withdrawal deletes or archives — is
|
||||
* observable without a live database.
|
||||
*
|
||||
* `orderBy`, `selectDistinctOn` and `limit` are evaluated the way Postgres
|
||||
* evaluates them (sort, then dedupe on the leading key, then bound), and a
|
||||
* select whose `from` is another select's `.as(alias)` evaluates that inner
|
||||
* statement first. A test can therefore observe which rows a bounded, possibly
|
||||
* two-stage candidate query actually returns rather than being handed a canned
|
||||
* answer.
|
||||
*
|
||||
* `liveServerIds` models the one correlated anti-join the restore issues: a
|
||||
* select whose WHERE carries a `notExists` drops the rows whose `serverId` the
|
||||
* workflow already holds a live registration on, exactly as the subquery would.
|
||||
* A statement that does not ask for the anti-join is filtered by nothing, so a
|
||||
* fixture can tell the two apart.
|
||||
*/
|
||||
function createFakeTx(results: QueuedRead[], liveServerIds: string[] = []) {
|
||||
const writes: RecordedWrite[] = []
|
||||
const queue = [...results]
|
||||
const liveServers = new Set(liveServerIds)
|
||||
let pending: RecordedWrite | null = null
|
||||
const selects: FakeSelect[] = []
|
||||
|
||||
const top = (): FakeSelect | null => selects.at(-1) ?? null
|
||||
|
||||
const startSelect = (projection: Record<string, unknown>, distinctOn: unknown[]): void => {
|
||||
selects.push({ projection, distinctOn, order: [], limit: null, source: null, where: undefined })
|
||||
}
|
||||
|
||||
/** Resolves an order/distinct column token to the row key it was selected as. */
|
||||
const rowKeyFor = (select: FakeSelect, column: unknown): string | null => {
|
||||
const projection = select.source ? select.source.projection : select.projection
|
||||
return Object.keys(projection).find((key) => projection[key] === column) ?? null
|
||||
}
|
||||
|
||||
const compare = (a: unknown, b: unknown): number => {
|
||||
if ((a as never) < (b as never)) return -1
|
||||
if ((a as never) > (b as never)) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
const evaluate = (select: FakeSelect): unknown[] => {
|
||||
let rows: unknown[]
|
||||
if (select.source) {
|
||||
rows = evaluate(select.source)
|
||||
} else {
|
||||
const next = queue.shift() ?? []
|
||||
rows = typeof next === 'function' ? next() : next
|
||||
}
|
||||
|
||||
if (hasMockCondition(select.where, (node) => node.type === 'notExists')) {
|
||||
rows = rows.filter(
|
||||
(row) => !liveServers.has((row as { serverId?: string }).serverId as string)
|
||||
)
|
||||
}
|
||||
|
||||
if (select.order.length > 0) {
|
||||
rows = [...rows].sort((left, right) => {
|
||||
for (const node of select.order) {
|
||||
const key = rowKeyFor(select, node.column)
|
||||
if (!key) continue
|
||||
const result = compare(
|
||||
(left as Record<string, unknown>)[key],
|
||||
(right as Record<string, unknown>)[key]
|
||||
)
|
||||
if (result !== 0) return node.type === 'desc' ? -result : result
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
if (select.distinctOn.length > 0) {
|
||||
const keys = select.distinctOn
|
||||
.map((column) => rowKeyFor(select, column))
|
||||
.filter((key): key is string => key !== null)
|
||||
const seen = new Set<string>()
|
||||
rows = rows.filter((row) => {
|
||||
const identity = JSON.stringify(keys.map((key) => (row as Record<string, unknown>)[key]))
|
||||
if (seen.has(identity)) return false
|
||||
seen.add(identity)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return select.limit === null ? rows : rows.slice(0, select.limit)
|
||||
}
|
||||
|
||||
const builder: Record<string, unknown> = {
|
||||
select(projection: Record<string, unknown>) {
|
||||
startSelect(projection ?? {}, [])
|
||||
return builder
|
||||
},
|
||||
selectDistinctOn(columns: unknown[], projection: Record<string, unknown>) {
|
||||
startSelect(projection ?? {}, columns)
|
||||
return builder
|
||||
},
|
||||
from(source: unknown) {
|
||||
const handle = (source as Record<symbol, FakeSelect> | null)?.[FAKE_SUBQUERY]
|
||||
const select = top()
|
||||
if (select && handle) select.source = handle
|
||||
return builder
|
||||
},
|
||||
orderBy(...nodes: FakeOrderNode[]) {
|
||||
const select = top()
|
||||
if (select) select.order = nodes
|
||||
return builder
|
||||
},
|
||||
limit(value: number) {
|
||||
const select = top()
|
||||
if (select) select.limit = value
|
||||
return builder
|
||||
},
|
||||
as() {
|
||||
const select = selects.pop() ?? null
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, key) {
|
||||
if (key === FAKE_SUBQUERY) return select
|
||||
return select?.projection[key as string]
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
update(table: unknown) {
|
||||
pending = { op: 'update', table }
|
||||
return builder
|
||||
},
|
||||
delete(table: unknown) {
|
||||
pending = { op: 'delete', table }
|
||||
writes.push(pending)
|
||||
return builder
|
||||
},
|
||||
set(values: Record<string, unknown>) {
|
||||
if (pending) {
|
||||
pending.values = values
|
||||
writes.push(pending)
|
||||
}
|
||||
return builder
|
||||
},
|
||||
/**
|
||||
* A correlated subquery is built — and finished by its own `where` — while
|
||||
* its enclosing statement is still open, so a completed select is popped
|
||||
* here to let the outer statement's `where` land on the outer select.
|
||||
*/
|
||||
where(condition: unknown) {
|
||||
if (pending) {
|
||||
pending.where = condition
|
||||
pending = null
|
||||
return builder
|
||||
}
|
||||
while (selects.length > 1 && top()?.where !== undefined) selects.pop()
|
||||
const select = top()
|
||||
if (select && select.where === undefined) select.where = condition
|
||||
return builder
|
||||
},
|
||||
then(onFulfilled: (value: unknown) => unknown) {
|
||||
const select = selects.pop() ?? null
|
||||
const rows = select ? evaluate(select) : (queue.shift() ?? [])
|
||||
return Promise.resolve(rows).then(onFulfilled)
|
||||
},
|
||||
}
|
||||
|
||||
return { tx: builder as never, writes, remaining: () => queue.length }
|
||||
}
|
||||
|
||||
const archivedRow = (id: string, serverId: string, toolName: string, updatedAt?: Date) => ({
|
||||
id,
|
||||
serverId,
|
||||
toolName,
|
||||
toolDescription: null,
|
||||
parameterSchema: { type: 'object', properties: {} },
|
||||
updatedAt,
|
||||
})
|
||||
|
||||
/**
|
||||
* Restore counts the workflow's live server memberships twice — once to bound
|
||||
* the candidate query, once under the candidate locks to make the budget
|
||||
* authoritative — so a fixture with no live registrations queues this twice.
|
||||
*/
|
||||
const NO_LIVE_SERVERS: unknown[] = []
|
||||
|
||||
const liveServerRows = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
serverId: `srv-live-${String(index).padStart(3, '0')}`,
|
||||
}))
|
||||
|
||||
const toolRow = (id: string, serverId: string) => ({
|
||||
id,
|
||||
serverId,
|
||||
toolName: `tool_${id}`,
|
||||
toolDescription: null,
|
||||
parameterDescriptionOverrides: {},
|
||||
})
|
||||
|
||||
describe('workflow MCP tool withdrawal and restore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.acquireLock.mockResolvedValue(undefined)
|
||||
mocks.usageRows.mockResolvedValue([])
|
||||
mocks.exceedsBudget.mockReturnValue(false)
|
||||
mocks.hasValidStartBlock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Undeploy used to DELETE every registration publishing the workflow, so a
|
||||
* redeploy could not bring them back and each server entry had to be
|
||||
* re-created by hand. Deploy/undeploy is a reversible lifecycle; only an
|
||||
* explicit tool delete is destructive.
|
||||
*/
|
||||
it('archives registrations on withdrawal instead of destroying them', async () => {
|
||||
const { tx, writes } = createFakeTx([[toolRow('t-1', 'srv-1')], []])
|
||||
|
||||
const servers = await removeMcpToolsForWorkflow(WORKFLOW_ID, REQUEST_ID, tx, true)
|
||||
|
||||
expect(servers).toEqual([{ serverId: 'srv-1' }])
|
||||
expect(writes.some((write) => write.op === 'delete')).toBe(false)
|
||||
const archive = writes.find((write) => write.op === 'update')
|
||||
expect(archive?.table).toBe(workflowMcpTool)
|
||||
expect(archive?.values?.archivedAt).toBeInstanceOf(Date)
|
||||
expect(hasMockCondition(archive?.where, (node) => node.type === 'isNull')).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Redeploying republishes the workflow on exactly the servers it was
|
||||
* published on before the undeploy.
|
||||
*/
|
||||
it('restores archived registrations when the workflow is deployed again', async () => {
|
||||
const { tx, writes } = createFakeTx([
|
||||
NO_LIVE_SERVERS,
|
||||
[archivedRow('t-1', 'srv-1', 'orders')],
|
||||
NO_LIVE_SERVERS,
|
||||
[],
|
||||
[],
|
||||
[toolRow('t-1', 'srv-1')],
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const restore = writes.find((write) => write.values?.archivedAt === null)
|
||||
expect(restore).toBeDefined()
|
||||
expect(restore?.table).toBe(workflowMcpTool)
|
||||
expect(
|
||||
hasMockCondition(
|
||||
restore?.where,
|
||||
(node) => node.type === 'inArray' && (node.values as string[]).includes('t-1')
|
||||
)
|
||||
).toBe(true)
|
||||
expect(mocks.acquireLock).toHaveBeenCalledWith(tx, 'srv-1')
|
||||
})
|
||||
|
||||
/**
|
||||
* `workflow_mcp_tool_server_workflow_unique` only covers unarchived rows, so
|
||||
* reviving a second live row for a server that already has one would violate
|
||||
* it on commit.
|
||||
*/
|
||||
it('never restores onto a server that already carries a live registration', async () => {
|
||||
const { tx, writes } = createFakeTx([
|
||||
NO_LIVE_SERVERS,
|
||||
[archivedRow('t-archived', 'srv-1', 'orders')],
|
||||
NO_LIVE_SERVERS,
|
||||
[{ id: 't-live', toolName: 'invoices', workflowId: WORKFLOW_ID }],
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
expect(writes.some((write) => write.values?.archivedAt === null)).toBe(false)
|
||||
})
|
||||
/**
|
||||
* Repeated undeploy/skipped-restore/manual-re-add cycles stack several
|
||||
* archived generations on one server. Bounding the candidate query by ROWS
|
||||
* let those duplicates consume the whole budget, so every other server the
|
||||
* workflow was published on fell out of the result and stayed unavailable
|
||||
* after an otherwise successful redeploy. The bound must apply to servers,
|
||||
* which means deduping to one row per server first.
|
||||
*/
|
||||
it('restores every distinct server even when one server has more archived generations than the bound', async () => {
|
||||
const duplicates = Array.from({ length: MAX_MCP_SERVERS_PER_WORKFLOW }, (_, index) =>
|
||||
archivedRow(`t-dup-${index}`, 'srv-dup', 'orders', new Date(2020, 0, 1 + index))
|
||||
)
|
||||
const newestDuplicateId = `t-dup-${MAX_MCP_SERVERS_PER_WORKFLOW - 1}`
|
||||
const { tx, writes } = createFakeTx([
|
||||
NO_LIVE_SERVERS,
|
||||
[
|
||||
...duplicates,
|
||||
archivedRow('t-2', 'srv-2', 'orders', new Date(2020, 5, 1)),
|
||||
archivedRow('t-3', 'srv-3', 'orders', new Date(2020, 5, 2)),
|
||||
],
|
||||
NO_LIVE_SERVERS,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
expect(mocks.acquireLock.mock.calls.map((call) => call[1])).toEqual([
|
||||
'srv-2',
|
||||
'srv-3',
|
||||
'srv-dup',
|
||||
])
|
||||
const restore = writes.find((write) => write.values?.archivedAt === null)
|
||||
expect(
|
||||
hasMockCondition(
|
||||
restore?.where,
|
||||
(node) => node.type === 'inArray' && (node.values as string[]).includes('t-2')
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasMockCondition(
|
||||
restore?.where,
|
||||
(node) => node.type === 'inArray' && (node.values as string[]).includes('t-3')
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasMockCondition(
|
||||
restore?.where,
|
||||
(node) => node.type === 'inArray' && (node.values as string[]).includes(newestDuplicateId)
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasMockCondition(
|
||||
restore?.where,
|
||||
(node) => node.type === 'inArray' && (node.values as string[]).includes('t-dup-0')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* `DISTINCT ON (server_id)` forces `server_id` to lead the ORDER BY, so
|
||||
* bounding that statement directly keeps the lexicographically lowest server
|
||||
* ids rather than the servers the workflow most recently published on. A
|
||||
* workflow archived across more than `MAX_MCP_SERVERS_PER_WORKFLOW` servers
|
||||
* would then come back on stale servers and leave its current ones archived.
|
||||
* Deduplication and the recency bound must therefore be separate stages.
|
||||
*/
|
||||
it('bounds the restore by the most recently used servers, not the lowest server ids', async () => {
|
||||
const overflow = 2
|
||||
const rows = Array.from({ length: MAX_MCP_SERVERS_PER_WORKFLOW + overflow }, (_, index) =>
|
||||
archivedRow(
|
||||
`t-${String(index).padStart(3, '0')}`,
|
||||
`srv-${String(index).padStart(3, '0')}`,
|
||||
'orders',
|
||||
new Date(2020, 0, 1 + index)
|
||||
)
|
||||
)
|
||||
|
||||
const { tx, writes } = createFakeTx([NO_LIVE_SERVERS, rows, NO_LIVE_SERVERS])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const lockedServers = mocks.acquireLock.mock.calls.map((call) => call[1])
|
||||
expect(lockedServers).toHaveLength(MAX_MCP_SERVERS_PER_WORKFLOW)
|
||||
expect(lockedServers).toEqual([...lockedServers].sort())
|
||||
expect(lockedServers).not.toContain('srv-000')
|
||||
expect(lockedServers).not.toContain('srv-001')
|
||||
expect(lockedServers).toContain(
|
||||
`srv-${String(MAX_MCP_SERVERS_PER_WORKFLOW + 1).padStart(3, '0')}`
|
||||
)
|
||||
|
||||
const restore = writes.find((write) => write.values?.archivedAt === null)
|
||||
const restoredIds = flattenMockConditions(restore?.where).find(
|
||||
(node) => node.type === 'inArray'
|
||||
)?.values as string[]
|
||||
expect(restoredIds).toHaveLength(MAX_MCP_SERVERS_PER_WORKFLOW)
|
||||
expect(restoredIds).not.toContain('t-000')
|
||||
expect(restoredIds).not.toContain('t-001')
|
||||
expect(restoredIds).toContain(`t-${String(MAX_MCP_SERVERS_PER_WORKFLOW + 1).padStart(3, '0')}`)
|
||||
})
|
||||
|
||||
/**
|
||||
* Archiving frees the server slot, so the servers a workflow is live on and
|
||||
* the servers it has archived registrations on are disjoint budgets that both
|
||||
* count towards `MAX_MCP_SERVERS_PER_WORKFLOW`. Bounding the candidate query
|
||||
* at the whole limit let a workflow that had been partly re-registered by
|
||||
* hand restore a full limit's worth on top of its live registrations; the
|
||||
* fanout check that runs immediately afterwards in the same deploy
|
||||
* transaction then threw and rolled the deployment back. The bound must be
|
||||
* the REMAINING headroom, and the candidates that fit it must be the most
|
||||
* recently used servers.
|
||||
*/
|
||||
it('restores only as many servers as the workflow fanout budget still allows', async () => {
|
||||
const headroom = 2
|
||||
const liveServers = liveServerRows(MAX_MCP_SERVERS_PER_WORKFLOW - headroom)
|
||||
const candidates = Array.from({ length: 5 }, (_, index) =>
|
||||
archivedRow(
|
||||
`t-arch-${index}`,
|
||||
`srv-arch-${index}`,
|
||||
`orders_${index}`,
|
||||
new Date(2020, 0, 1 + index)
|
||||
)
|
||||
)
|
||||
|
||||
const { tx, writes } = createFakeTx([liveServers, candidates, liveServers, [], []])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
expect(mocks.acquireLock.mock.calls.map((call) => call[1])).toEqual([
|
||||
'srv-arch-3',
|
||||
'srv-arch-4',
|
||||
])
|
||||
|
||||
const restore = writes.find((write) => write.values?.archivedAt === null)
|
||||
const restoredIds = flattenMockConditions(restore?.where).find(
|
||||
(node) => node.type === 'inArray'
|
||||
)?.values as string[]
|
||||
expect(restoredIds).toEqual(['t-arch-4', 't-arch-3'])
|
||||
expect(liveServers.length + restoredIds.length).toBeLessThanOrEqual(
|
||||
MAX_MCP_SERVERS_PER_WORKFLOW
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* A `(server, workflow)` pair can hold both a live row and archived
|
||||
* generations: `workflow_mcp_tool_server_workflow_unique` is scoped to
|
||||
* unarchived rows, so it constrains only the live one. Such a server was
|
||||
* counted twice — once by the live-server count that shrinks the budget, once
|
||||
* as a candidate — and the slot it consumed was spent on a candidate the
|
||||
* post-lock liveness check could only ever skip, leaving a genuinely
|
||||
* restorable server beyond the bound unfetched. The candidate query must
|
||||
* exclude it so the budget and the candidate set agree.
|
||||
*/
|
||||
it('never spends a restore slot on a server the workflow is already live on', async () => {
|
||||
const headroom = 2
|
||||
const sharedServerId = 'srv-shared'
|
||||
const liveServers = [
|
||||
...liveServerRows(MAX_MCP_SERVERS_PER_WORKFLOW - headroom - 1),
|
||||
{ serverId: sharedServerId },
|
||||
]
|
||||
const candidates = [
|
||||
archivedRow('t-arch-a', 'srv-arch-a', 'orders_a', new Date(2020, 0, 1)),
|
||||
archivedRow('t-arch-b', 'srv-arch-b', 'orders_b', new Date(2020, 0, 2)),
|
||||
archivedRow('t-arch-shared', sharedServerId, 'orders_shared', new Date(2020, 0, 3)),
|
||||
]
|
||||
|
||||
const { tx, writes } = createFakeTx(
|
||||
[liveServers, candidates, liveServers, [], [], []],
|
||||
[sharedServerId]
|
||||
)
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
expect(mocks.acquireLock.mock.calls.map((call) => call[1])).toEqual([
|
||||
'srv-arch-a',
|
||||
'srv-arch-b',
|
||||
])
|
||||
|
||||
const restore = writes.find((write) => write.values?.archivedAt === null)
|
||||
const restoredIds = flattenMockConditions(restore?.where).find(
|
||||
(node) => node.type === 'inArray'
|
||||
)?.values as string[]
|
||||
expect(restoredIds).toEqual(['t-arch-b', 't-arch-a'])
|
||||
})
|
||||
|
||||
/**
|
||||
* The headroom that bounds the candidate query is read before any lock is
|
||||
* taken, so a manual `tools create` on another server can consume part of it
|
||||
* in between. The budget is therefore recounted once every candidate server
|
||||
* is locked and spent one unit per accepted candidate, and the candidates
|
||||
* that survive the smaller budget are still the most recent ones.
|
||||
*/
|
||||
it('re-reads the fanout budget under the locks and drops the least recent candidate', async () => {
|
||||
const preLockLiveServers = liveServerRows(MAX_MCP_SERVERS_PER_WORKFLOW - 2)
|
||||
const underLockLiveServers = liveServerRows(MAX_MCP_SERVERS_PER_WORKFLOW - 1)
|
||||
const candidates = Array.from({ length: 5 }, (_, index) =>
|
||||
archivedRow(
|
||||
`t-arch-${index}`,
|
||||
`srv-arch-${index}`,
|
||||
`orders_${index}`,
|
||||
new Date(2020, 0, 1 + index)
|
||||
)
|
||||
)
|
||||
|
||||
const { tx, writes } = createFakeTx([
|
||||
preLockLiveServers,
|
||||
candidates,
|
||||
underLockLiveServers,
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const restore = writes.find((write) => write.values?.archivedAt === null)
|
||||
const restoredIds = flattenMockConditions(restore?.where).find(
|
||||
(node) => node.type === 'inArray'
|
||||
)?.values as string[]
|
||||
expect(restoredIds).toEqual(['t-arch-4'])
|
||||
})
|
||||
|
||||
/**
|
||||
* A manual `tools create` on the same server can commit between a pre-lock
|
||||
* uniqueness read and the lock. Reading live rows before the lock therefore
|
||||
* proves nothing: restore would un-archive a second live row for the same
|
||||
* `(server_id, workflow_id)`, violate
|
||||
* `workflow_mcp_tool_server_workflow_unique` on commit, and roll back the
|
||||
* whole deploy. The queued read here answers differently once the lock has
|
||||
* been taken, exactly as the database would.
|
||||
*/
|
||||
it('sees a live registration that appears only after the lock is taken', async () => {
|
||||
let concurrentCreateCommitted = false
|
||||
mocks.acquireLock.mockImplementation(async () => {
|
||||
concurrentCreateCommitted = true
|
||||
})
|
||||
|
||||
const { tx, writes } = createFakeTx([
|
||||
NO_LIVE_SERVERS,
|
||||
[archivedRow('t-archived', 'srv-1', 'orders')],
|
||||
NO_LIVE_SERVERS,
|
||||
() =>
|
||||
concurrentCreateCommitted
|
||||
? [{ id: 't-live', toolName: 'invoices', workflowId: WORKFLOW_ID }]
|
||||
: [],
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
expect(mocks.acquireLock).toHaveBeenCalledWith(tx, 'srv-1')
|
||||
expect(writes.some((write) => write.values?.archivedAt === null)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* Archiving frees the tool name: the create-path collision query skips
|
||||
* archived rows, so another workflow can take `orders` while this one is
|
||||
* undeployed. Restoring blindly would leave the server serving two live
|
||||
* tools named `orders`, which no database constraint catches.
|
||||
*/
|
||||
it('leaves a candidate archived when its tool name is taken by a live tool', async () => {
|
||||
const { tx, writes } = createFakeTx([
|
||||
NO_LIVE_SERVERS,
|
||||
[archivedRow('t-archived', 'srv-1', 'orders')],
|
||||
NO_LIVE_SERVERS,
|
||||
[{ id: 't-other', toolName: 'orders' }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
expect(mocks.acquireLock).toHaveBeenCalledWith(tx, 'srv-1')
|
||||
expect(writes.some((write) => write.values?.archivedAt === null)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* Archiving also frees the server slot, so a restore can push a server past
|
||||
* MAX_MCP_TOOLS_PER_SERVER that the create path would have rejected.
|
||||
*/
|
||||
it('leaves a candidate archived when the server is already at the tool cap', async () => {
|
||||
const liveTools = Array.from({ length: MAX_MCP_TOOLS_PER_SERVER }, (_, index) => ({
|
||||
id: `t-live-${index}`,
|
||||
toolName: `tool_${index}`,
|
||||
}))
|
||||
const { tx, writes } = createFakeTx([
|
||||
NO_LIVE_SERVERS,
|
||||
[archivedRow('t-archived', 'srv-1', 'orders')],
|
||||
NO_LIVE_SERVERS,
|
||||
liveTools,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
expect(writes.some((write) => write.values?.archivedAt === null)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* A restore that pushes the server past its tools/list metadata budget breaks
|
||||
* the server for every consumer with no recovery path, so the candidate stays
|
||||
* archived instead.
|
||||
*/
|
||||
it('leaves a candidate archived when restoring it would exceed the metadata budget', async () => {
|
||||
mocks.exceedsBudget.mockReturnValue(true)
|
||||
const { tx, writes } = createFakeTx([
|
||||
NO_LIVE_SERVERS,
|
||||
[archivedRow('t-archived', 'srv-1', 'orders')],
|
||||
NO_LIVE_SERVERS,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: WORKFLOW_ID,
|
||||
requestId: REQUEST_ID,
|
||||
state: { blocks: {} },
|
||||
tx,
|
||||
notify: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
expect(mocks.exceedsBudget).toHaveBeenCalled()
|
||||
expect(writes.some((write) => write.values?.archivedAt === null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { db, workflowMcpServer, workflowMcpTool } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq, gt, inArray, isNull } from 'drizzle-orm'
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notExists } from 'drizzle-orm'
|
||||
import { alias } from 'drizzle-orm/pg-core'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { MAX_MCP_SERVERS_PER_WORKFLOW } from '@/lib/mcp/constants'
|
||||
import { MAX_MCP_SERVERS_PER_WORKFLOW, MAX_MCP_TOOLS_PER_SERVER } from '@/lib/mcp/constants'
|
||||
import { acquireWorkflowMcpServerLock } from '@/lib/mcp/server-locks'
|
||||
import {
|
||||
addMcpToolMetadataUsageRow,
|
||||
@@ -109,6 +110,273 @@ async function collectWorkflowMcpToolServerIds(
|
||||
return [...serverIds].sort().map((serverId) => ({ serverId }))
|
||||
}
|
||||
|
||||
interface RestoreCandidate {
|
||||
id: string
|
||||
serverId: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the distinct servers the workflow is currently published on. Bounded at
|
||||
* the fanout limit so the read never grows with the number of registrations,
|
||||
* and never itself the reason a deploy fails: unlike
|
||||
* {@link collectWorkflowMcpToolServerIds} it reports the count instead of
|
||||
* throwing once the limit is passed.
|
||||
*/
|
||||
async function countWorkflowLiveMcpServers(tx: DbOrTx, workflowId: string): Promise<number> {
|
||||
const rows = await tx
|
||||
.selectDistinctOn([workflowMcpTool.serverId], { serverId: workflowMcpTool.serverId })
|
||||
.from(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt)))
|
||||
.orderBy(asc(workflowMcpTool.serverId))
|
||||
.limit(MAX_MCP_SERVERS_PER_WORKFLOW)
|
||||
|
||||
return rows.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run, against a candidate's already-locked server, the invariants
|
||||
* `performCreateWorkflowMcpTool` enforces when a registration is first created,
|
||||
* plus the `(server_id, workflow_id)` uniqueness that
|
||||
* `workflow_mcp_tool_server_workflow_unique` enforces over unarchived rows.
|
||||
* Every one of them is read under the server's lock, so a concurrent manual
|
||||
* tool create cannot slip a live row in between the check and the unarchive.
|
||||
* Returns the reason the candidate must stay archived, or `null` when it is
|
||||
* safe to revive.
|
||||
*
|
||||
* `remainingServerBudget` is how many further servers the workflow may still be
|
||||
* published on. It is checked first because it needs no read: once the budget
|
||||
* is spent no candidate can be revived whatever its server looks like, so a
|
||||
* workflow with a full server set skips its archived candidates without issuing
|
||||
* two queries apiece inside the deploy transaction.
|
||||
*/
|
||||
async function getRestoreSkipReason(
|
||||
tx: DbOrTx,
|
||||
workflowId: string,
|
||||
candidate: RestoreCandidate,
|
||||
remainingServerBudget: number
|
||||
): Promise<string | null> {
|
||||
if (remainingServerBudget <= 0) {
|
||||
return `restoring it would expose the workflow on more than ${MAX_MCP_SERVERS_PER_WORKFLOW} MCP servers`
|
||||
}
|
||||
|
||||
const liveTools = await tx
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(eq(workflowMcpTool.serverId, candidate.serverId), isNull(workflowMcpTool.archivedAt))
|
||||
)
|
||||
.limit(MAX_MCP_TOOLS_PER_SERVER)
|
||||
|
||||
if (liveTools.some((tool) => tool.workflowId === workflowId)) {
|
||||
return 'the server already carries a live registration for this workflow'
|
||||
}
|
||||
|
||||
if (liveTools.length >= MAX_MCP_TOOLS_PER_SERVER) {
|
||||
return `server already has the maximum of ${MAX_MCP_TOOLS_PER_SERVER} tools`
|
||||
}
|
||||
|
||||
if (liveTools.some((tool) => tool.toolName === candidate.toolName)) {
|
||||
return `another live tool on this server already uses the name "${candidate.toolName}"`
|
||||
}
|
||||
|
||||
const usage = getMcpToolMetadataUsageFromRows(
|
||||
await getMcpServerToolMetadataUsageRows(tx, candidate.serverId)
|
||||
)
|
||||
if (
|
||||
exceedsMcpServerToolMetadataBudget(usage, {
|
||||
toolName: candidate.toolName,
|
||||
toolDescription: candidate.toolDescription,
|
||||
parameterSchema: candidate.parameterSchema,
|
||||
})
|
||||
) {
|
||||
return 'restoring it would exceed the server tools/list metadata budget'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-archive the registrations an earlier undeploy withdrew, so redeploying a
|
||||
* workflow republishes it on exactly the servers it was published on before.
|
||||
*
|
||||
* Repeated undeploy/skipped-restore/manual-re-add cycles leave several archived
|
||||
* generations per server, so the candidate query runs in two stages. The inner
|
||||
* `DISTINCT ON (server_id)` keeps one row per server, the most recently updated
|
||||
* generation winning; the outer select then orders that deduplicated set by
|
||||
* recency and bounds it at the workflow's remaining server budget. Both stages are
|
||||
* needed: bounding rows instead of servers would let one server's stale
|
||||
* generations consume the budget and drop other servers out of the restore,
|
||||
* while bounding the inner statement directly would keep the lexicographically
|
||||
* lowest `server_id`s — `DISTINCT ON` requires `server_id` to lead its
|
||||
* `ORDER BY` — and silently leave the workflow's most recently used servers
|
||||
* archived. The bound stays in SQL so the read never grows with the number of
|
||||
* archived generations.
|
||||
*
|
||||
* That bound is the workflow's REMAINING budget — `MAX_MCP_SERVERS_PER_WORKFLOW`
|
||||
* minus the servers it is already live on — not the whole limit. Bounding at the
|
||||
* whole limit let a workflow that had been partly re-registered by hand restore
|
||||
* a full limit's worth on top of its live registrations; the fanout check in
|
||||
* {@link collectWorkflowMcpToolServerIds}, which runs immediately after this in
|
||||
* the same deploy transaction, then threw and rolled the deployment back. The
|
||||
* pre-lock count can only go stale in one direction (a concurrent create
|
||||
* consuming headroom), so it bounds the read while the authoritative decision is
|
||||
* made after the locks: the budget is recounted once every candidate server is
|
||||
* locked, and each accepted candidate spends one unit of it, so the restore can
|
||||
* never select more servers than the workflow has room for.
|
||||
*
|
||||
* The candidate query also excludes, with a correlated `NOT EXISTS`, every
|
||||
* server the workflow already holds a live row on. Those servers are counted by
|
||||
* {@link countWorkflowLiveMcpServers} and so already shrink the budget, while
|
||||
* `workflow_mcp_tool_server_workflow_unique` — scoped to unarchived rows —
|
||||
* leaves their archived generations unconstrained. Without the exclusion such a
|
||||
* server was counted twice: once against the budget and once as a candidate that
|
||||
* consumed one of the remaining slots only to be skipped under the lock, which
|
||||
* kept a genuinely restorable server beyond the bound from ever being fetched.
|
||||
* The exclusion is a pre-lock optimisation and can go stale, so the
|
||||
* `(server_id, workflow_id)` liveness check in {@link getRestoreSkipReason}
|
||||
* stays authoritative.
|
||||
*
|
||||
* Candidates are decided in recency order — the order the bounded query already
|
||||
* ranks them in — so when there is headroom for some but not all, the servers
|
||||
* the workflow most recently published on are the ones that come back. Lock
|
||||
* order stays sorted by server id and is deliberately separate from that.
|
||||
*
|
||||
* A candidate is dropped when reviving it would break an invariant that
|
||||
* `performCreateWorkflowMcpTool` enforces on create but that archiving silently
|
||||
* relaxes: archiving frees both the tool name and the server slot, so while a
|
||||
* workflow sits undeployed another workflow can take its name on the server or
|
||||
* fill the server to `MAX_MCP_TOOLS_PER_SERVER`, and no database constraint
|
||||
* covers either case. It is dropped too when the server already carries a live
|
||||
* registration for this workflow, which `workflow_mcp_tool_server_workflow_unique`
|
||||
* does cover — a violation there would abort the whole deploy transaction.
|
||||
* {@link getRestoreSkipReason} checks all four, plus the remaining server
|
||||
* budget. A dropped candidate stays
|
||||
* archived and is logged as a warning naming the workflow, the server, and the
|
||||
* reason: restore runs inside the deploy transaction, so throwing would fail an
|
||||
* otherwise valid deploy over a registration the user can restore by hand, while
|
||||
* silently skipping would leave the operator with no signal at all.
|
||||
*
|
||||
* Locks are taken in sorted server order before any of those checks read the
|
||||
* server's live rows, matching every other writer here, so concurrent syncs
|
||||
* neither deadlock against each other nor race the checks.
|
||||
*/
|
||||
async function restoreArchivedMcpToolsForWorkflow(
|
||||
tx: DbOrTx,
|
||||
workflowId: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
const preLockServerBudget =
|
||||
MAX_MCP_SERVERS_PER_WORKFLOW - (await countWorkflowLiveMcpServers(tx, workflowId))
|
||||
if (preLockServerBudget <= 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] Skipped restoring archived MCP tools for workflow ${workflowId}: it is already exposed on the maximum of ${MAX_MCP_SERVERS_PER_WORKFLOW} MCP servers`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-join alias correlating a candidate row with the workflow's live rows
|
||||
* on the same server: `workflow_mcp_tool` appears on both sides of the
|
||||
* candidate query, so the inner reference needs its own name.
|
||||
*/
|
||||
const liveWorkflowMcpTool = alias(workflowMcpTool, 'live_workflow_mcp_tool')
|
||||
|
||||
const latestPerServer = tx
|
||||
.selectDistinctOn([workflowMcpTool.serverId], {
|
||||
id: workflowMcpTool.id,
|
||||
serverId: workflowMcpTool.serverId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
updatedAt: workflowMcpTool.updatedAt,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.workflowId, workflowId),
|
||||
isNotNull(workflowMcpTool.archivedAt),
|
||||
notExists(
|
||||
tx
|
||||
.select({ id: liveWorkflowMcpTool.id })
|
||||
.from(liveWorkflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(liveWorkflowMcpTool.workflowId, workflowId),
|
||||
eq(liveWorkflowMcpTool.serverId, workflowMcpTool.serverId),
|
||||
isNull(liveWorkflowMcpTool.archivedAt)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(workflowMcpTool.serverId),
|
||||
desc(workflowMcpTool.updatedAt),
|
||||
desc(workflowMcpTool.id)
|
||||
)
|
||||
.as('latest_archived_per_server')
|
||||
|
||||
const archived: RestoreCandidate[] = await tx
|
||||
.select({
|
||||
id: latestPerServer.id,
|
||||
serverId: latestPerServer.serverId,
|
||||
toolName: latestPerServer.toolName,
|
||||
toolDescription: latestPerServer.toolDescription,
|
||||
parameterSchema: latestPerServer.parameterSchema,
|
||||
})
|
||||
.from(latestPerServer)
|
||||
.orderBy(desc(latestPerServer.updatedAt), desc(latestPerServer.id))
|
||||
.limit(preLockServerBudget)
|
||||
|
||||
if (archived.length === 0) return
|
||||
|
||||
const candidateByServer = new Map<string, RestoreCandidate>()
|
||||
const candidatesByRecency: RestoreCandidate[] = []
|
||||
for (const row of archived) {
|
||||
if (candidateByServer.has(row.serverId)) continue
|
||||
candidateByServer.set(row.serverId, row)
|
||||
candidatesByRecency.push(row)
|
||||
}
|
||||
|
||||
const candidateServerIds = [...candidateByServer.keys()].sort()
|
||||
for (const serverId of candidateServerIds) {
|
||||
await acquireWorkflowMcpServerLock(tx, serverId)
|
||||
}
|
||||
|
||||
let remainingServerBudget =
|
||||
MAX_MCP_SERVERS_PER_WORKFLOW - (await countWorkflowLiveMcpServers(tx, workflowId))
|
||||
|
||||
const restorableIds: string[] = []
|
||||
for (const candidate of candidatesByRecency) {
|
||||
const skipReason = await getRestoreSkipReason(tx, workflowId, candidate, remainingServerBudget)
|
||||
if (skipReason) {
|
||||
logger.warn(
|
||||
`[${requestId}] Skipped restoring archived MCP tool "${candidate.toolName}" for workflow ${workflowId} on server ${candidate.serverId}: ${skipReason}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
restorableIds.push(candidate.id)
|
||||
remainingServerBudget -= 1
|
||||
}
|
||||
if (restorableIds.length === 0) return
|
||||
|
||||
await tx
|
||||
.update(workflowMcpTool)
|
||||
.set({ archivedAt: null })
|
||||
.where(inArray(workflowMcpTool.id, restorableIds))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Restored ${restorableIds.length} archived MCP tool(s) for workflow: ${workflowId}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MCP tool parameter schema from workflow blocks.
|
||||
*/
|
||||
@@ -212,6 +480,8 @@ export async function syncMcpToolsForWorkflow(
|
||||
return await removeMcpToolsForWorkflow(workflowId, requestId, tx, true)
|
||||
}
|
||||
|
||||
await restoreArchivedMcpToolsForWorkflow(tx, workflowId, requestId)
|
||||
|
||||
const generatedParameterSchema = state.blocks
|
||||
? generateSchemaFromBlocks(state.blocks)
|
||||
: EMPTY_SCHEMA
|
||||
@@ -320,8 +590,15 @@ export async function syncMcpToolsForWorkflow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all MCP tools for a workflow (used when undeploying).
|
||||
* Queries affected tools before deleting so their servers can be notified.
|
||||
* Withdraw every MCP tool registration for a workflow (used when undeploying).
|
||||
* Queries affected tools before withdrawing so their servers can be notified.
|
||||
*
|
||||
* Rows are archived, not deleted. Deploy/undeploy is a reversible lifecycle:
|
||||
* an undeploy that destroyed the registrations would force the user to re-create
|
||||
* by hand every server entry that published the workflow, with no warning and no
|
||||
* way back. {@link restoreArchivedMcpToolsForWorkflow} brings them back on the
|
||||
* next deploy. Deleting a tool through the MCP server surface stays a hard
|
||||
* delete, so a registration the user removed on purpose is never resurrected.
|
||||
*
|
||||
* Server notification is strictly post-commit: the standalone path notifies
|
||||
* after the transaction opened here commits; when `tx` is provided the
|
||||
@@ -350,12 +627,15 @@ export async function removeMcpToolsForWorkflow(
|
||||
await acquireWorkflowMcpServerLock(tx, serverId)
|
||||
}
|
||||
|
||||
await tx.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
logger.info(`[${requestId}] Removed MCP tools for workflow: ${workflowId}`)
|
||||
await tx
|
||||
.update(workflowMcpTool)
|
||||
.set({ archivedAt: new Date() })
|
||||
.where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt)))
|
||||
logger.info(`[${requestId}] Archived MCP tools for workflow: ${workflowId}`)
|
||||
|
||||
return tools
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error removing MCP tools:`, error)
|
||||
logger.error(`[${requestId}] Error archiving MCP tools:`, error)
|
||||
if (throwOnError) throw error
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ import {
|
||||
resolveArchivedTableContext,
|
||||
} from '@/lib/table/application/context'
|
||||
|
||||
/**
|
||||
* Copilot tool invocations as they appear in prose: a `glob(...)`/`grep(...)` call, or a
|
||||
* bare `table_*` / `save_upload` tool name. `save_upload` matches WITHOUT a trailing paren
|
||||
* because the remediation text that had to go named it as a tool, not as a call — requiring
|
||||
* the paren let that exact string back in. Bare `read` is deliberately absent: `read(`
|
||||
* matches ordinary stream code. Still narrow enough that SQL identifiers
|
||||
* (`user_table_rows`) do not trip it.
|
||||
*/
|
||||
const COPILOT_TOOL_REFERENCE =
|
||||
/\b(?:glob|grep)\(|\bsave_upload\b|\btable_(?:views|rows|columns|metadata)\b(?!\.)/
|
||||
|
||||
const WORKSPACE_ONE = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceOrganizationId: 'organization-1',
|
||||
@@ -277,3 +288,77 @@ describe('resolveArchivedTableContext', () => {
|
||||
).rejects.toMatchObject({ code: 'not_found' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Table errors reach a CLI, an HTTP client and Copilot alike, so their remediation
|
||||
* must describe an ACTION every caller can take. Naming a Copilot tool (`glob(...)`,
|
||||
* `table_views`, `save_upload`) turns the advice into noise — or a dead end — for the
|
||||
* other two.
|
||||
*/
|
||||
describe('surface-neutral remediation text', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// `clearAllMocks` drops call history but keeps implementations, so without an explicit
|
||||
// reset this block would inherit whichever `mockResolvedValue` the previous describe
|
||||
// happened to leave behind — the state these assertions depend on.
|
||||
getTableById.mockReset()
|
||||
loadWorkspace.mockReset()
|
||||
loadWorkspace.mockImplementation(async (workspaceId: string) =>
|
||||
workspaceId === 'workspace-1' ? WORKSPACE_ONE : WORKSPACE_TWO
|
||||
)
|
||||
})
|
||||
|
||||
it('tells a caller to list tables without naming a Copilot tool', async () => {
|
||||
getTableById.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
resolveActiveTableContext({ tableId: 'missing', assertedWorkspaceId: 'workspace-1' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'not_found',
|
||||
message: expect.stringContaining('List the tables in this workspace'),
|
||||
})
|
||||
|
||||
const [error] = await resolveActiveTableContext({ tableId: 'missing' }).catch((err) => [err])
|
||||
expect((error as Error).message).not.toMatch(COPILOT_TOOL_REFERENCE)
|
||||
})
|
||||
|
||||
/**
|
||||
* Modules whose only consumer is Copilot, where naming a Copilot tool is the
|
||||
* correct remediation rather than a leak.
|
||||
*
|
||||
* `workspace-file-imports` lives under `lib/table` but is imported solely by
|
||||
* `lib/copilot/application/table-commands`, so every message it raises reaches
|
||||
* an agent that can actually call `save_upload` and `glob(...)`. Rewriting
|
||||
* those into surface-neutral prose removed a working next step from the one
|
||||
* caller able to act on it.
|
||||
*/
|
||||
const COPILOT_ONLY_MODULES = new Set(['workspace-file-imports.ts'])
|
||||
|
||||
it('names no Copilot tool anywhere under lib/table', async () => {
|
||||
const { readdir, readFile } = await import('node:fs/promises')
|
||||
const root = new URL('../', import.meta.url).pathname
|
||||
const offenders: string[] = []
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = `${dir}${entry.name}${entry.isDirectory() ? '/' : ''}`
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full)
|
||||
continue
|
||||
}
|
||||
if (!entry.name.endsWith('.ts') || entry.name.endsWith('.test.ts')) continue
|
||||
if (COPILOT_ONLY_MODULES.has(entry.name)) continue
|
||||
const source = await readFile(full, 'utf8')
|
||||
for (const line of source.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('*') || trimmed.startsWith('//')) continue
|
||||
if (!line.includes("'") && !line.includes('`')) continue
|
||||
if (COPILOT_TOOL_REFERENCE.test(line)) offenders.push(`${entry.name}: ${line.trim()}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ async function requireTable(tableId: string, workspaceId: string | undefined) {
|
||||
if (!table || (workspaceId !== undefined && table.workspaceId !== workspaceId)) {
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
`Table "${tableId}" not found in this workspace — it may not exist or may belong to a different workspace. Run glob("tables/*") to list the tables you can use here.`
|
||||
`Table "${tableId}" not found in this workspace — it may not exist or may belong to a different workspace. List the tables in this workspace to see the ids you can use here.`
|
||||
)
|
||||
}
|
||||
return table
|
||||
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
mockBatchUpdateRows,
|
||||
mockGetRowSummaryById,
|
||||
mockLoadExecutionsForRow,
|
||||
mockLoadEnrichmentDetail,
|
||||
} = vi.hoisted(() => ({
|
||||
mockReplaceRowsPrimitive: vi.fn(),
|
||||
mockDeleteRowsByIds: vi.fn(),
|
||||
@@ -57,6 +58,7 @@ const {
|
||||
mockBatchUpdateRows: vi.fn(),
|
||||
mockGetRowSummaryById: vi.fn(),
|
||||
mockLoadExecutionsForRow: vi.fn(),
|
||||
mockLoadEnrichmentDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => ({
|
||||
@@ -151,7 +153,7 @@ vi.mock('@/lib/table/import', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/rows/executions', () => ({
|
||||
loadEnrichmentDetail: vi.fn(),
|
||||
loadEnrichmentDetail: mockLoadEnrichmentDetail,
|
||||
loadExecutionsForRow: mockLoadExecutionsForRow,
|
||||
}))
|
||||
|
||||
@@ -169,6 +171,7 @@ import {
|
||||
ProjectedWireRowsValidationError,
|
||||
queryTableRows,
|
||||
readTableRow,
|
||||
readTableRowEnrichmentDetail,
|
||||
replaceProjectedWireRows,
|
||||
replaceTableRows,
|
||||
TableRowsValidationError,
|
||||
@@ -1599,3 +1602,59 @@ describe('batchUpdateTableRows application use case', () => {
|
||||
expect(mockSignalRowsChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A bogus row or group id used to read back `{ detail: null }` with a 200 — the same
|
||||
* answer as "this cell has no enrichment run yet", so a typo was undetectable.
|
||||
*/
|
||||
describe('enrichment detail id validation', () => {
|
||||
const ENRICHED_TABLE: TableDefinition = {
|
||||
...TABLE,
|
||||
schema: {
|
||||
columns: [{ id: 'column-name', name: 'name', type: 'string' }],
|
||||
workflowGroups: [{ id: 'group-1', name: 'Enrich', type: 'enrichment', columnIds: [] }],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolvePermission.mockResolvedValue('read')
|
||||
mockResolveContext.mockResolvedValue(contextFor(ENRICHED_TABLE))
|
||||
mockLoadEnrichmentDetail.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('404s on a row id the table does not have', async () => {
|
||||
mockGetRowSummaryById.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
readTableRowEnrichmentDetail.execute({
|
||||
principal: PRINCIPAL,
|
||||
input: { tableId: ENRICHED_TABLE.id, rowId: 'row-nope', groupId: 'group-1' },
|
||||
})
|
||||
).rejects.toThrowError(expect.objectContaining({ code: 'not_found' }))
|
||||
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s on a group id the table does not have', async () => {
|
||||
mockGetRowSummaryById.mockResolvedValue({ id: 'row-1', data: {} })
|
||||
|
||||
await expect(
|
||||
readTableRowEnrichmentDetail.execute({
|
||||
principal: PRINCIPAL,
|
||||
input: { tableId: ENRICHED_TABLE.id, rowId: 'row-1', groupId: 'group-nope' },
|
||||
})
|
||||
).rejects.toThrowError(expect.objectContaining({ code: 'not_found' }))
|
||||
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still answers null for a real row and group with no recorded run', async () => {
|
||||
mockGetRowSummaryById.mockResolvedValue({ id: 'row-1', data: {} })
|
||||
|
||||
const result = await readTableRowEnrichmentDetail.execute({
|
||||
principal: PRINCIPAL,
|
||||
input: { tableId: ENRICHED_TABLE.id, rowId: 'row-1', groupId: 'group-1' },
|
||||
})
|
||||
|
||||
expect(result.detail).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -581,7 +581,8 @@ export interface ReadTableRowEnrichmentResult extends TableResult {
|
||||
* The enrichment cascade breakdown — provider outcomes, cost, timing — for one
|
||||
* cell. Deliberately kept off the hot grid read and fetched on demand by the
|
||||
* details panel; `null` for a cell with no recorded run, or a run predating the
|
||||
* feature.
|
||||
* feature. The row id and group id are validated first so an unknown id 404s
|
||||
* instead of being indistinguishable from "no enrichment run yet".
|
||||
*
|
||||
* Shares {@link tableOperations.readRow}: this is a projection of the same row,
|
||||
* under the same role, so it is not a second semantic operation.
|
||||
@@ -591,6 +592,14 @@ export const readTableRowEnrichmentDetail = defineAuthorizedTableUseCase({
|
||||
resolveContext: ({ input }: { input: ReadTableRowEnrichmentInput }) =>
|
||||
resolveActiveTableContext(input),
|
||||
async execute({ input, context }): Promise<ReadTableRowEnrichmentResult> {
|
||||
const rowExists = await getRowSummaryById(context.tableId, input.rowId, context.workspaceId)
|
||||
if (!rowExists) throw new OrchestrationError('not_found', 'Row not found')
|
||||
const groupExists = (context.table.schema.workflowGroups ?? []).some(
|
||||
(group) => group.id === input.groupId
|
||||
)
|
||||
if (!groupExists) {
|
||||
throw new OrchestrationError('not_found', 'Workflow group not found')
|
||||
}
|
||||
return {
|
||||
table: context.table,
|
||||
detail: await loadEnrichmentDetail(db, context.tableId, input.rowId, input.groupId),
|
||||
|
||||
@@ -227,7 +227,7 @@ export const updateTableUseCase = defineAuthorizedTableUseCase({
|
||||
if (!table || table.workspaceId !== context.workspaceId) {
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'Table not found in this workspace — run glob("tables/*") to list valid tables'
|
||||
'Table not found in this workspace — list the tables in this workspace to see valid table ids'
|
||||
)
|
||||
}
|
||||
const index =
|
||||
@@ -302,7 +302,7 @@ export const deleteTableUseCase = defineAuthorizedTableUseCase({
|
||||
if (!archived)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'Table not found in this workspace — run glob("tables/*") to list valid tables'
|
||||
'Table not found in this workspace — list the tables in this workspace to see valid table ids'
|
||||
)
|
||||
return {
|
||||
id: context.table.id,
|
||||
|
||||
@@ -59,7 +59,7 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({
|
||||
if (!view)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
'View not found on this table — list the views on this table for valid view ids'
|
||||
)
|
||||
return { view, columns, table: context.table }
|
||||
},
|
||||
@@ -135,7 +135,7 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({
|
||||
if (!existing)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
'View not found on this table — list the views on this table for valid view ids'
|
||||
)
|
||||
const view = await updateTableView({
|
||||
viewId: input.viewId,
|
||||
@@ -151,7 +151,7 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({
|
||||
if (!view)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
'View not found on this table — list the views on this table for valid view ids'
|
||||
)
|
||||
return {
|
||||
view,
|
||||
@@ -196,14 +196,14 @@ export const deleteTableViewUseCase = defineAuthorizedTableUseCase({
|
||||
if (!existing)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
'View not found on this table — list the views on this table for valid view ids'
|
||||
)
|
||||
try {
|
||||
const deleted = await deleteTableView(input.viewId, context.table.id, context.workspaceId)
|
||||
if (!deleted)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
'View not found on this table — list the views on this table for valid view ids'
|
||||
)
|
||||
return { viewId: input.viewId, viewName: existing.name, table: context.table }
|
||||
} catch (error) {
|
||||
|
||||
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
|
||||
runDetached: vi.fn(),
|
||||
signal: vi.fn(),
|
||||
validateMapping: vi.fn(),
|
||||
coerceRows: vi.fn(),
|
||||
CsvImportValidationError: class extends Error {},
|
||||
}))
|
||||
|
||||
@@ -48,7 +49,7 @@ vi.mock('@/lib/core/utils/background', () => ({ runDetached: mocks.runDetached }
|
||||
vi.mock('@/lib/table', () => ({
|
||||
batchInsertRows: mocks.batchInsert,
|
||||
buildAutoMapping: vi.fn(() => ({ name: 'name' })),
|
||||
coerceRowsForTable: (rows: unknown[]) => rows,
|
||||
coerceRowsForTable: mocks.coerceRows,
|
||||
CsvImportValidationError: mocks.CsvImportValidationError,
|
||||
CSV_ASYNC_IMPORT_THRESHOLD_BYTES: 8 * 1024 * 1024,
|
||||
CSV_MAX_BATCH_SIZE: 1000,
|
||||
@@ -105,6 +106,8 @@ const table: TableDefinition = {
|
||||
createdAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||
}
|
||||
const rejectedSample = { code: 'CSV_QUOTE_NOT_CLOSED', line: 3, message: 'Quote Not Closed' }
|
||||
|
||||
const sourceFile = {
|
||||
id: 'file-1',
|
||||
workspaceId: 'workspace-1',
|
||||
@@ -147,7 +150,11 @@ describe('workspace-file Table application commands', () => {
|
||||
mocks.loadFileContext.mockResolvedValue(sourceFile)
|
||||
mocks.provenance.mockResolvedValue({ status: 'exact', entries: [] })
|
||||
mocks.fetchFile.mockResolvedValue(Buffer.from('name\nAda'))
|
||||
mocks.parseRows.mockResolvedValue({ headers: ['name'], rows: [{ name: 'Ada' }] })
|
||||
mocks.parseRows.mockResolvedValue({
|
||||
headers: ['name'],
|
||||
rows: [{ name: 'Ada' }],
|
||||
rejections: { rowsRejected: 0, rejectedSamples: [] },
|
||||
})
|
||||
mocks.inferSchema.mockReturnValue({
|
||||
columns: [{ name: 'name', type: 'string' }],
|
||||
headerToColumn: new Map([['name', 'name']]),
|
||||
@@ -160,6 +167,7 @@ describe('workspace-file Table application commands', () => {
|
||||
mocks.replaceRows.mockResolvedValue({ insertedCount: 1, deletedCount: 2 })
|
||||
mocks.markJob.mockResolvedValue(true)
|
||||
mocks.releaseJob.mockResolvedValue(true)
|
||||
mocks.coerceRows.mockImplementation((rows: unknown[]) => rows)
|
||||
mocks.validateMapping.mockReturnValue({
|
||||
effectiveMap: new Map([['name', 'name']]),
|
||||
mappedHeaders: ['name'],
|
||||
@@ -189,6 +197,37 @@ describe('workspace-file Table application commands', () => {
|
||||
expect(mocks.signal).toHaveBeenCalledWith(table.id)
|
||||
})
|
||||
|
||||
/**
|
||||
* The parse dropped these records silently, so an inline import used to finish
|
||||
* with a smaller table and nothing distinguishing it from a clean one.
|
||||
*/
|
||||
it('surfaces the records the parse dropped', async () => {
|
||||
mocks.parseRows.mockResolvedValueOnce({
|
||||
headers: ['name'],
|
||||
rows: [{ name: 'Ada' }],
|
||||
rejections: { rowsRejected: 2, rejectedSamples: [rejectedSample] },
|
||||
})
|
||||
|
||||
const result = await createTableFromWorkspaceFile.execute({
|
||||
principal,
|
||||
input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' },
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'inline',
|
||||
rejections: { rowsRejected: 2, cellsRejected: 0, rejectedSamples: [rejectedSample] },
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the accounting entirely from a clean import', async () => {
|
||||
const result = await createTableFromWorkspaceFile.execute({
|
||||
principal,
|
||||
input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' },
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('rejections')
|
||||
})
|
||||
|
||||
it('conceals cross-workspace files before parsing or table mutation', async () => {
|
||||
mocks.resolveFile.mockResolvedValueOnce({ ...sourceFile, workspaceId: 'workspace-other' })
|
||||
|
||||
@@ -282,6 +321,41 @@ describe('workspace-file Table application commands', () => {
|
||||
expect(events).toEqual(['claim', 'load', 'mutate', 'release'])
|
||||
})
|
||||
|
||||
it('surfaces dropped records and uncoercible cells on an inline append', async () => {
|
||||
mocks.parseRows.mockResolvedValueOnce({
|
||||
headers: ['name'],
|
||||
rows: [{ name: 'Ada' }],
|
||||
rejections: { rowsRejected: 1, rejectedSamples: [rejectedSample] },
|
||||
})
|
||||
mocks.coerceRows.mockImplementationOnce(
|
||||
(
|
||||
rows: unknown[],
|
||||
_schema: unknown,
|
||||
_map: unknown,
|
||||
_options: unknown,
|
||||
onValueRejected?: (columnName: string) => void
|
||||
) => {
|
||||
onValueRejected?.('name')
|
||||
return rows
|
||||
}
|
||||
)
|
||||
|
||||
const result = await importWorkspaceFileIntoTable.execute({
|
||||
principal: tablePrincipal,
|
||||
input: {
|
||||
tableId: table.id,
|
||||
assertedWorkspaceId: table.workspaceId,
|
||||
fileReference: 'files/people.csv',
|
||||
mode: 'append',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'inline',
|
||||
rejections: { rowsRejected: 1, cellsRejected: 1, rejectedSamples: [rejectedSample] },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a concurrent import claim before buffering or mutating rows', async () => {
|
||||
mocks.markJob.mockResolvedValueOnce(false)
|
||||
|
||||
@@ -306,6 +380,7 @@ describe('workspace-file Table application commands', () => {
|
||||
mocks.parseRows.mockResolvedValueOnce({
|
||||
headers: ['name'],
|
||||
rows: Array.from({ length: 1001 }, (_, index) => ({ name: `Person ${index}` })),
|
||||
rejections: { rowsRejected: 0, rejectedSamples: [] },
|
||||
})
|
||||
const stopped = new Error('stopped')
|
||||
let checks = 0
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CSV_MAX_BATCH_SIZE,
|
||||
type CsvHeaderMapping,
|
||||
CsvImportValidationError,
|
||||
type CsvRejectionSummary,
|
||||
coerceRowsForTable,
|
||||
getWorkspaceTableLimits,
|
||||
inferSchemaFromCsv,
|
||||
@@ -36,6 +37,7 @@ import { runTableImport, type TableImportPayload } from '@/lib/table/import-runn
|
||||
import {
|
||||
markTableJobRunningInWorkspace,
|
||||
releaseJobClaimInWorkspace,
|
||||
type TableImportRejectionSummary,
|
||||
} from '@/lib/table/jobs/service'
|
||||
import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance'
|
||||
import { createTable, deleteTable } from '@/lib/table/service'
|
||||
@@ -87,6 +89,8 @@ export type CreateTableFromWorkspaceFileResult =
|
||||
droppedRows: number
|
||||
maxRowsPerTable: number
|
||||
sourceFile: TableWorkspaceFileSource
|
||||
/** Omitted when the file imported cleanly. See {@link summarizeRejections}. */
|
||||
rejections?: TableImportRejectionSummary
|
||||
}
|
||||
|
||||
export interface ImportWorkspaceFileInput {
|
||||
@@ -119,6 +123,8 @@ export type ImportWorkspaceFileResult =
|
||||
skippedColumns: string[]
|
||||
insertedCount: number
|
||||
sourceFileName: string
|
||||
/** Omitted when the file imported cleanly. See {@link summarizeRejections}. */
|
||||
rejections?: TableImportRejectionSummary
|
||||
}
|
||||
| {
|
||||
kind: 'inline'
|
||||
@@ -129,6 +135,8 @@ export type ImportWorkspaceFileResult =
|
||||
insertedCount: number
|
||||
deletedCount: number
|
||||
sourceFileName: string
|
||||
/** Omitted when the file imported cleanly. See {@link summarizeRejections}. */
|
||||
rejections?: TableImportRejectionSummary
|
||||
}
|
||||
|
||||
function requestId(): string {
|
||||
@@ -182,6 +190,37 @@ async function loadInlineRows(file: WorkspaceFileRecord) {
|
||||
return parseFileRows(content, file.name, file.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* What an inline (buffered) import reports about the data it lost, so a
|
||||
* partially imported file is not presented as a clean one — the same accounting
|
||||
* the streaming runner folds into the import record, returned inline because
|
||||
* these imports never create one.
|
||||
*
|
||||
* `undefined` when nothing was lost, leaving a clean import's result exactly
|
||||
* what it has always been. `rowsRejected` stays a FLOOR: one parser failure can
|
||||
* discard many records and is reported once. Any loss is also warned, so a
|
||||
* caller that discards the summary still leaves a trace.
|
||||
*/
|
||||
function summarizeRejections(
|
||||
rejections: CsvRejectionSummary,
|
||||
cellsRejected: number,
|
||||
file: TableWorkspaceFileSource
|
||||
): TableImportRejectionSummary | undefined {
|
||||
if (rejections.rowsRejected === 0 && cellsRejected === 0) return undefined
|
||||
const summary = {
|
||||
rowsRejected: rejections.rowsRejected,
|
||||
cellsRejected,
|
||||
rejectedSamples: rejections.rejectedSamples,
|
||||
}
|
||||
logger.warn('Inline table import lost source data', {
|
||||
workspaceId: file.workspaceId,
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
...summary,
|
||||
})
|
||||
return summary
|
||||
}
|
||||
|
||||
async function batchInsertAll(params: {
|
||||
table: TableDefinition
|
||||
rows: RowData[]
|
||||
@@ -333,8 +372,9 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({
|
||||
return { kind: 'background', table, jobId, sourceFile }
|
||||
}
|
||||
|
||||
const { headers, rows: sourceRows } = await loadInlineRows(sourceFile)
|
||||
const { headers, rows: sourceRows, rejections } = await loadInlineRows(sourceFile)
|
||||
if (sourceRows.length === 0) {
|
||||
summarizeRejections(rejections, 0, sourceFile)
|
||||
return { kind: 'empty', sourceFile }
|
||||
}
|
||||
const { columns, headerToColumn } = inferSchemaFromCsv(headers, sourceRows)
|
||||
@@ -352,14 +392,18 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({
|
||||
},
|
||||
requestId()
|
||||
)
|
||||
let cellsRejected = 0
|
||||
try {
|
||||
const insertedCount = await batchInsertAll({
|
||||
table,
|
||||
rows: coerceRowsForTable(rows, table.schema, headerToColumn),
|
||||
rows: coerceRowsForTable(rows, table.schema, headerToColumn, undefined, () => {
|
||||
cellsRejected++
|
||||
}),
|
||||
workspaceId: context.workspaceId,
|
||||
userId,
|
||||
assertNotAborted: input.assertNotAborted,
|
||||
})
|
||||
const summary = summarizeRejections(rejections, cellsRejected, sourceFile)
|
||||
return {
|
||||
kind: 'inline',
|
||||
table,
|
||||
@@ -368,6 +412,7 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({
|
||||
droppedRows,
|
||||
maxRowsPerTable: limits.maxRowsPerTable,
|
||||
sourceFile,
|
||||
...(summary ? { rejections: summary } : {}),
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -454,9 +499,10 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({
|
||||
if (!claimed)
|
||||
throw new OrchestrationError('conflict', 'A job is already in progress for this table')
|
||||
return withReleasedTableJobClaim(context.table.id, context.workspaceId, jobId, async () => {
|
||||
const { headers, rows: sourceRows } = await loadInlineRows(sourceFile)
|
||||
const { headers, rows: sourceRows, rejections } = await loadInlineRows(sourceFile)
|
||||
input.assertNotAborted?.()
|
||||
if (sourceRows.length === 0) {
|
||||
summarizeRejections(rejections, 0, sourceFile)
|
||||
return { kind: 'empty', table: context.table, mode: input.mode }
|
||||
}
|
||||
const mapping = input.mapping ?? buildAutoMapping(headers, context.table.schema)
|
||||
@@ -477,7 +523,17 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({
|
||||
`No matching columns between file (${headers.join(', ')}) and table (${context.table.schema.columns.map((column) => column.name).join(', ')})`
|
||||
)
|
||||
}
|
||||
const rows = coerceRowsForTable(sourceRows, context.table.schema, validation.effectiveMap)
|
||||
let cellsRejected = 0
|
||||
const rows = coerceRowsForTable(
|
||||
sourceRows,
|
||||
context.table.schema,
|
||||
validation.effectiveMap,
|
||||
undefined,
|
||||
() => {
|
||||
cellsRejected++
|
||||
}
|
||||
)
|
||||
const summary = summarizeRejections(rejections, cellsRejected, sourceFile)
|
||||
if (input.mode === 'replace') {
|
||||
const result = await replaceTableRows(
|
||||
{
|
||||
@@ -499,6 +555,7 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({
|
||||
insertedCount: result.insertedCount,
|
||||
deletedCount: result.deletedCount,
|
||||
sourceFileName: sourceFile.name,
|
||||
...(summary ? { rejections: summary } : {}),
|
||||
}
|
||||
}
|
||||
const insertedCount = await batchInsertAll({
|
||||
@@ -516,6 +573,7 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({
|
||||
skippedColumns: validation.skippedHeaders,
|
||||
insertedCount,
|
||||
sourceFileName: sourceFile.name,
|
||||
...(summary ? { rejections: summary } : {}),
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
mockDeleteFile,
|
||||
mockDownloadFileStream,
|
||||
mockHeadObject,
|
||||
mockRecordImportRejections,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetTableById: vi.fn(),
|
||||
mockBulkInsertImportBatch: vi.fn(),
|
||||
@@ -28,6 +29,7 @@ const {
|
||||
mockDeleteFile: vi.fn(),
|
||||
mockDownloadFileStream: vi.fn(),
|
||||
mockHeadObject: vi.fn(),
|
||||
mockRecordImportRejections: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/service', () => ({
|
||||
@@ -42,6 +44,7 @@ vi.mock('@/lib/table/import-data', () => ({
|
||||
vi.mock('@/lib/table/jobs/service', () => ({
|
||||
markJobFailedInWorkspace: mockMarkJobFailed,
|
||||
markJobReadyInWorkspace: mockMarkJobReady,
|
||||
recordImportRejections: mockRecordImportRejections,
|
||||
updateJobProgressInWorkspace: mockUpdateJobProgress,
|
||||
}))
|
||||
vi.mock('@/lib/table/rows/ordering', () => ({
|
||||
@@ -59,7 +62,7 @@ vi.mock('@/lib/table/wire', () => ({
|
||||
normalizeColumn: (col: unknown) => col,
|
||||
}))
|
||||
|
||||
import { CSV_MAX_BATCH_SIZE_BYTES } from '@/lib/table/import'
|
||||
import { CSV_MAX_BATCH_SIZE_BYTES, CSV_SCHEMA_SAMPLE_SIZE } from '@/lib/table/import'
|
||||
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
|
||||
|
||||
const table = {
|
||||
@@ -97,6 +100,7 @@ describe('runTableImport source-file cleanup', () => {
|
||||
mockBulkInsertImportBatch.mockResolvedValue({ inserted: 2, lastOrderKey: 'a1' })
|
||||
mockMarkJobReady.mockResolvedValue(true)
|
||||
mockDeleteFile.mockResolvedValue(undefined)
|
||||
mockRecordImportRejections.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('fails an insert-locked replace before anything is deleted', async () => {
|
||||
@@ -162,3 +166,188 @@ describe('runTableImport source-file cleanup', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('runTableImport rejection accounting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetTableById.mockResolvedValue(table)
|
||||
mockHeadObject.mockResolvedValue({ size: 100 })
|
||||
mockNextImportStartPosition.mockResolvedValue(0)
|
||||
mockNextImportStartOrderKey.mockResolvedValue(null)
|
||||
mockUpdateJobProgress.mockResolvedValue(true)
|
||||
mockBulkInsertImportBatch.mockImplementation(async ({ rows }: { rows: unknown[] }) => ({
|
||||
inserted: rows.length,
|
||||
lastOrderKey: 'a1',
|
||||
}))
|
||||
mockMarkJobReady.mockResolvedValue(true)
|
||||
mockMarkJobFailed.mockResolvedValue(undefined)
|
||||
mockDeleteFile.mockResolvedValue(undefined)
|
||||
mockRecordImportRejections.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('records the records a malformed CSV silently loses', async () => {
|
||||
// The unterminated quote swallows the rest of the file, so csv-parse drops both
|
||||
// remaining data rows. Without an accounting of that the import reports `ready`
|
||||
// with one row and no error — indistinguishable from a one-row file.
|
||||
const csv = 'name\nOk\nBroken,"unterminated\nAnother\n'
|
||||
mockHeadObject.mockResolvedValue({ size: Buffer.byteLength(csv) })
|
||||
mockDownloadFileStream.mockResolvedValue(Readable.from(csv))
|
||||
|
||||
await runTableImport(buildPayload())
|
||||
|
||||
expect(mockMarkJobReady).toHaveBeenCalled()
|
||||
expect(mockRecordImportRejections).toHaveBeenCalledWith(
|
||||
'tbl_1',
|
||||
'ws_1',
|
||||
'job_1',
|
||||
expect.objectContaining({ rowsRejected: 1, cellsRejected: 0 })
|
||||
)
|
||||
const [, , , summary] = mockRecordImportRejections.mock.calls[0]
|
||||
expect(summary.rejectedSamples).toHaveLength(1)
|
||||
expect(summary.rejectedSamples[0]).toMatchObject({ code: 'CSV_QUOTE_NOT_CLOSED' })
|
||||
})
|
||||
|
||||
it('does not count blanked cells for a batch that never landed', async () => {
|
||||
// The coercion hook fires while the batch is being prepared, before the capacity check
|
||||
// and the insert. Counting there persists loss for rows the table never received.
|
||||
mockGetTableById.mockResolvedValue({
|
||||
...table,
|
||||
schema: { columns: [{ id: 'col_score', name: 'score', type: 'number' }] },
|
||||
})
|
||||
const csv = 'score\n1\nnot-a-number\n3\n'
|
||||
mockHeadObject.mockResolvedValue({ size: Buffer.byteLength(csv) })
|
||||
mockDownloadFileStream.mockResolvedValue(Readable.from(csv))
|
||||
mockBulkInsertImportBatch.mockRejectedValue(new Error('insert failed'))
|
||||
|
||||
await runTableImport(buildPayload())
|
||||
|
||||
expect(mockMarkJobFailed).toHaveBeenCalled()
|
||||
expect(mockRecordImportRejections).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the storage stream before writing the rejection summary', async () => {
|
||||
mockGetTableById.mockResolvedValue({
|
||||
...table,
|
||||
schema: { columns: [{ id: 'col_score', name: 'score', type: 'number' }] },
|
||||
})
|
||||
const cell = 'x'.repeat(390 * 1024)
|
||||
// Never ended, and the run stops on a lost ownership gate rather than end-of-stream, so
|
||||
// the storage response body is still open when the `finally` runs — the only state in
|
||||
// which the summary write can be seen holding that connection.
|
||||
const source = new Readable({ read() {} })
|
||||
source.push(`score\n${Array.from({ length: 40 }, () => cell).join('\n')}\n`)
|
||||
mockHeadObject.mockResolvedValue({ size: 400 * 1024 * 1024 })
|
||||
mockDownloadFileStream.mockResolvedValue(source)
|
||||
mockBulkInsertImportBatch.mockImplementation(async ({ rows }: { rows: unknown[] }) => ({
|
||||
inserted: rows.length,
|
||||
lastOrderKey: 'a1',
|
||||
}))
|
||||
// Startup, schema resolution, the first batch's gate and its emit-cadence write all own
|
||||
// the run; the second batch's gate finds it lost and stops mid-stream.
|
||||
mockUpdateJobProgress
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValue(false)
|
||||
let destroyedWhenRecording: boolean | null = null
|
||||
mockRecordImportRejections.mockImplementation(async () => {
|
||||
destroyedWhenRecording = source.destroyed
|
||||
})
|
||||
|
||||
await runTableImport(buildPayload())
|
||||
|
||||
expect(mockRecordImportRejections).toHaveBeenCalled()
|
||||
expect(destroyedWhenRecording).toBe(true)
|
||||
})
|
||||
|
||||
it('records cell values the target column type could not hold', async () => {
|
||||
mockGetTableById.mockResolvedValue({
|
||||
...table,
|
||||
schema: { columns: [{ id: 'col_score', name: 'score', type: 'number' }] },
|
||||
})
|
||||
const csv = 'score\n1\nnot-a-number\n3\n'
|
||||
mockHeadObject.mockResolvedValue({ size: Buffer.byteLength(csv) })
|
||||
mockDownloadFileStream.mockResolvedValue(Readable.from(csv))
|
||||
|
||||
await runTableImport(buildPayload())
|
||||
|
||||
expect(mockRecordImportRejections).toHaveBeenCalledWith(
|
||||
'tbl_1',
|
||||
'ws_1',
|
||||
'job_1',
|
||||
expect.objectContaining({ rowsRejected: 0, cellsRejected: 1 })
|
||||
)
|
||||
})
|
||||
|
||||
it('records nothing for a clean import', async () => {
|
||||
mockDownloadFileStream.mockResolvedValue(Readable.from('name\nAlice\nBob\n'))
|
||||
|
||||
await runTableImport(buildPayload())
|
||||
|
||||
expect(mockMarkJobReady).toHaveBeenCalled()
|
||||
expect(mockRecordImportRejections).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runTableImport in-flight progress', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetTableById.mockResolvedValue(table)
|
||||
mockNextImportStartPosition.mockResolvedValue(0)
|
||||
mockNextImportStartOrderKey.mockResolvedValue(null)
|
||||
mockUpdateJobProgress.mockResolvedValue(true)
|
||||
mockMarkJobReady.mockResolvedValue(true)
|
||||
mockMarkJobFailed.mockResolvedValue(undefined)
|
||||
mockDeleteFile.mockResolvedValue(undefined)
|
||||
mockRecordImportRejections.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('writes progress once per batch rather than twice', async () => {
|
||||
const cell = 'x'.repeat(390 * 1024)
|
||||
const csv = `name\n${Array.from({ length: 14 }, () => cell).join('\n')}\n`
|
||||
mockHeadObject.mockResolvedValue({ size: Buffer.byteLength(csv) })
|
||||
mockDownloadFileStream.mockResolvedValue(Readable.from(csv))
|
||||
mockBulkInsertImportBatch.mockImplementation(async ({ rows }: { rows: unknown[] }) => ({
|
||||
inserted: rows.length,
|
||||
lastOrderKey: 'a1',
|
||||
}))
|
||||
|
||||
await runTableImport(buildPayload())
|
||||
|
||||
expect(mockBulkInsertImportBatch).toHaveBeenCalledTimes(2)
|
||||
// Startup, schema resolution, one ownership gate per batch, one emit-cadence write (the
|
||||
// first batch always emits), and the terminal write. An unconditional post-insert write
|
||||
// per batch doubles an import's UPDATE volume — ~400 writes on a 1M-row file instead of
|
||||
// ~200 — to freshen a display counter the next batch's gate refreshes anyway.
|
||||
expect(mockUpdateJobProgress).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('persists the post-insert count so an interrupted import reports committed rows', async () => {
|
||||
// The ownership gate necessarily writes the count as it stood *before* its batch, so a run
|
||||
// interrupted while reading the next batch would otherwise leave `rows_processed` a whole
|
||||
// batch behind the rows it actually committed.
|
||||
const cell = 'x'.repeat(1024)
|
||||
// Enough rows to fill the schema sample (so one batch commits) and larger than the
|
||||
// delimiter sniff window (so the sniffer stops on the window rather than waiting for an
|
||||
// end-of-stream that never comes).
|
||||
const rows = Array.from({ length: CSV_SCHEMA_SAMPLE_SIZE + 50 }, () => cell)
|
||||
const head = `name\n${rows.join('\n')}\n`
|
||||
const source = new Readable({ read() {} })
|
||||
source.push(head)
|
||||
mockHeadObject.mockResolvedValue({ size: Buffer.byteLength(head) * 10 })
|
||||
mockDownloadFileStream.mockResolvedValue(source)
|
||||
mockBulkInsertImportBatch.mockImplementation(async ({ rows }: { rows: unknown[] }) => {
|
||||
// The source dies while the worker waits for the next batch's records.
|
||||
setImmediate(() => source.destroy(new Error('storage stream reset')))
|
||||
return { inserted: rows.length, lastOrderKey: 'a1' }
|
||||
})
|
||||
|
||||
await runTableImport(buildPayload())
|
||||
|
||||
expect(mockBulkInsertImportBatch).toHaveBeenCalledTimes(1)
|
||||
expect(mockMarkJobFailed).toHaveBeenCalled()
|
||||
const persisted = mockUpdateJobProgress.mock.calls.map((call) => call[2])
|
||||
expect(Math.max(...persisted)).toBe(CSV_SCHEMA_SAMPLE_SIZE)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CSV_SCHEMA_SAMPLE_SIZE,
|
||||
type CsvHeaderMapping,
|
||||
coerceRowsForTable,
|
||||
createCsvRejectionCollector,
|
||||
inferColumnType,
|
||||
inferSchemaFromCsv,
|
||||
sanitizeName,
|
||||
@@ -32,6 +33,7 @@ import { createCsvParser } from '@/lib/table/import-stream'
|
||||
import {
|
||||
markJobFailedInWorkspace,
|
||||
markJobReadyInWorkspace,
|
||||
recordImportRejections,
|
||||
updateJobProgressInWorkspace,
|
||||
} from '@/lib/table/jobs/service'
|
||||
import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks'
|
||||
@@ -102,6 +104,10 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
|
||||
// Hoisted so `finally` can destroy it on any failure — otherwise the storage HTTP body leaks
|
||||
// open until it times out.
|
||||
let source: Readable | undefined
|
||||
// Hoisted alongside `source` so the `finally` can persist the summary on every exit path —
|
||||
// ready, failed, canceled or superseded.
|
||||
const rejections = createCsvRejectionCollector()
|
||||
let cellsRejected = 0
|
||||
|
||||
try {
|
||||
if (!(await updateJobProgressInWorkspace(tableId, workspaceId, 0, importId))) {
|
||||
@@ -185,9 +191,16 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
|
||||
})
|
||||
|
||||
let csvHeaders: string[] = []
|
||||
const parser = createCsvParser(sniffed.delimiter, (headers) => {
|
||||
csvHeaders = headers
|
||||
})
|
||||
// Rejections are counted, never thrown: `skip_records_with_error` is what lets a file with
|
||||
// one bad record still import the rest. Counting them is what stops that from reading as a
|
||||
// clean import — the summary lands on the job payload in `finally`.
|
||||
const parser = createCsvParser(
|
||||
sniffed.delimiter,
|
||||
(headers) => {
|
||||
csvHeaders = headers
|
||||
},
|
||||
rejections.onSkip
|
||||
)
|
||||
// `.pipe` doesn't forward source errors; forward so the iterator throws.
|
||||
csvStream.on('error', (err) => parser.destroy(err))
|
||||
byteCounter.on('error', (err) => parser.destroy(err))
|
||||
@@ -280,9 +293,19 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
|
||||
// may own. Runs per batch (not just at the emit cadence) so we stop within one batch.
|
||||
const owns = await updateJobProgressInWorkspace(tableId, workspaceId, inserted, importId)
|
||||
if (!owns) throw new ImportSupersededError()
|
||||
const coerced = coerceRowsForTable(rows, schema, headerToColumn, {
|
||||
timezone: payload.timezone,
|
||||
})
|
||||
// Held per batch and folded into the run total only once the rows commit: an
|
||||
// assertRowCapacity rejection or a failed insert below discards this batch entirely,
|
||||
// and counting its blanked cells would report loss for rows that never landed.
|
||||
let batchCellsRejected = 0
|
||||
const coerced = coerceRowsForTable(
|
||||
rows,
|
||||
schema,
|
||||
headerToColumn,
|
||||
{ timezone: payload.timezone },
|
||||
() => {
|
||||
batchCellsRejected++
|
||||
}
|
||||
)
|
||||
const rowLimit = await assertRowCapacity({
|
||||
workspaceId,
|
||||
currentRowCount: existingRowCount + inserted,
|
||||
@@ -309,11 +332,21 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
|
||||
})
|
||||
inserted += result.inserted
|
||||
lastOrderKey = result.lastOrderKey
|
||||
cellsRejected += batchCellsRejected
|
||||
// Emit after the first batch, then every interval, so the bar appears early without flooding.
|
||||
if (
|
||||
inserted - lastReported >= PROGRESS_INTERVAL_ROWS ||
|
||||
(lastReported === 0 && inserted > 0)
|
||||
) {
|
||||
// Persist the post-insert count. The ownership gate above necessarily writes the count
|
||||
// as it stood *before* this batch, so without this an in-flight or canceled import
|
||||
// reports a `rowsProcessed` a whole batch behind the rows actually committed —
|
||||
// CSV_MAX_BATCH_SIZE rows, which reads as a ~50x under-count on the progress line. A
|
||||
// no-op once a newer run owns the job, exactly like the gate. Gated on the emit
|
||||
// cadence because the next batch's gate persists the count anyway: writing it every
|
||||
// batch doubles an import's UPDATE volume purely to freshen a display counter, and
|
||||
// the run's terminal write after the last flush pins the final number.
|
||||
await updateJobProgressInWorkspace(tableId, workspaceId, inserted, importId)
|
||||
lastReported = inserted
|
||||
// Exact, monotonic completion from bytes consumed — no wobbly row estimate.
|
||||
const percent =
|
||||
@@ -488,8 +521,27 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Release the storage stream so its HTTP connection doesn't leak on failure.
|
||||
// Release the storage stream first: it holds an open HTTP response body, and the
|
||||
// rejection-summary write below is a database round trip that would otherwise keep that
|
||||
// connection pinned for its duration on every import.
|
||||
source?.destroy()
|
||||
// Written whatever the outcome (ready, failed, canceled, superseded) so a partial import
|
||||
// is observable on the import record instead of only in this worker's memory.
|
||||
if (rejections.summary.rowsRejected > 0 || cellsRejected > 0) {
|
||||
try {
|
||||
await recordImportRejections(tableId, workspaceId, importId, {
|
||||
rowsRejected: rejections.summary.rowsRejected,
|
||||
cellsRejected,
|
||||
rejectedSamples: rejections.summary.rejectedSamples,
|
||||
})
|
||||
} catch (summaryError) {
|
||||
logger.error(`[${requestId}] Failed to record import rejections`, {
|
||||
tableId,
|
||||
importId,
|
||||
error: getErrorMessage(summaryError, 'Unknown rejection-summary error'),
|
||||
})
|
||||
}
|
||||
}
|
||||
// The uploaded source file is single-use (a fresh upload per import) — delete it once the
|
||||
// import is terminal so the workspace bucket doesn't accumulate. Best-effort. Skipped for
|
||||
// persistent workspace files (deleteSourceFile: false).
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { type Parser, parse as parseCsvStream } from 'csv-parse'
|
||||
import { csvParseOptions } from '@/lib/table/import'
|
||||
import { type CsvSkippedRecord, csvParseOptions } from '@/lib/table/import'
|
||||
|
||||
/**
|
||||
* Returns a streaming `csv-parse` parser (a `Transform`/async-iterable). Pipe a
|
||||
@@ -21,7 +21,12 @@ import { csvParseOptions } from '@/lib/table/import'
|
||||
* so the file is never fully buffered in memory.
|
||||
*
|
||||
* `onHeaders` fires once, before the first record, with the full header row.
|
||||
* `onSkip` fires once per source record the parser had to drop.
|
||||
*/
|
||||
export function createCsvParser(delimiter = ',', onHeaders?: (headers: string[]) => void): Parser {
|
||||
return parseCsvStream(csvParseOptions(delimiter, onHeaders))
|
||||
export function createCsvParser(
|
||||
delimiter = ',',
|
||||
onHeaders?: (headers: string[]) => void,
|
||||
onSkip?: (skipped: CsvSkippedRecord) => void
|
||||
): Parser {
|
||||
return parseCsvStream(csvParseOptions(delimiter, onHeaders, onSkip))
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
CsvImportValidationError,
|
||||
coerceRowsForTable,
|
||||
coerceValue,
|
||||
createCsvRejectionCollector,
|
||||
csvParseOptions,
|
||||
dedupeHeaders,
|
||||
detectCsvDelimiter,
|
||||
inferColumnType,
|
||||
inferSchemaFromCsv,
|
||||
MAX_REJECTED_SAMPLES,
|
||||
parseCsvBuffer,
|
||||
parseFileRows,
|
||||
sanitizeName,
|
||||
validateMapping,
|
||||
} from '@/lib/table/import'
|
||||
@@ -347,6 +350,53 @@ describe('import', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('rejection accounting', () => {
|
||||
/**
|
||||
* The parser drops malformed records silently, so a buffered import used to
|
||||
* finish with a smaller table and nothing to distinguish it from a clean one.
|
||||
*/
|
||||
it('reports the records a malformed CSV silently loses', async () => {
|
||||
const { rows, rejections } = await parseCsvBuffer('name\nOk\nBroken,"unterminated\nAnother\n')
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rejections.rowsRejected).toBeGreaterThan(0)
|
||||
expect(rejections.rejectedSamples[0]).toMatchObject({ code: 'CSV_QUOTE_NOT_CLOSED' })
|
||||
})
|
||||
|
||||
it('reports no rejections for a clean CSV', async () => {
|
||||
const { rejections } = await parseCsvBuffer('a,b\n1,2\n')
|
||||
expect(rejections).toEqual({ rowsRejected: 0, rejectedSamples: [] })
|
||||
})
|
||||
|
||||
it('threads the summary through parseFileRows for CSV', async () => {
|
||||
const { rejections } = await parseFileRows(
|
||||
Buffer.from('name\nOk\nBroken,"unterminated\nAnother\n'),
|
||||
'rows.csv'
|
||||
)
|
||||
expect(rejections.rowsRejected).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reports no rejections for JSON, which throws rather than dropping rows', async () => {
|
||||
const { rejections } = await parseFileRows(Buffer.from('[{"a":1}]'), 'rows.json')
|
||||
expect(rejections).toEqual({ rowsRejected: 0, rejectedSamples: [] })
|
||||
})
|
||||
|
||||
/**
|
||||
* The count is a floor and the samples are capped, so a systematically broken
|
||||
* million-row file cannot accumulate an entry per lost record.
|
||||
*/
|
||||
it('counts every rejection but caps the retained samples', () => {
|
||||
const collector = createCsvRejectionCollector()
|
||||
for (let index = 0; index < MAX_REJECTED_SAMPLES + 3; index++) {
|
||||
collector.onSkip({ code: 'CSV_QUOTE_NOT_CLOSED', line: index, message: 'bad' })
|
||||
}
|
||||
|
||||
expect(collector.summary.rowsRejected).toBe(MAX_REJECTED_SAMPLES + 3)
|
||||
expect(collector.summary.rejectedSamples).toHaveLength(MAX_REJECTED_SAMPLES)
|
||||
expect(collector.summary.rejectedSamples[0]).toMatchObject({ line: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('csvParseOptions', () => {
|
||||
it('sets bom and the delimiter, with a header-capturing columns callback', () => {
|
||||
const options = csvParseOptions('\t')
|
||||
|
||||
+117
-13
@@ -38,6 +38,64 @@ export const CSV_DELIMITER_SNIFF_BYTES = 64 * 1024
|
||||
/** Maximum characters buffered for one CSV record before parsing fails. */
|
||||
export const CSV_MAX_RECORD_SIZE_BYTES = 1024 * 1024
|
||||
|
||||
/**
|
||||
* One parser failure that made the CSV reader drop source data.
|
||||
*
|
||||
* `skip_records_with_error` discards these silently, which is what makes a
|
||||
* malformed file import as a smaller table with no error. Reporting them is how
|
||||
* a caller tells a partial import from a clean one. One entry can stand for more
|
||||
* than one lost record (see {@link csvParseOptions}), so a count of these is a
|
||||
* lower bound on the loss, never an exact tally.
|
||||
*/
|
||||
export interface CsvSkippedRecord {
|
||||
/** `csv-parse` error code, e.g. `CSV_QUOTE_NOT_CLOSED`. */
|
||||
code: string
|
||||
/** 1-based source line the parser had reached when it gave up, or `null`. */
|
||||
line: number | null
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How many dropped records are kept as a sample alongside a rejection count.
|
||||
* Bounded because a systematically malformed million-row file would otherwise
|
||||
* accumulate one entry per lost record in memory and then carry them all into
|
||||
* whatever the caller writes the summary to.
|
||||
*/
|
||||
export const MAX_REJECTED_SAMPLES = 5
|
||||
|
||||
/**
|
||||
* What a caller reports about the records a parse had to drop.
|
||||
*
|
||||
* `rowsRejected` is a FLOOR, never an exact tally: only hard failures reach
|
||||
* `on_skip` at all, and a single one — `CSV_QUOTE_NOT_CLOSED` swallows the
|
||||
* remainder of the file — can discard many records while being reported once.
|
||||
*/
|
||||
export interface CsvRejectionSummary {
|
||||
rowsRejected: number
|
||||
rejectedSamples: CsvSkippedRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulates {@link csvParseOptions}'s `onSkip` calls into a
|
||||
* {@link CsvRejectionSummary}, applying {@link MAX_REJECTED_SAMPLES} once so
|
||||
* every import path caps its sample list the same way.
|
||||
*/
|
||||
export function createCsvRejectionCollector(): {
|
||||
onSkip: (skipped: CsvSkippedRecord) => void
|
||||
summary: CsvRejectionSummary
|
||||
} {
|
||||
const summary: CsvRejectionSummary = { rowsRejected: 0, rejectedSamples: [] }
|
||||
return {
|
||||
onSkip(skipped) {
|
||||
summary.rowsRejected++
|
||||
if (summary.rejectedSamples.length < MAX_REJECTED_SAMPLES) {
|
||||
summary.rejectedSamples.push(skipped)
|
||||
}
|
||||
},
|
||||
summary,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the `csv-parse` options used by both the buffered
|
||||
* sync parser and the streaming parser.
|
||||
@@ -49,8 +107,17 @@ export const CSV_MAX_RECORD_SIZE_BYTES = 1024 * 1024
|
||||
*/
|
||||
export function csvParseOptions(
|
||||
delimiter = ',',
|
||||
onHeaders?: (headers: string[]) => void
|
||||
onHeaders?: (headers: string[]) => void,
|
||||
onSkip?: (skipped: CsvSkippedRecord) => void
|
||||
): CsvParseOptions {
|
||||
// csv-parse can report several errors while giving up on one malformed record, and they
|
||||
// all carry the same `lines` position. Collapsing consecutive reports by line keeps the
|
||||
// callback's meaning "one call per parser failure" rather than "one call per error raised".
|
||||
// That is a LOWER BOUND on records lost, not an exact count: `relax_column_count` and
|
||||
// `relax_quotes` mean only hard failures reach `on_skip` at all, and one failure can
|
||||
// discard many records — `CSV_QUOTE_NOT_CLOSED` swallows the remainder of the file and is
|
||||
// reported once. Callers must publish the derived count as a floor.
|
||||
let lastSkippedLine: number | null = null
|
||||
return {
|
||||
columns: (header: string[]) => {
|
||||
// Deliver headers deduped to match the record keys: csv-parse collapses duplicate
|
||||
@@ -68,6 +135,11 @@ export function csvParseOptions(
|
||||
skip_records_with_error: true,
|
||||
on_skip(error) {
|
||||
if (error?.code === 'CSV_MAX_RECORD_SIZE') throw error
|
||||
if (!onSkip || !error) return
|
||||
const line = typeof error.lines === 'number' ? error.lines : null
|
||||
if (line !== null && line === lastSkippedLine) return
|
||||
lastSkippedLine = line
|
||||
onSkip({ code: error.code, line, message: error.message })
|
||||
},
|
||||
cast: false,
|
||||
bom: true,
|
||||
@@ -263,19 +335,33 @@ export class CsvImportValidationError extends Error {
|
||||
* is stripped by csv-parse (`bom: true` in {@link csvParseOptions}).
|
||||
*
|
||||
* For HTTP uploads prefer {@link createCsvParser} so the file isn't buffered.
|
||||
*
|
||||
* `rejections` reports the records the parser had to drop. `skip_records_with_error`
|
||||
* discards them silently, so without this a malformed file imports as a smaller
|
||||
* table and reads exactly like a clean one; see {@link CsvRejectionSummary} for why
|
||||
* the count is a floor.
|
||||
*/
|
||||
export async function parseCsvBuffer(
|
||||
input: Buffer | Uint8Array | string,
|
||||
delimiter = ','
|
||||
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
|
||||
): Promise<{
|
||||
headers: string[]
|
||||
rows: Record<string, unknown>[]
|
||||
rejections: CsvRejectionSummary
|
||||
}> {
|
||||
const { parse } = await import('csv-parse/sync')
|
||||
|
||||
const text = decodeCsvText(input)
|
||||
|
||||
let headers: string[] = []
|
||||
const options = csvParseOptions(delimiter, (h) => {
|
||||
headers = h
|
||||
})
|
||||
const rejections = createCsvRejectionCollector()
|
||||
const options = csvParseOptions(
|
||||
delimiter,
|
||||
(h) => {
|
||||
headers = h
|
||||
},
|
||||
rejections.onSkip
|
||||
)
|
||||
// double-cast-allowed: shared csvParseOptions() loses the `columns` literal that drives
|
||||
// csv-parse's record-vs-string[][] overload, but `columns` is always set so records are objects
|
||||
const parsed = parse(text, options) as unknown as Record<string, unknown>[]
|
||||
@@ -288,7 +374,7 @@ export async function parseCsvBuffer(
|
||||
throw new OrchestrationError('validation', 'CSV file has no headers')
|
||||
}
|
||||
|
||||
return { headers, rows: parsed }
|
||||
return { headers, rows: parsed, rejections: rejections.summary }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -582,12 +668,18 @@ export function buildAutoMapping(csvHeaders: string[], tableSchema: TableSchema)
|
||||
* `tableSchema`. Headers not present in `headerToColumn` are dropped. Missing
|
||||
* table columns remain unset (schema validation decides whether that's
|
||||
* acceptable). Pass the schema returned by `createTable` so ids are resolved.
|
||||
*
|
||||
* `onValueRejected` fires for every non-empty source value that the target
|
||||
* column's type could not represent, which {@link coerceValue} stores as `null`.
|
||||
* The row survives with a blank cell, so without this hook the loss is invisible
|
||||
* — the import runner counts these onto the import record.
|
||||
*/
|
||||
export function coerceRowsForTable(
|
||||
rows: Record<string, unknown>[],
|
||||
tableSchema: TableSchema,
|
||||
headerToColumn: Map<string, string>,
|
||||
options?: NormalizeDateCellOptions
|
||||
options?: NormalizeDateCellOptions,
|
||||
onValueRejected?: (columnName: string) => void
|
||||
): RowData[] {
|
||||
const colByName = new Map(tableSchema.columns.map((c) => [c.name, c]))
|
||||
|
||||
@@ -599,10 +691,14 @@ export function coerceRowsForTable(
|
||||
const col = colByName.get(colName)
|
||||
if (!col) continue
|
||||
const colType = (col.type as CsvColumnType) ?? 'string'
|
||||
coerced[getColumnId(col)] = coerceValue(value, colType, {
|
||||
const next = coerceValue(value, colType, {
|
||||
...options,
|
||||
...(col.currencyCode !== undefined ? { currencyCode: col.currencyCode } : {}),
|
||||
}) as RowData[string]
|
||||
})
|
||||
if (next === null && onValueRejected && value !== null && value !== undefined) {
|
||||
if (typeof value !== 'string' || value.trim() !== '') onValueRejected(col.name)
|
||||
}
|
||||
coerced[getColumnId(col)] = next as RowData[string]
|
||||
}
|
||||
return coerced
|
||||
})
|
||||
@@ -675,17 +771,25 @@ export function parseJsonRows(buffer: Buffer | string): {
|
||||
|
||||
/**
|
||||
* Parses a tabular upload (CSV, TSV, or JSON array-of-objects) into a uniform
|
||||
* `{ headers, rows }` shape, dispatching on file extension and falling back to
|
||||
* the MIME content type. Throws on unsupported formats so callers fail fast.
|
||||
* `{ headers, rows, rejections }` shape, dispatching on file extension and falling
|
||||
* back to the MIME content type. Throws on unsupported formats so callers fail fast.
|
||||
*
|
||||
* `rejections` is what a caller publishes so a partially imported file is not
|
||||
* reported as a clean import. The JSON path never drops records — a malformed
|
||||
* element throws — so it always reports none.
|
||||
*/
|
||||
export async function parseFileRows(
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
contentType?: string
|
||||
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
|
||||
): Promise<{
|
||||
headers: string[]
|
||||
rows: Record<string, unknown>[]
|
||||
rejections: CsvRejectionSummary
|
||||
}> {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase()
|
||||
if (ext === 'json' || contentType === 'application/json') {
|
||||
return parseJsonRows(buffer)
|
||||
return { ...parseJsonRows(buffer), rejections: { rowsRejected: 0, rejectedSamples: [] } }
|
||||
}
|
||||
if (ext === 'csv' || ext === 'tsv' || contentType === 'text/csv') {
|
||||
const delimiter = await detectCsvDelimiter(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
|
||||
import { dbChainMockFns, flattenMockConditions, resetDbChainMock, schemaMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
EMPTY_JOB_FIELDS,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
latestJobsForTables,
|
||||
latestNonExportJobJson,
|
||||
mapJobRow,
|
||||
recordImportRejections,
|
||||
type TableImportRejectionSummary,
|
||||
} from '@/lib/table/jobs/service'
|
||||
|
||||
function job(overrides: Partial<LatestJobRow>): LatestJobRow {
|
||||
@@ -174,3 +176,51 @@ describe('latestJobsForTables', () => {
|
||||
expect(jobs.get('table-1')).toMatchObject({ jobId: 'job-1', pendingDeleteRemaining: 6 })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The merge is hand-written SQL (`coalesce(payload, '{}'::jsonb) || $1::jsonb`) over a
|
||||
* four-clause WHERE, and the row-queue mock returns whatever was queued regardless of the
|
||||
* statement — so a canned `.returning()` cannot tell a merge from a clobbering overwrite.
|
||||
* The generated statement and condition nodes are the only place either is observable.
|
||||
*/
|
||||
describe('recordImportRejections', () => {
|
||||
const summary: TableImportRejectionSummary = {
|
||||
rowsRejected: 2,
|
||||
cellsRejected: 5,
|
||||
rejectedSamples: [{ code: 'CSV_QUOTE_NOT_CLOSED', line: 7, message: 'Quote not closed' }],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('merges the summary into the existing payload rather than replacing it', async () => {
|
||||
await recordImportRejections('table-1', 'workspace-1', 'job-1', summary)
|
||||
|
||||
const update = dbChainMockFns.set.mock.calls.at(-1)?.[0] as { payload?: unknown }
|
||||
const payload = update?.payload as { toSQL?: () => { sql: string; params: unknown[] } }
|
||||
// A plain `set({ payload: summary })` writes the object itself — no fragment, no merge —
|
||||
// which is what silently drops `kind`/`userId`/`source`/`target` from the job payload.
|
||||
expect(typeof payload?.toSQL).toBe('function')
|
||||
|
||||
const rendered = payload.toSQL!()
|
||||
expect(rendered.sql.replace(/\s+/g, ' ')).toBe("coalesce(?, '{}'::jsonb) || ?::jsonb")
|
||||
expect(rendered.params).toEqual([schemaMock.tableJobs.payload, JSON.stringify(summary)])
|
||||
})
|
||||
|
||||
it('scopes the merge to this job, table, workspace and job type', async () => {
|
||||
await recordImportRejections('table-1', 'workspace-1', 'job-1', summary)
|
||||
|
||||
const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0])
|
||||
expect(conditions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: 'eq', left: schemaMock.tableJobs.id, right: 'job-1' },
|
||||
{ type: 'eq', left: schemaMock.tableJobs.tableId, right: 'table-1' },
|
||||
{ type: 'eq', left: schemaMock.tableJobs.workspaceId, right: 'workspace-1' },
|
||||
{ type: 'eq', left: schemaMock.tableJobs.type, right: 'import' },
|
||||
])
|
||||
)
|
||||
expect(conditions).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { db } from '@sim/db'
|
||||
import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema'
|
||||
import type { Column, SQL } from 'drizzle-orm'
|
||||
import { and, asc, desc, eq, gt, inArray, ne, or, sql } from 'drizzle-orm'
|
||||
import type { CsvSkippedRecord } from '@/lib/table/import'
|
||||
import { pendingDeleteMask } from '@/lib/table/rows/pending-delete-mask'
|
||||
import type {
|
||||
RowData,
|
||||
@@ -300,6 +301,55 @@ export async function updateJobProgressInWorkspace(
|
||||
return updated.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejection accounting an import worker folds into `table_jobs.payload`, so a partial
|
||||
* import is observable on the import record. Kept in the existing payload column
|
||||
* rather than new columns because the payload is already the job's type-specific
|
||||
* descriptor (see `doomedCount`), and an import record must be able to report loss
|
||||
* without a schema migration.
|
||||
*/
|
||||
export interface TableImportRejectionSummary {
|
||||
/**
|
||||
* Lower bound on the source records the CSV parser could not read and dropped —
|
||||
* one per parser failure, and a single failure can discard many records.
|
||||
*/
|
||||
rowsRejected: number
|
||||
/** Non-empty cell values the target column type could not represent (stored as null). */
|
||||
cellsRejected: number
|
||||
/** Bounded sample of the dropped records, for locating them in the source file. */
|
||||
rejectedSamples: CsvSkippedRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges an import's rejection summary into its job payload.
|
||||
*
|
||||
* Deliberately NOT scoped to `status = 'running'` like the progress writes: the summary is
|
||||
* written once the run is terminal (including cancel/failure), when the row is no longer
|
||||
* active. It is still scoped to the job id, table, workspace and type, so a stale worker can
|
||||
* only ever annotate its own job row. `||` merges into the existing payload, leaving the
|
||||
* import descriptor `parseImportJobPayload` reads intact.
|
||||
*/
|
||||
export async function recordImportRejections(
|
||||
tableId: string,
|
||||
workspaceId: string,
|
||||
jobId: string,
|
||||
summary: TableImportRejectionSummary
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(tableJobs)
|
||||
.set({
|
||||
payload: sql`coalesce(${tableJobs.payload}, '{}'::jsonb) || ${JSON.stringify(summary)}::jsonb`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(tableJobs.id, jobId),
|
||||
eq(tableJobs.tableId, tableId),
|
||||
eq(tableJobs.workspaceId, workspaceId),
|
||||
eq(tableJobs.type, 'import')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the persisted progress of an in-flight job this worker still owns (`null` when the job
|
||||
* was canceled/superseded). A retried run seeds its counter from this so progress stays
|
||||
|
||||
@@ -254,6 +254,77 @@ describe('findTableImportResource on a job that is not a v2 import resource', ()
|
||||
target: TARGET,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A malformed source file imports fewer rows than it holds. Without this on the
|
||||
* record, `completed` with `rowsProcessed: 1` is indistinguishable from a clean
|
||||
* one-row import, and a script has no way to notice the loss.
|
||||
*/
|
||||
it('surfaces the rejection summary the runner merged into the payload', async () => {
|
||||
const sample = { code: 'CSV_QUOTE_NOT_CLOSED', line: 4, message: 'Quote Not Closed' }
|
||||
mockDbLimit.mockResolvedValue([
|
||||
job({
|
||||
status: 'ready',
|
||||
rowsProcessed: 1,
|
||||
payload: {
|
||||
...PAYLOAD,
|
||||
rowsRejected: 1,
|
||||
cellsRejected: 2,
|
||||
rejectedSamples: [sample],
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
await expect(findTableImportResource({ importId: IMPORT_ID })).resolves.toMatchObject({
|
||||
rowsProcessed: 1,
|
||||
rowsRejected: 1,
|
||||
cellsRejected: 2,
|
||||
rejectedSamples: [sample],
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The samples are read straight out of schemaless jsonb into a `.strict()` response
|
||||
* schema, so an element carrying an extra key, a non-integer line, or no message at all
|
||||
* would turn a read of the import into a 500 rather than a degraded body.
|
||||
*/
|
||||
it('drops persisted rejection data the response schema could not accept', async () => {
|
||||
mockDbLimit.mockResolvedValue([
|
||||
job({
|
||||
status: 'ready',
|
||||
rowsProcessed: 1,
|
||||
payload: {
|
||||
...PAYLOAD,
|
||||
rowsRejected: 1.5,
|
||||
cellsRejected: -2,
|
||||
rejectedSamples: [
|
||||
{ code: 'CSV_QUOTE_NOT_CLOSED', line: 4, message: 'Quote Not Closed', extra: 'drift' },
|
||||
{ code: 'CSV_RECORD_INCONSISTENT_COLUMNS', line: '9', message: 'Ragged row' },
|
||||
{ code: 'CSV_MAX_RECORD_SIZE' },
|
||||
'not-an-object',
|
||||
],
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const resource = await findTableImportResource({ importId: IMPORT_ID })
|
||||
|
||||
expect(resource?.rejectedSamples).toEqual([
|
||||
{ code: 'CSV_QUOTE_NOT_CLOSED', line: 4, message: 'Quote Not Closed' },
|
||||
{ code: 'CSV_RECORD_INCONSISTENT_COLUMNS', line: null, message: 'Ragged row' },
|
||||
])
|
||||
expect(resource).toMatchObject({ rowsRejected: 0, cellsRejected: 0 })
|
||||
})
|
||||
|
||||
it('reads a job written before rejection accounting as a clean import', async () => {
|
||||
mockDbLimit.mockResolvedValue([job({ payload: PAYLOAD, status: 'ready', rowsProcessed: 3 })])
|
||||
|
||||
await expect(findTableImportResource({ importId: IMPORT_ID })).resolves.toMatchObject({
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 0,
|
||||
rejectedSamples: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,9 +25,13 @@ import { getWorkspaceTableLimits } from '@/lib/table/billing'
|
||||
import {
|
||||
CSV_DURABLE_MAX_FILE_SIZE_BYTES,
|
||||
CSV_DURABLE_MAX_FILE_SIZE_MESSAGE,
|
||||
type CsvSkippedRecord,
|
||||
} from '@/lib/table/import'
|
||||
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
|
||||
import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service'
|
||||
import {
|
||||
markTableJobRunningInWorkspace,
|
||||
type TableImportRejectionSummary,
|
||||
} from '@/lib/table/jobs/service'
|
||||
import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks'
|
||||
import { assertWorkspaceTableCapacity, createTable, getTableById } from '@/lib/table/service'
|
||||
import type { TableImportJobPayload } from '@/lib/table/types'
|
||||
@@ -58,6 +62,10 @@ export interface TableImportResource {
|
||||
tableId: string | null
|
||||
status: TableImportStatus
|
||||
rowsProcessed: number
|
||||
/** See {@link TableImportRejectionSummary}. Zeroed for an import that lost nothing. */
|
||||
rowsRejected: number
|
||||
cellsRejected: number
|
||||
rejectedSamples: CsvSkippedRecord[]
|
||||
error: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -253,6 +261,7 @@ export async function findTableImportResource(params: {
|
||||
tableId: job.tableId,
|
||||
status,
|
||||
rowsProcessed: job.rowsProcessed,
|
||||
...parseRejectionSummary(job.payload),
|
||||
error: job.error,
|
||||
createdAt: job.startedAt,
|
||||
updatedAt: job.updatedAt,
|
||||
@@ -307,6 +316,9 @@ export function toV2TableImport(record: TableImportResource): V2TableImport {
|
||||
target: record.target,
|
||||
tableId: record.tableId,
|
||||
rowsProcessed: record.rowsProcessed,
|
||||
rowsRejected: record.rowsRejected,
|
||||
cellsRejected: record.cellsRejected,
|
||||
rejectedSamples: record.rejectedSamples,
|
||||
error: record.error,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
@@ -466,6 +478,9 @@ function resourceFromUpload(
|
||||
tableId: body.target.type === 'existing' ? body.target.tableId : null,
|
||||
status: uploadStatus(upload),
|
||||
rowsProcessed: 0,
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 0,
|
||||
rejectedSamples: [],
|
||||
error: null,
|
||||
createdAt: upload.createdAt,
|
||||
updatedAt: upload.updatedAt,
|
||||
@@ -499,6 +514,55 @@ interface ParsedTableImportPayload {
|
||||
options: TableImportJobPayload['options']
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the rejection summary the import runner merges into `table_jobs.payload`.
|
||||
*
|
||||
* Every field is defensive: the payload is schemaless jsonb, and jobs that ran before
|
||||
* rejection accounting existed simply carry none — those read back as a clean import,
|
||||
* which is what they were as far as anything can now tell.
|
||||
*/
|
||||
function parseRejectionSummary(payload: unknown): TableImportRejectionSummary {
|
||||
const empty: TableImportRejectionSummary = {
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 0,
|
||||
rejectedSamples: [],
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return empty
|
||||
const candidate = payload as Partial<TableImportRejectionSummary>
|
||||
const samples = Array.isArray(candidate.rejectedSamples) ? candidate.rejectedSamples : []
|
||||
return {
|
||||
rowsRejected: parseRejectionCount(candidate.rowsRejected),
|
||||
cellsRejected: parseRejectionCount(candidate.cellsRejected),
|
||||
rejectedSamples: samples
|
||||
.map(parseRejectedSample)
|
||||
.filter((sample): sample is CsvSkippedRecord => sample !== null),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows one persisted rejection sample to exactly the fields the strict response schema
|
||||
* accepts, or `null` when it cannot be read as one.
|
||||
*
|
||||
* The array is read straight out of schemaless jsonb, so an element written by a different
|
||||
* worker version — an extra key, a non-integer line, a missing message — would otherwise be
|
||||
* handed unchanged to a `.strict()` schema and turn a read of the import into a 500.
|
||||
*/
|
||||
function parseRejectedSample(value: unknown): CsvSkippedRecord | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
const candidate = value as Partial<CsvSkippedRecord>
|
||||
if (typeof candidate.code !== 'string' || typeof candidate.message !== 'string') return null
|
||||
const line =
|
||||
typeof candidate.line === 'number' && Number.isInteger(candidate.line) && candidate.line >= 0
|
||||
? candidate.line
|
||||
: null
|
||||
return { code: candidate.code, line, message: candidate.message }
|
||||
}
|
||||
|
||||
/** Reads a persisted count the response schema declares as a non-negative integer. */
|
||||
function parseRejectionCount(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `table_jobs.payload` as an import-resource payload, or `null` when it
|
||||
* is not one. A null payload is the normal shape for the first-party CSV import
|
||||
|
||||
@@ -15,6 +15,10 @@ const {
|
||||
mockGetMaxRowsPerTable,
|
||||
mockDispatchAfterBatchInsert,
|
||||
mockSignalSchemaChanged,
|
||||
mockGetWorkspaceTableLimits,
|
||||
mockBatchInsertRows,
|
||||
mockCreateTable,
|
||||
mockDeleteTable,
|
||||
} = vi.hoisted(() => ({
|
||||
mockMarkTableJobRunning: vi.fn(),
|
||||
mockReleaseJobClaim: vi.fn(),
|
||||
@@ -23,6 +27,10 @@ const {
|
||||
mockGetMaxRowsPerTable: vi.fn(),
|
||||
mockDispatchAfterBatchInsert: vi.fn(),
|
||||
mockSignalSchemaChanged: vi.fn(),
|
||||
mockGetWorkspaceTableLimits: vi.fn(),
|
||||
mockBatchInsertRows: vi.fn(),
|
||||
mockCreateTable: vi.fn(),
|
||||
mockDeleteTable: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/jobs/service', () => ({
|
||||
@@ -35,18 +43,21 @@ vi.mock('@/lib/table/import-data', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/table/billing', () => ({
|
||||
getMaxRowsPerTable: mockGetMaxRowsPerTable,
|
||||
getWorkspaceTableLimits: vi.fn(),
|
||||
getWorkspaceTableLimits: mockGetWorkspaceTableLimits,
|
||||
wouldExceedRowLimit: (limit: number, current: number, added: number) =>
|
||||
limit >= 0 && current + added > limit,
|
||||
}))
|
||||
vi.mock('@/lib/table/rows/service', () => ({
|
||||
batchInsertRows: vi.fn(),
|
||||
batchInsertRows: mockBatchInsertRows,
|
||||
dispatchAfterBatchInsert: mockDispatchAfterBatchInsert,
|
||||
}))
|
||||
vi.mock('@/lib/table/service', () => ({ createTable: vi.fn(), deleteTable: vi.fn() }))
|
||||
vi.mock('@/lib/table/service', () => ({
|
||||
createTable: mockCreateTable,
|
||||
deleteTable: mockDeleteTable,
|
||||
}))
|
||||
vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged }))
|
||||
|
||||
import { performTableCsvImport } from '@/lib/table/orchestration/import'
|
||||
import { performCreateTableFromCsv, performTableCsvImport } from '@/lib/table/orchestration/import'
|
||||
|
||||
const TABLE = {
|
||||
id: 'table-1',
|
||||
@@ -94,6 +105,12 @@ beforeEach(() => {
|
||||
table: TABLE,
|
||||
})
|
||||
mockImportReplaceRows.mockResolvedValue({ insertedCount: 2, deletedCount: 10 })
|
||||
mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 10, maxRowsPerTable: 1000 })
|
||||
mockCreateTable.mockResolvedValue(TABLE)
|
||||
mockDeleteTable.mockResolvedValue(undefined)
|
||||
mockBatchInsertRows.mockImplementation(async ({ rows }: { rows: unknown[] }) =>
|
||||
rows.map((_, index) => ({ id: `row-${index}` }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('performTableCsvImport', () => {
|
||||
@@ -212,6 +229,55 @@ describe('performTableCsvImport', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The parser drops malformed records silently, so a buffered import used to
|
||||
* finish with a smaller table and nothing distinguishing it from a clean one.
|
||||
*/
|
||||
describe('rejection accounting', () => {
|
||||
it('reports the records a malformed CSV silently dropped', async () => {
|
||||
const result = await performTableCsvImport(
|
||||
importParams({
|
||||
fileStream: csvStream('email,name\na@b.c,Ann\nd@e.f,"unterminated\ng@h.i,Gil\n'),
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.rejections?.rowsRejected).toBeGreaterThan(0)
|
||||
expect(result.data?.rejections?.rejectedSamples[0]).toMatchObject({
|
||||
code: 'CSV_QUOTE_NOT_CLOSED',
|
||||
})
|
||||
})
|
||||
|
||||
it('counts cells the target column type could not represent', async () => {
|
||||
const result = await performTableCsvImport(
|
||||
importParams({
|
||||
table: {
|
||||
...TABLE,
|
||||
schema: {
|
||||
columns: [
|
||||
{ id: 'col_age', name: 'age', type: 'number', required: false, unique: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
fileStream: csvStream('age\n42\nnot-a-number\n'),
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.rejections).toEqual({
|
||||
rowsRejected: 0,
|
||||
cellsRejected: 1,
|
||||
rejectedSamples: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the accounting entirely from a clean import', async () => {
|
||||
const result = await performTableCsvImport(importParams())
|
||||
|
||||
expect(result.data).not.toHaveProperty('rejections')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects createColumns naming a header the file does not have', async () => {
|
||||
const result = await performTableCsvImport(importParams({ createColumns: ['phone'] }))
|
||||
|
||||
@@ -233,3 +299,37 @@ describe('performTableCsvImport', () => {
|
||||
expect(Object.keys(rows[0])).toContain(additions[0].id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('performCreateTableFromCsv', () => {
|
||||
function createParams(text: string) {
|
||||
return {
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
fileStream: csvStream(text),
|
||||
fileName: 'contacts.csv',
|
||||
fallbackDelimiter: ',' as const,
|
||||
folderId: null,
|
||||
timezone: 'UTC',
|
||||
requestId: 'req-1',
|
||||
}
|
||||
}
|
||||
|
||||
it('reports the records a malformed CSV silently dropped', async () => {
|
||||
const result = await performCreateTableFromCsv(
|
||||
createParams('email,name\na@b.c,Ann\nd@e.f,"unterminated\ng@h.i,Gil\n')
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.rejections?.rowsRejected).toBeGreaterThan(0)
|
||||
expect(result.data?.rejections?.rejectedSamples[0]).toMatchObject({
|
||||
code: 'CSV_QUOTE_NOT_CLOSED',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the accounting entirely from a clean import', async () => {
|
||||
const result = await performCreateTableFromCsv(createParams(CSV))
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).not.toHaveProperty('rejections')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
type CsvDelimiter,
|
||||
type CsvHeaderMapping,
|
||||
CsvImportValidationError,
|
||||
type CsvRejectionSummary,
|
||||
coerceRowsForTable,
|
||||
createCsvRejectionCollector,
|
||||
inferColumnType,
|
||||
inferSchemaFromCsv,
|
||||
sanitizeName,
|
||||
@@ -28,7 +30,11 @@ import {
|
||||
} from '@/lib/table/import'
|
||||
import { importAppendRows, importReplaceRows } from '@/lib/table/import-data'
|
||||
import { createCsvParser } from '@/lib/table/import-stream'
|
||||
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
|
||||
import {
|
||||
markTableJobRunning,
|
||||
releaseJobClaim,
|
||||
type TableImportRejectionSummary,
|
||||
} from '@/lib/table/jobs/service'
|
||||
import { TableLockedError } from '@/lib/table/mutation-locks'
|
||||
import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance'
|
||||
import { batchInsertRows, dispatchAfterBatchInsert } from '@/lib/table/rows/service'
|
||||
@@ -78,6 +84,37 @@ function classifyImportFailure(error: unknown, requestId: string, tableId: strin
|
||||
return fail(toError(error).message, 'internal')
|
||||
}
|
||||
|
||||
/**
|
||||
* What a synchronous import reports about the data it lost, so a partial import
|
||||
* is not rendered as a clean one — the same accounting the streaming runner
|
||||
* folds into the import record, returned inline because these paths have no
|
||||
* import record to read afterwards.
|
||||
*
|
||||
* Omitted entirely when nothing was lost, so a clean import's payload stays
|
||||
* exactly what it has always been.
|
||||
*/
|
||||
export interface ImportRejectionFields {
|
||||
rejections?: TableImportRejectionSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects a parse summary and the coercion tally onto the field a result
|
||||
* carries, collapsing to `{}` when the import lost nothing.
|
||||
*/
|
||||
function rejectionFields(
|
||||
rejections: CsvRejectionSummary,
|
||||
cellsRejected: number
|
||||
): ImportRejectionFields {
|
||||
if (rejections.rowsRejected === 0 && cellsRejected === 0) return {}
|
||||
return {
|
||||
rejections: {
|
||||
rowsRejected: rejections.rowsRejected,
|
||||
cellsRejected,
|
||||
rejectedSamples: rejections.rejectedSamples,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains a CSV/TSV stream into memory. The extension only picks the fallback —
|
||||
* the separator is sniffed from the file's head so semicolon/pipe exports
|
||||
@@ -86,13 +123,22 @@ function classifyImportFailure(error: unknown, requestId: string, tableId: strin
|
||||
async function readCsvRows(
|
||||
fileStream: Readable,
|
||||
fallbackDelimiter: CsvDelimiter
|
||||
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
|
||||
): Promise<{
|
||||
headers: string[]
|
||||
rows: Record<string, unknown>[]
|
||||
rejections: CsvRejectionSummary
|
||||
}> {
|
||||
const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter)
|
||||
|
||||
let headers: string[] = []
|
||||
const parser = createCsvParser(delimiter, (parsedHeaders) => {
|
||||
headers = parsedHeaders
|
||||
})
|
||||
const rejections = createCsvRejectionCollector()
|
||||
const parser = createCsvParser(
|
||||
delimiter,
|
||||
(parsedHeaders) => {
|
||||
headers = parsedHeaders
|
||||
},
|
||||
rejections.onSkip
|
||||
)
|
||||
// `.pipe` doesn't forward source errors; forward them so the iterator throws.
|
||||
stream.on('error', (streamError) => parser.destroy(streamError))
|
||||
stream.pipe(parser)
|
||||
@@ -101,7 +147,7 @@ async function readCsvRows(
|
||||
for await (const record of parser as AsyncIterable<Record<string, unknown>>) {
|
||||
rows.push(record)
|
||||
}
|
||||
return { headers, rows }
|
||||
return { headers, rows, rejections: rejections.summary }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +236,7 @@ export interface PerformTableCsvImportParams {
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface TableCsvImportData {
|
||||
export interface TableCsvImportData extends ImportRejectionFields {
|
||||
tableId: string
|
||||
mode: 'append' | 'replace'
|
||||
insertedCount: number
|
||||
@@ -234,7 +280,7 @@ export async function performTableCsvImport(
|
||||
return fail('A job is already in progress for this table', 'conflict')
|
||||
}
|
||||
|
||||
const { headers, rows } = await readCsvRows(fileStream, fallbackDelimiter)
|
||||
const { headers, rows, rejections } = await readCsvRows(fileStream, fallbackDelimiter)
|
||||
if (rows.length === 0) return fail('CSV file has no data rows', 'validation')
|
||||
|
||||
let effectiveMapping = params.mapping ?? buildAutoMapping(headers, table.schema)
|
||||
@@ -272,7 +318,24 @@ export async function performTableCsvImport(
|
||||
)
|
||||
}
|
||||
|
||||
const coerced = coerceRowsForTable(rows, prospectiveSchema, validation.effectiveMap, { timezone })
|
||||
let cellsRejected = 0
|
||||
const coerced = coerceRowsForTable(
|
||||
rows,
|
||||
prospectiveSchema,
|
||||
validation.effectiveMap,
|
||||
{ timezone },
|
||||
() => {
|
||||
cellsRejected++
|
||||
}
|
||||
)
|
||||
const rejected = rejectionFields(rejections, cellsRejected)
|
||||
if (rejected.rejections) {
|
||||
logger.warn(`[${requestId}] CSV import lost source data`, {
|
||||
tableId: table.id,
|
||||
fileName,
|
||||
...rejected.rejections,
|
||||
})
|
||||
}
|
||||
|
||||
const importId = generateId()
|
||||
if (!(await markTableJobRunning(table.id, importId, 'import'))) {
|
||||
@@ -286,6 +349,7 @@ export async function performTableCsvImport(
|
||||
skippedHeaders: validation.skippedHeaders,
|
||||
unmappedColumns: validation.unmappedColumns,
|
||||
sourceFile: fileName,
|
||||
...rejected,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -383,7 +447,7 @@ export interface PerformCreateTableFromCsvResult {
|
||||
* caller which lock to clear, and leaving it off the type is how a 423 silently loses it.
|
||||
*/
|
||||
lock?: TableLockKind
|
||||
data?: { table: CreatedTableFromCsv }
|
||||
data?: { table: CreatedTableFromCsv } & ImportRejectionFields
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,9 +468,15 @@ export async function performCreateTableFromCsv(
|
||||
const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter)
|
||||
|
||||
let csvHeaders: string[] = []
|
||||
const parser = createCsvParser(delimiter, (headers) => {
|
||||
csvHeaders = headers
|
||||
})
|
||||
const rejections = createCsvRejectionCollector()
|
||||
let cellsRejected = 0
|
||||
const parser = createCsvParser(
|
||||
delimiter,
|
||||
(headers) => {
|
||||
csvHeaders = headers
|
||||
},
|
||||
rejections.onSkip
|
||||
)
|
||||
stream.on('error', (streamError) => parser.destroy(streamError))
|
||||
stream.pipe(parser)
|
||||
|
||||
@@ -422,7 +492,15 @@ export async function performCreateTableFromCsv(
|
||||
currentRowCount: number
|
||||
): Promise<number> => {
|
||||
if (batch.length === 0) return 0
|
||||
const coerced = coerceRowsForTable(batch, state.schema, state.headerToColumn, { timezone })
|
||||
const coerced = coerceRowsForTable(
|
||||
batch,
|
||||
state.schema,
|
||||
state.headerToColumn,
|
||||
{ timezone },
|
||||
() => {
|
||||
cellsRejected++
|
||||
}
|
||||
)
|
||||
const inserted = await batchInsertRows(
|
||||
{
|
||||
tableId: state.table.id,
|
||||
@@ -513,6 +591,15 @@ export async function performCreateTableFromCsv(
|
||||
rows: inserted,
|
||||
})
|
||||
|
||||
const rejected = rejectionFields(rejections.summary, cellsRejected)
|
||||
if (rejected.rejections) {
|
||||
logger.warn(`[${requestId}] CSV import lost source data`, {
|
||||
tableId: state.table.id,
|
||||
fileName,
|
||||
...rejected.rejections,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -523,6 +610,7 @@ export async function performCreateTableFromCsv(
|
||||
schema: state.schema,
|
||||
rowCount: inserted,
|
||||
},
|
||||
...rejected,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
recordAudit: vi.fn(),
|
||||
resolveContext: vi.fn(),
|
||||
resolvePermission: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getRunFiles: vi.fn(),
|
||||
describeRunFiles: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => ({
|
||||
AuditAction: {},
|
||||
AuditResourceType: { WORKFLOW: 'workflow' },
|
||||
recordAudit: mocks.recordAudit,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/workspace', () => ({
|
||||
permissionSatisfies: (actual: string | null, required: string) => {
|
||||
const rank = { read: 1, write: 2, admin: 3 } as const
|
||||
return (
|
||||
actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank]
|
||||
)
|
||||
},
|
||||
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/application/context', () => ({
|
||||
resolveActiveWorkflowRunApplicationContext: mocks.resolveContext,
|
||||
}))
|
||||
vi.mock('@/lib/workflows/executor/execution-status', () => ({
|
||||
getWorkflowExecutionStatus: mocks.getStatus,
|
||||
}))
|
||||
vi.mock('@/lib/workflows/executor/execution-run-files', () => ({
|
||||
getWorkflowRunFiles: mocks.getRunFiles,
|
||||
describeWorkflowRunFiles: mocks.describeRunFiles,
|
||||
}))
|
||||
|
||||
import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run'
|
||||
|
||||
const context = {
|
||||
workflowId: 'workflow-1',
|
||||
runId: 'run-1',
|
||||
workflow: { id: 'workflow-1', name: 'Daily digest', workspaceId: 'workspace-1' },
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceOrganizationId: null,
|
||||
allowPersonalApiKeys: true,
|
||||
billedAccountUserId: 'billing-owner-1',
|
||||
}
|
||||
|
||||
const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }
|
||||
|
||||
const BLOCK_ID = '2f9c2d4e-1a3b-4c5d-8e7f-0a1b2c3d4e5f'
|
||||
|
||||
function input(selectedOutputs: string[]) {
|
||||
return { workflowId: 'workflow-1', runId: 'run-1', includeOutput: true, selectedOutputs }
|
||||
}
|
||||
|
||||
describe('readWorkflowRun selector resolution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.resolveContext.mockResolvedValue(context)
|
||||
mocks.resolvePermission.mockResolvedValue('read')
|
||||
mocks.getRunFiles.mockResolvedValue(null)
|
||||
mocks.getStatus.mockResolvedValue({ status: 'completed', blockOutputs: {} })
|
||||
})
|
||||
|
||||
it('rejects a block-name selector against a recorded output projection', async () => {
|
||||
mocks.getStatus.mockResolvedValue({
|
||||
status: 'completed',
|
||||
blockOutputs: { [BLOCK_ID]: { content: 'hi' } },
|
||||
})
|
||||
await expect(readWorkflowRun.execute({ principal, input: input(['Agent 1']) })).rejects.toThrow(
|
||||
/did not resolve to any block on this run: Agent 1/
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* The regression: a queued or resuming run records no output projection yet,
|
||||
* so a name-headed selector used to be waved through and answer 200 with
|
||||
* nothing selected — the silent empty answer the check exists to remove.
|
||||
*/
|
||||
it('rejects a block-name selector on a run with no recorded output projection', async () => {
|
||||
mocks.getStatus.mockResolvedValue({ status: 'queued', blockOutputs: null })
|
||||
await expect(readWorkflowRun.execute({ principal, input: input(['Agent 1']) })).rejects.toThrow(
|
||||
/did not resolve to any block on this run: Agent 1/
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a well-formed block id on a run with no recorded output projection', async () => {
|
||||
mocks.getStatus.mockResolvedValue({ status: 'queued', blockOutputs: null })
|
||||
await expect(
|
||||
readWorkflowRun.execute({ principal, input: input([`${BLOCK_ID}.content`]) })
|
||||
).resolves.toMatchObject({ status: 'queued', blockOutputs: null })
|
||||
})
|
||||
|
||||
it('accepts a well-formed block id that produced no output', async () => {
|
||||
await expect(
|
||||
readWorkflowRun.execute({ principal, input: input([BLOCK_ID]) })
|
||||
).resolves.toMatchObject({ status: 'completed', blockOutputs: {} })
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isValidUuid } from '@sim/utils/id'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import {
|
||||
FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE,
|
||||
@@ -13,6 +14,35 @@ import {
|
||||
} from '@/lib/workflows/executor/execution-run-files'
|
||||
import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status'
|
||||
|
||||
/**
|
||||
* Selectors this resource can never answer, so the caller hears about them.
|
||||
*
|
||||
* This resource reads a recorded run and deliberately never loads the
|
||||
* workflow's blocks, so it matches block *ids* only. A selector headed by
|
||||
* something that is not a block id — a block name, the shape the streaming
|
||||
* execute request accepts — has nothing to resolve against and used to come
|
||||
* back as an empty `blockOutputs` with a `200`, which reads exactly like "that
|
||||
* block produced nothing".
|
||||
*
|
||||
* A head that *is* a well-formed id but produced no output stays a legitimate
|
||||
* empty answer: the block may simply not have run on this path. That is also
|
||||
* why a run carrying no output projection at all — queued, resuming, or asked
|
||||
* about without `includeOutput` — is judged on the selector's shape alone
|
||||
* rather than waved through: a block *name* has nothing to resolve against on
|
||||
* such a run either, and letting it pass restores exactly the silent empty
|
||||
* answer this check exists to remove.
|
||||
*/
|
||||
function unresolvableSelectors(
|
||||
selectedOutputs: readonly string[],
|
||||
blockOutputs: Record<string, unknown> | null | undefined
|
||||
): string[] {
|
||||
return selectedOutputs.filter(
|
||||
(selector) =>
|
||||
!(blockOutputs && Object.hasOwn(blockOutputs, selector)) &&
|
||||
!isValidUuid(selector.split('.')[0])
|
||||
)
|
||||
}
|
||||
|
||||
export interface ReadWorkflowRunInput {
|
||||
workflowId: string
|
||||
runId: string
|
||||
@@ -39,6 +69,14 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({
|
||||
})
|
||||
if (!status) throw new OrchestrationError('not_found', 'Run not found')
|
||||
|
||||
const unresolvable = unresolvableSelectors(input.selectedOutputs, status.blockOutputs)
|
||||
if (unresolvable.length > 0) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
`selectedOutputs did not resolve to any block on this run: ${unresolvable.join(', ')}. This resource matches block ids only — pass "blockId" or "blockId.path", not a block name.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* File descriptors follow `output`'s gating: they describe the run's
|
||||
* output, so a caller that did not ask for output gets `null` rather than
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('workflow run application use cases', () => {
|
||||
workflowId: 'workflow-1',
|
||||
runId: 'run-1',
|
||||
includeOutput: true,
|
||||
selectedOutputs: ['block-1.value'],
|
||||
selectedOutputs: ['4f1c2b3a-0000-4000-8000-000000000001.value'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -129,10 +129,56 @@ describe('workflow run application use cases', () => {
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'run-1',
|
||||
includeOutput: true,
|
||||
selectedOutputs: ['block-1.value'],
|
||||
selectedOutputs: ['4f1c2b3a-0000-4000-8000-000000000001.value'],
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a selector that is not headed by a block id instead of answering an empty selection', async () => {
|
||||
mocks.getStatus.mockResolvedValueOnce({
|
||||
executionId: 'run-1',
|
||||
workflowId: 'workflow-1',
|
||||
status: 'completed',
|
||||
blockOutputs: {},
|
||||
})
|
||||
|
||||
await expect(
|
||||
readWorkflowRun.execute({
|
||||
principal: principals[2],
|
||||
input: {
|
||||
workflowId: 'workflow-1',
|
||||
runId: 'run-1',
|
||||
includeOutput: true,
|
||||
selectedOutputs: ['doubler.doubled'],
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'validation' })
|
||||
})
|
||||
|
||||
/**
|
||||
* A well-formed id that produced nothing is a legitimate empty answer — the
|
||||
* block may simply not have run on this path.
|
||||
*/
|
||||
it('allows a block id that produced no output on this run', async () => {
|
||||
mocks.getStatus.mockResolvedValueOnce({
|
||||
executionId: 'run-1',
|
||||
workflowId: 'workflow-1',
|
||||
status: 'completed',
|
||||
blockOutputs: {},
|
||||
})
|
||||
|
||||
const result = await readWorkflowRun.execute({
|
||||
principal: principals[2],
|
||||
input: {
|
||||
workflowId: 'workflow-1',
|
||||
runId: 'run-1',
|
||||
includeOutput: true,
|
||||
selectedOutputs: ['4f1c2b3a-0000-4000-8000-000000000001.value'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.blockOutputs).toEqual({})
|
||||
})
|
||||
|
||||
/**
|
||||
* File descriptors follow `output`'s gating: a caller that did not ask for
|
||||
* output must not receive a file list it did not request.
|
||||
|
||||
@@ -50,6 +50,35 @@ describe('upsertCustomTools schema invariant', () => {
|
||||
expect(db.transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* The stored declaration is what the model is handed. A name outside the
|
||||
* `[a-zA-Z0-9_-]{1,64}` charset every provider enforces, or a `parameters`
|
||||
* type that is not an object, used to be stored verbatim because the shared
|
||||
* read shape only checks that the name is a non-empty string.
|
||||
*/
|
||||
it.each([
|
||||
['a function name no provider accepts', { ...storableSchema.function, name: 'has spaces!' }],
|
||||
[
|
||||
'a parameters type that is not an object',
|
||||
{
|
||||
...storableSchema.function,
|
||||
parameters: { type: 'banana', properties: {} },
|
||||
},
|
||||
],
|
||||
])('refuses %s', async (_label, functionDeclaration) => {
|
||||
await expect(
|
||||
upsertCustomTools({
|
||||
tools: [
|
||||
{ title: 'Bad', schema: { type: 'function', function: functionDeclaration }, code: '' },
|
||||
],
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userId: USER_ID,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'validation' })
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the transaction for a storable declaration', async () => {
|
||||
await upsertCustomTools({
|
||||
tools: [{ title: 'Good', schema: storableSchema, code: '' }],
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
timestampKey,
|
||||
} from '@/lib/api/list-query'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { assertStorableCustomToolSchema } from '@/lib/custom-tools/schema'
|
||||
import { assertValidCustomToolDeclaration } from '@/lib/custom-tools/schema'
|
||||
|
||||
const logger = createLogger('CustomToolsOperations')
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function upsertCustomTools(params: {
|
||||
* the tools that preceded the unstorable one.
|
||||
*/
|
||||
for (const tool of tools) {
|
||||
assertStorableCustomToolSchema(tool.schema)
|
||||
assertValidCustomToolDeclaration(tool.schema)
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { applyOperationsToWorkflowState } from './engine'
|
||||
|
||||
vi.mock('@/blocks/registry', () => {
|
||||
@@ -403,6 +404,95 @@ describe('handleEditOperation dependent inputs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function makeParallelWorkflow() {
|
||||
const workflow = makeLoopWorkflow()
|
||||
workflow.blocks['loop-1'].type = 'parallel'
|
||||
workflow.blocks['loop-1'].data = { parallelType: 'count', count: 5 }
|
||||
return workflow
|
||||
}
|
||||
|
||||
describe('handleEditOperation container inputs', () => {
|
||||
it('reports an unknown loop input field instead of discarding it silently', () => {
|
||||
const workflow = makeLoopWorkflow()
|
||||
|
||||
const { state, validationErrors } = applyOperationsToWorkflowState(workflow, [
|
||||
{ operation_type: 'edit', block_id: 'loop-1', params: { inputs: { count: 3 } } },
|
||||
])
|
||||
|
||||
expect(validationErrors).toHaveLength(1)
|
||||
expect(validationErrors[0]).toMatchObject({ blockId: 'loop-1', field: 'count' })
|
||||
expect(validationErrors[0].error).toContain('iterations')
|
||||
expect(state.blocks['loop-1'].data.count).toBe(5)
|
||||
})
|
||||
|
||||
it('reports an unknown parallel input field', () => {
|
||||
const workflow = makeParallelWorkflow()
|
||||
|
||||
const { validationErrors } = applyOperationsToWorkflowState(workflow, [
|
||||
{ operation_type: 'edit', block_id: 'loop-1', params: { inputs: { maxConcurrency: 3 } } },
|
||||
])
|
||||
|
||||
expect(validationErrors).toHaveLength(1)
|
||||
expect(validationErrors[0]).toMatchObject({ blockId: 'loop-1', field: 'maxConcurrency' })
|
||||
})
|
||||
|
||||
it('applies `count` on a parallel container, the key the read view exports', () => {
|
||||
const workflow = makeParallelWorkflow()
|
||||
|
||||
const { state, validationErrors } = applyOperationsToWorkflowState(workflow, [
|
||||
{ operation_type: 'edit', block_id: 'loop-1', params: { inputs: { count: 3 } } },
|
||||
])
|
||||
|
||||
expect(validationErrors).toEqual([])
|
||||
expect(state.blocks['loop-1'].data.count).toBe(3)
|
||||
})
|
||||
|
||||
it('reports `iterations` on a parallel container and names `count` instead', () => {
|
||||
const workflow = makeParallelWorkflow()
|
||||
|
||||
const { state, validationErrors } = applyOperationsToWorkflowState(workflow, [
|
||||
{ operation_type: 'edit', block_id: 'loop-1', params: { inputs: { iterations: 3 } } },
|
||||
])
|
||||
|
||||
expect(validationErrors).toHaveLength(1)
|
||||
expect(validationErrors[0]).toMatchObject({ blockId: 'loop-1', field: 'iterations' })
|
||||
expect(validationErrors[0].error).toContain('count')
|
||||
expect(state.blocks['loop-1'].data.count).toBe(5)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a count parallel', makeParallelWorkflow, 5],
|
||||
['a for loop', makeLoopWorkflow, 5],
|
||||
])("round-trips the read view's container inputs for %s", (_label, makeWorkflow, expected) => {
|
||||
const workflow = makeWorkflow()
|
||||
const readInputs = sanitizeForCopilot(workflow as any).blocks['loop-1'].inputs
|
||||
|
||||
const { state, validationErrors } = applyOperationsToWorkflowState(makeWorkflow(), [
|
||||
{ operation_type: 'edit', block_id: 'loop-1', params: { inputs: readInputs } },
|
||||
])
|
||||
|
||||
expect(validationErrors).toEqual([])
|
||||
expect(state.blocks['loop-1'].data.count).toBe(expected)
|
||||
})
|
||||
|
||||
it('still applies a loop edit that uses the real input keys', () => {
|
||||
const workflow = makeLoopWorkflow()
|
||||
|
||||
const { state, validationErrors, skippedItems } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'loop-1',
|
||||
params: { inputs: { loopType: 'for', iterations: 3 } },
|
||||
},
|
||||
])
|
||||
|
||||
expect(validationErrors).toEqual([])
|
||||
expect(skippedItems).toEqual([])
|
||||
expect(state.blocks['loop-1'].data.count).toBe(3)
|
||||
expect(state.blocks['loop-1'].data.loopType).toBe('for')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleEditOperation nestedNodes merge', () => {
|
||||
it('preserves existing child block IDs when editing a loop with nestedNodes', () => {
|
||||
const workflow = makeLoopWorkflow()
|
||||
|
||||
@@ -55,6 +55,26 @@ export function findBlockWithDuplicateNormalizedName(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The input keys the edit engine actually writes onto a container block's
|
||||
* `data`.
|
||||
*
|
||||
* `loop` and `parallel` are not registry blocks, so without this they fell
|
||||
* through to the unknown-block-type branch, which returns every supplied key as
|
||||
* valid. The engine then wrote only the keys it recognises and discarded the
|
||||
* rest without a word, so a caller who guessed `count` instead of `iterations`
|
||||
* was told the edit applied while the loop kept its old bound — and `atomic`,
|
||||
* which refuses on exactly these errors, had nothing to refuse on.
|
||||
*/
|
||||
const CONTAINER_BLOCK_INPUT_FIELDS: Record<'loop' | 'parallel', ReadonlySet<string>> = {
|
||||
loop: new Set(['loopType', 'iterations', 'collection', 'condition']),
|
||||
parallel: new Set(['parallelType', 'count', 'collection']),
|
||||
}
|
||||
|
||||
function isContainerBlockType(blockType: string): blockType is 'loop' | 'parallel' {
|
||||
return blockType === 'loop' || blockType === 'parallel'
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and filters inputs against a block's subBlock configuration
|
||||
* Returns valid inputs and any validation errors encountered
|
||||
@@ -91,6 +111,25 @@ export function validateInputsForBlock(
|
||||
inputs = omit(inputs, [TRIGGER_ROUTING_FIELD])
|
||||
}
|
||||
|
||||
if (isContainerBlockType(blockType)) {
|
||||
const containerFields = CONTAINER_BLOCK_INPUT_FIELDS[blockType]
|
||||
const containerInputs: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
if (containerFields.has(key)) {
|
||||
containerInputs[key] = value
|
||||
continue
|
||||
}
|
||||
errors.push({
|
||||
blockId,
|
||||
blockType,
|
||||
field: key,
|
||||
value,
|
||||
error: `Unknown input field "${key}" for block type "${blockType}" (expected one of: ${[...containerFields].join(', ')})`,
|
||||
})
|
||||
}
|
||||
return { validInputs: containerInputs, errors }
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(blockType)
|
||||
|
||||
if (!blockConfig) {
|
||||
@@ -115,21 +154,15 @@ export function validateInputsForBlock(
|
||||
|
||||
const subBlockConfig = subBlockMap.get(key)
|
||||
|
||||
// If subBlock doesn't exist in config, skip it (unless it's a known dynamic field)
|
||||
// If subBlock doesn't exist in config, report it rather than dropping it
|
||||
if (!subBlockConfig) {
|
||||
// Some fields are valid but not in subBlocks (like loop/parallel config)
|
||||
// Allow these through for special block types
|
||||
if (blockType === 'loop' || blockType === 'parallel') {
|
||||
validatedInputs[key] = value
|
||||
} else {
|
||||
errors.push({
|
||||
blockId,
|
||||
blockType,
|
||||
field: key,
|
||||
value,
|
||||
error: `Unknown input field "${key}" for block type "${blockType}"`,
|
||||
})
|
||||
}
|
||||
errors.push({
|
||||
blockId,
|
||||
blockType,
|
||||
field: key,
|
||||
value,
|
||||
error: `Unknown input field "${key}" for block type "${blockType}"`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { isStorableCustomToolSchema } from '@/lib/custom-tools/schema'
|
||||
import { isValidCustomToolDeclaration } from '@/lib/custom-tools/schema'
|
||||
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
|
||||
const logger = createLogger('CustomToolsPersistence')
|
||||
@@ -146,7 +146,7 @@ export async function persistCustomToolsToDatabase(
|
||||
* unstorable declaration does not cost the rest of the import its tools —
|
||||
* the tool itself still runs from the inline definition on the block.
|
||||
*/
|
||||
if (!isStorableCustomToolSchema(tool.schema)) {
|
||||
if (!isValidCustomToolDeclaration(tool.schema)) {
|
||||
logger.warn(`Skipping custom tool with an unstorable schema: ${tool.title}`)
|
||||
return false
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,10 +14,11 @@
|
||||
"dev:sockets": "cd apps/realtime && bun run dev",
|
||||
"dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"",
|
||||
"dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"",
|
||||
"test": "bun run test:setup && bun run test:icon-path-precision && bun run test:tool-registry-boundary && turbo run test",
|
||||
"test": "bun run test:setup && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:generators && turbo run test",
|
||||
"test:setup": "bun run --cwd packages/sim-setup test",
|
||||
"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:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-docs.test.ts",
|
||||
"format": "turbo run format",
|
||||
"format:check": "turbo run format:check",
|
||||
"lint": "turbo run lint",
|
||||
|
||||
+91
-22
@@ -21,16 +21,16 @@ Non-secret settings live in `~/.sim/config`:
|
||||
```ini
|
||||
[default]
|
||||
endpoint = https://www.sim.ai
|
||||
workspace = ws_abc123
|
||||
workspace = b7f4a1c3-9e02-4d68-8a5b-1c3f6d90e274
|
||||
output = table
|
||||
|
||||
[profile dev]
|
||||
endpoint = http://localhost:3000
|
||||
workspace = ws_local
|
||||
workspace = a3c81b02-5f4d-4e19-9d7a-2b6c1e084f55
|
||||
|
||||
[profile acme]
|
||||
auth_profile = default
|
||||
workspace = ws_acme
|
||||
workspace = 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28
|
||||
```
|
||||
|
||||
Keys live in `~/.sim/credentials`, written `0600`:
|
||||
@@ -48,12 +48,22 @@ The section-naming asymmetry — `[profile dev]` in config, `[dev]` in credentia
|
||||
|
||||
```bash
|
||||
sim configure --set-endpoint http://localhost:3000 --profile dev
|
||||
sim configure --set-workspace ws_local --profile dev
|
||||
sim profiles # list them; * marks the active one
|
||||
sim profile add acme --workspace ws_acme # share the active stored login
|
||||
sim whoami # resolved values, where each came from, and whether they work
|
||||
sim configure --set-workspace a3c81b02-5f4d-4e19-9d7a-2b6c1e084f55 --profile dev
|
||||
|
||||
sim profiles # list them; * marks the active one
|
||||
sim whoami # resolved values, where each came from, and whether they work
|
||||
|
||||
# Share the active stored login with a second workspace
|
||||
sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28
|
||||
```
|
||||
|
||||
A profile name that is not configured is refused, with the closest configured
|
||||
name suggested. A typo used to fall through to the built-in defaults, so
|
||||
`--profile stagng` talked to production and handed it whatever key resolved.
|
||||
The two exceptions are the commands whose job is to create a profile:
|
||||
`sim login --profile new` and `sim configure --profile new` still accept a name
|
||||
that does not exist yet.
|
||||
|
||||
## Where settings come from
|
||||
|
||||
Each setting resolves independently, first match wins:
|
||||
@@ -62,13 +72,13 @@ Each setting resolves independently, first match wins:
|
||||
| --- | --- |
|
||||
| 1 | Command-line flag (`--endpoint`, `--workspace`, `--output`) |
|
||||
| 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) |
|
||||
| 3 | `~/.sim/config` for the selected profile and credentials for its `auth_profile`, when set |
|
||||
| 4 | Built-in default (`https://www.sim.ai`, `table`) |
|
||||
|
||||
`SIM_TIMEOUT_SECONDS` bounds each request (default `3600`, `0` waits
|
||||
indefinitely) and `SIM_DEBUG=1` traces requests to stderr. Node ignores
|
||||
`HTTPS_PROXY` unless `NODE_USE_ENV_PROXY=1` is also set, on Node 22.21+ or
|
||||
24.5+; the CLI warns when a proxy is configured but will not be used.
|
||||
| 3 | `~/.sim/config` for the selected profile and credentials for its `auth_profile`, when set |
|
||||
| 4 | Built-in default (`https://www.sim.ai`, `table`) |
|
||||
|
||||
Formats are listed under [Output formats](#output-formats).
|
||||
|
||||
@@ -105,7 +115,8 @@ http://localhost:3000/cli/auth?request=…&scope=platform
|
||||
Waiting for approval…
|
||||
|
||||
✓ Logged in. Key stored in /Users/you/.sim/credentials
|
||||
Personal key, defaulting to ws_local. Override per command with --workspace.
|
||||
Personal key, defaulting to a3c81b02-5f4d-4e19-9d7a-2b6c1e084f55.
|
||||
Override per command with --workspace.
|
||||
```
|
||||
|
||||
The approval page is where you pick the workspace — the terminal has no key yet,
|
||||
@@ -117,6 +128,35 @@ the key can access.
|
||||
`sim login --workspace <id>` preselects a workspace in the picker, and an
|
||||
existing profile's workspace preselects itself on re-login.
|
||||
|
||||
### Personal and workspace keys
|
||||
|
||||
Some operations accept only a **personal** API key: secrets, knowledge chunks,
|
||||
knowledge connectors, knowledge tag writes, `chat`, `audit-logs`, and most
|
||||
workflow deployment writes. A workspace-scoped key gets a `FORBIDDEN` response
|
||||
on those, under one of two codes:
|
||||
|
||||
- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — the workspace-scoped operations,
|
||||
which is most of the list: secrets, knowledge, `chat`, and the deployment
|
||||
writes.
|
||||
- `PRINCIPAL_KIND_NOT_PERMITTED` — the organization-scoped ones, which never go
|
||||
through workspace authorization at all. `audit-logs` is the one in this list.
|
||||
|
||||
Match on either when you are branching on the refusal. `sim whoami` reports
|
||||
which kind you hold on its `Key type` row, and `sim meta status` reports the
|
||||
same thing as `personal` or `workspace` alongside the rest of the key's state:
|
||||
|
||||
```bash
|
||||
$ sim meta status --output json
|
||||
{
|
||||
"v2Enabled": true,
|
||||
"keyType": "personal",
|
||||
"expiresAt": null
|
||||
}
|
||||
```
|
||||
|
||||
`sim whoami --no-verify` stays offline and so cannot read the key type; it
|
||||
prints `not checked` instead.
|
||||
|
||||
`sim logout` removes the stored key. A shared workspace profile cannot remove
|
||||
its authentication profile's key; use `sim logout --all --profile <name>` to
|
||||
remove only the workspace profile. An authentication profile cannot be removed
|
||||
@@ -129,10 +169,14 @@ The commands below are the common ones. The complete reference — every group,
|
||||
subcommand, argument, and flag, generated from this package — is at
|
||||
[docs.sim.ai/cli/commands](https://docs.sim.ai/cli/commands).
|
||||
|
||||
Plural resource names are canonical, but every plural top-level resource group
|
||||
also accepts its singular form: for example, `sim table list`,
|
||||
`sim file get`, and `sim workflow get` are equivalent to their plural
|
||||
spellings.
|
||||
Plural resource names are canonical. Most plural top-level resource groups also
|
||||
accept their singular form — `sim table list`, `sim file get`, and
|
||||
`sim workflow get` are equivalent to their plural spellings. The groups with a
|
||||
singular alias are `audit-logs`, `credentials`, `custom-tools`, `files`, `logs`,
|
||||
`mcp-servers`, `secrets`, `skills`, `tables`, `workflows`, and `workspaces`;
|
||||
`blocks`, `tools`, `chat-deployments`, `connector-types`, and
|
||||
`workflow-mcp-servers` are plural-only. `sim --help` lists each group with the
|
||||
aliases it actually accepts.
|
||||
|
||||
`knowledge` also accepts the shorter `kb` alias.
|
||||
|
||||
@@ -152,8 +196,8 @@ sim workflows runs resume <runId> --workflow <workflowId> --context <contextId>
|
||||
sim logs list [--level error] [--workflow <id>…] [--trigger <name>…] [--start-date <date>]
|
||||
sim logs get <runId>
|
||||
|
||||
sim audit-logs list --organization <organizationId> [--all-workspaces]
|
||||
sim audit-logs get <id> --organization <organizationId>
|
||||
sim audit-logs list [--organization <organizationId>] [--all-workspaces]
|
||||
sim audit-logs get <id> [--organization <organizationId>]
|
||||
|
||||
sim workspaces list
|
||||
sim workspaces get
|
||||
@@ -204,7 +248,8 @@ sim billing logs [--period 7d] [--source sim-chat] [--limit <n>] [--all-workspac
|
||||
```
|
||||
|
||||
The `sim-chat` billing source combines Copilot and workspace chat usage.
|
||||
Organization audit logs require a personal API key. Commands with
|
||||
Organization audit logs require a personal API key; `--organization` defaults
|
||||
to your only organization and is needed only when you belong to more than one. Commands with
|
||||
`--all-workspaces` otherwise default to the workspace in the active profile.
|
||||
|
||||
`workflows runs get` is the lightweight status and polling resource.
|
||||
@@ -255,14 +300,37 @@ Primitive lists take space-separated values. Prefix a path with `@` to read
|
||||
one value per line, or use `@-` to read the list from stdin.
|
||||
|
||||
```bash
|
||||
sim files mv --file-ids file_1 file_2 --to Archive
|
||||
sim files mv --file-ids wf_3Kq9tVbN2xLpR7sWmZ4dY wf_8Jd5cHy1QnT6vXbA0rEuP --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_3Kq9tVbN2xLpR7sWmZ4dY\nwf_8Jd5cHy1QnT6vXbA0rEuP\n' | sim files mv --file-ids @- --to Archive
|
||||
```
|
||||
|
||||
File IDs carry a `wf_` prefix, as above. Workflow, knowledge-base, and
|
||||
workspace IDs are bare UUIDs, and table IDs are `tbl_`-prefixed — the `wf_`
|
||||
prefix belongs to files, not workflows.
|
||||
|
||||
Arrays of objects remain JSON inputs because they cannot be represented as a
|
||||
flat list without losing structure.
|
||||
|
||||
### Secret values
|
||||
|
||||
`sim secrets set` takes the same `@` convention for its `--value`. Passing a
|
||||
secret inline exposes it to shell history and to anything reading the process
|
||||
list, so prefer a file or stdin; the contents are sent verbatim, with no
|
||||
trimming. A value that genuinely begins with `@` is written `@@`, and only the
|
||||
leading `@` is dropped. Omit `--value` entirely and the terminal prompts for it
|
||||
without echoing.
|
||||
|
||||
```bash
|
||||
sim secrets set STRIPE_KEY --scope workspace --value @stripe.key
|
||||
op read op://vault/stripe/key | sim secrets set STRIPE_KEY --scope workspace --value @-
|
||||
sim secrets set MENTION --scope workspace --value @@channel # the literal @channel
|
||||
```
|
||||
|
||||
`--unredacted` marks a workspace secret whose value may appear in run logs and
|
||||
model-visible content; `--no-unredacted` restores redaction. Omit both and the
|
||||
secret keeps whatever it had. Both apply only to `--scope workspace`.
|
||||
|
||||
### Filtering table rows
|
||||
|
||||
`--filter` takes the same predicate tree the API uses — `all` (AND) or `any`
|
||||
@@ -311,9 +379,10 @@ sim --output json logs list --level error | jq -r '.[].runId'
|
||||
sim logs list --level error --output json | jq -r '.[].runId'
|
||||
SIM_OUTPUT=yaml sim logs list --level error > logs.yaml
|
||||
|
||||
SIM_OUTPUT=text sim files list | while IFS=$'\t' read -r id name size type uploaded; do
|
||||
echo "$id $name"
|
||||
done
|
||||
SIM_OUTPUT=text sim files list |
|
||||
while IFS=$'\t' read -r id name folder size type uploaded_by uploaded; do
|
||||
echo "$id $name"
|
||||
done
|
||||
```
|
||||
|
||||
An absent value is an em-dash in `table` and an **empty field** in `text`, so
|
||||
|
||||
@@ -87,6 +87,49 @@ describe('pollForKey', () => {
|
||||
expect(result.apiKey).toBe('sim_abc')
|
||||
})
|
||||
|
||||
it('says an unreachable endpoint is not answering, then keeps polling', async () => {
|
||||
// A typo'd endpoint used to be indistinguishable from a slow approval:
|
||||
// every transport failure was swallowed and retried silently, so after 40
|
||||
// seconds against a closed port the only output was still "Waiting for
|
||||
// approval…" — for the full 15-minute timeout.
|
||||
let call = 0
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
if (call++ < 4) throw new Error('getaddrinfo ENOTFOUND sim.tset')
|
||||
return reply(200, COMPLETE)
|
||||
})
|
||||
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => {
|
||||
fn()
|
||||
return 0 as unknown as NodeJS.Timeout
|
||||
}) as never)
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
|
||||
const result = await pollForKey(ENDPOINT, createAuthRequest())
|
||||
|
||||
// Reported, not aborted: the retry policy and the 15-minute deadline stand.
|
||||
expect(result.apiKey).toBe('sim_abc')
|
||||
expect(stderr).toHaveBeenCalledOnce()
|
||||
const warning = String(stderr.mock.calls[0][0])
|
||||
expect(warning).toContain(ENDPOINT)
|
||||
expect(warning).toContain('getaddrinfo ENOTFOUND sim.tset')
|
||||
})
|
||||
|
||||
it('stays quiet about a single blip, which the retry policy exists to absorb', async () => {
|
||||
let call = 0
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
if (call++ === 0) throw new Error('ECONNRESET')
|
||||
return reply(200, COMPLETE)
|
||||
})
|
||||
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => {
|
||||
fn()
|
||||
return 0 as unknown as NodeJS.Timeout
|
||||
}) as never)
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
|
||||
await pollForKey(ENDPOINT, createAuthRequest())
|
||||
|
||||
expect(stderr).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives up on a deliberate refusal rather than spinning to the timeout', async () => {
|
||||
await expect(
|
||||
poll([() => reply(400, { error: 'verifier must be a base64url secret' })])
|
||||
|
||||
@@ -43,6 +43,17 @@ const POLL_PATH = '/api/cli/auth/poll'
|
||||
*/
|
||||
const RETRYABLE_POLL_STATUSES = new Set([409, 429, 500, 502, 503, 504])
|
||||
|
||||
/**
|
||||
* Consecutive transport failures before the poll says so on stderr.
|
||||
*
|
||||
* Three at the 2s interval is about six seconds: past any single DNS, TLS, or
|
||||
* connection-reset blip, and far short of the point where someone starts
|
||||
* wondering whether their approval registered. An endpoint that nothing is
|
||||
* listening on fails every attempt, so it trips this within seconds instead of
|
||||
* looking exactly like a slow approval for the full 15 minutes.
|
||||
*/
|
||||
const TRANSPORT_FAILURES_BEFORE_WARNING = 3
|
||||
|
||||
export type CliAuthScope = 'copilot' | 'platform'
|
||||
|
||||
export interface AuthRequest {
|
||||
@@ -156,6 +167,12 @@ function toRedirectError(endpoint: string, response: Response): SimApiError {
|
||||
* wait are all recoverable, and the approval sits in Redis with its own TTL. A
|
||||
* non-2xx *response*, by contrast, is the server refusing on purpose and is
|
||||
* surfaced immediately.
|
||||
*
|
||||
* Retried is not the same as unreported: once
|
||||
* {@link TRANSPORT_FAILURES_BEFORE_WARNING} attempts in a row fail to reach the
|
||||
* endpoint at all, the reason is printed once to stderr and the poll carries on.
|
||||
* Without it a typo'd endpoint was indistinguishable from a slow approval for
|
||||
* the entire 15-minute timeout.
|
||||
*/
|
||||
export async function pollForKey(
|
||||
endpoint: string,
|
||||
@@ -163,6 +180,8 @@ export async function pollForKey(
|
||||
signal?: AbortSignal
|
||||
): Promise<MintedKey> {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||
let consecutiveTransportFailures = 0
|
||||
let warnedAboutTransport = false
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) throw new SimApiError('Login cancelled.', 0)
|
||||
@@ -180,11 +199,23 @@ export async function pollForKey(
|
||||
signal,
|
||||
redirect: 'manual',
|
||||
})
|
||||
} catch {
|
||||
} catch (cause) {
|
||||
response = null
|
||||
consecutiveTransportFailures++
|
||||
if (
|
||||
!warnedAboutTransport &&
|
||||
consecutiveTransportFailures >= TRANSPORT_FAILURES_BEFORE_WARNING
|
||||
) {
|
||||
warnedAboutTransport = true
|
||||
process.stderr.write(
|
||||
`Still waiting: ${endpoint} is not answering the login poll (${(cause as Error).message}). Check the endpoint; retrying until you approve or the login times out.\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (response) {
|
||||
consecutiveTransportFailures = 0
|
||||
|
||||
if (REDIRECT_STATUSES.has(response.status)) throw toRedirectError(endpoint, response)
|
||||
|
||||
const raw = await response.text()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user