diff --git a/.claude/rules/global.md b/.claude/rules/global.md index 3a222b935d5..d0ce371a49c 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -51,7 +51,12 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations: - `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))` - `omit(obj, keys)` from `@sim/utils/object` — remove keys from object - `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))` +- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)` +- `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array. Never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts +- `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload. Never declare a local one-liner that is byte-identical to one of these (`asString`, `getString`, `nullableString`, …). Keep a local helper that differs: one returning `undefined` rather than `null` changes the wire shape, and one adding `Number.isFinite` or a string parse is a stricter check these deliberately omit - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis +- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` +- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there - `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter - `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds diff --git a/.claude/rules/sim-imports.md b/.claude/rules/sim-imports.md index 3aeafa0dd2e..6757ecc1d43 100644 --- a/.claude/rules/sim-imports.md +++ b/.claude/rules/sim-imports.md @@ -13,8 +13,8 @@ paths: ```typescript // ✓ Good +import { Chip } from '@sim/emcn' import { useWorkflowStore } from '@/stores/workflows/store' -import { Button } from '@/components/ui/button' // ✗ Bad import { useWorkflowStore } from '../../../stores/workflows/store' diff --git a/.cursor/rules/global.mdc b/.cursor/rules/global.mdc index 1bf193b00ec..4862beed1db 100644 --- a/.cursor/rules/global.mdc +++ b/.cursor/rules/global.mdc @@ -54,7 +54,12 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations: - `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))` - `omit(obj, keys)` from `@sim/utils/object` — remove keys from object - `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))` +- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)` +- `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array. Never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts +- `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload. Never declare a local one-liner that is byte-identical to one of these (`asString`, `getString`, `nullableString`, …). Keep a local helper that differs: one returning `undefined` rather than `null` changes the wire shape, and one adding `Number.isFinite` or a string parse is a stricter check these deliberately omit - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis +- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` +- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there - `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter - `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds diff --git a/.cursor/rules/sim-imports.mdc b/.cursor/rules/sim-imports.mdc index 19da378bccf..95d111c7747 100644 --- a/.cursor/rules/sim-imports.mdc +++ b/.cursor/rules/sim-imports.mdc @@ -13,8 +13,8 @@ globs: ["apps/sim/**/*.ts","apps/sim/**/*.tsx"] ```typescript // ✓ Good +import { Chip } from '@sim/emcn' import { useWorkflowStore } from '@/stores/workflows/store' -import { Button } from '@/components/ui/button' // ✗ Bad import { useWorkflowStore } from '../../../stores/workflows/store' diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index a890cd09d0b..08d1305a6c0 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -149,6 +149,18 @@ jobs: KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim run: bunx vitest run --mode integration lib/workspace-files/search/dispatcher.integration.ts + - name: Verify workspace file version history on PostgreSQL 17 + working-directory: apps/sim + env: + KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + run: bunx vitest run --mode integration lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts + + - name: Verify file search trigram estimate against pg_trgm + working-directory: apps/sim + env: + KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + run: bunx vitest run --mode integration lib/workspace-files/search/index-plan.integration.ts + - name: Verify SCIM and administration over real HTTP working-directory: apps/sim env: @@ -219,6 +231,8 @@ jobs: bunx vitest run lib/table/rows/secret-provenance.postgres.test.ts lib/memory/message-provenance.postgres.test.ts + lib/memory/conversation-store.postgres.test.ts + lib/memory/summary-store.postgres.test.ts executor/handlers/agent/memory-harness.postgres.test.ts - name: Verify Search vector projection upgrade in PostgreSQL @@ -229,6 +243,7 @@ jobs: bunx vitest run script-migrations/0016_backfill_search_vectors.postgres.test.ts script-migrations/0018_repair_workspace_file_content_revision.postgres.test.ts + script-migrations/0019_tin_keyword_projection.postgres.test.ts - name: Verify Search progress, pagination, and outbox scheduling in PostgreSQL working-directory: apps/sim @@ -246,6 +261,8 @@ jobs: lib/knowledge/__integration__/stored-document-recovery.integration.ts lib/knowledge/__integration__/connector-partition-work.integration.ts lib/knowledge/__integration__/listing-continuation.integration.ts + lib/knowledge/__integration__/member-scope-renewal.integration.ts + lib/knowledge/__integration__/slack-empty-threads.integration.ts lib/knowledge/__integration__/kb-block-search.integration.ts lib/core/outbox/service.integration.ts lib/knowledge/__integration__/connector-upload.integration.ts diff --git a/CLAUDE.md b/CLAUDE.md index 09dabbc5d78..29ca6ff4ad1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,12 @@ You are a professional software engineer. All code must follow best practices: a - `getErrorMessage(e, fallback?)` from `@sim/utils/errors` — extract message string from unknown caught value; never write `e instanceof Error ? e.message : 'fallback'` - `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))` - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))` + - `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)` + - `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array; never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts + - `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload; never declare a local one-liner byte-identical to one of these. Keep a local helper that differs: `undefined` instead of `null` changes the wire shape, and a `Number.isFinite` or string-parse variant is a stricter check these omit - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis + - `escapeRegExp(value)` from `@sim/utils/string` — never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` + - `compareStrings(left, right)` from `@sim/utils/string` — code-unit ordering for hashes, fingerprints, and cross-process comparisons; never `localeCompare` there - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline - **Deployment flags in the browser**: client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout instead. Server code keeps reading `env-flags` - **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx` diff --git a/README.md b/README.md index f25af677768..5a186532d0c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Ask DeepWiki - Set Up with Cursor + Set Up with Cursor

diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 78038098bbc..50c3d64efd2 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -27,7 +27,7 @@ import { type TerminalToolArgs, } from '@sim/terminal-protocol' import { getErrorMessage } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' +import { isRecordLike, toRecord } from '@sim/utils/object' import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' import { clipboard, ipcMain, shell } from 'electron' @@ -856,7 +856,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ) { return { ok: false, error: `Unknown browser tool: ${String(tool)}` } } - const toolParams = isRecordLike(params) ? params : {} + const toolParams = toRecord(params) return executeTool( scope, tool, @@ -1532,7 +1532,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ) { return { ok: false, error: `Unknown terminal tool: ${String(tool)}` } } - const call = isRecordLike(params) ? params : {} + const call = toRecord(params) if (!isTerminalOperation(call.operation)) { return { ok: false, error: `Unknown terminal operation: ${String(call.operation)}` } } diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts index 573f5a8cb88..edf226d4592 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -19,6 +19,7 @@ import { } from '@sim/desktop-bridge/local-filesystem-limits' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' +import { escapeRegExp } from '@sim/utils/string' import { app, dialog, shell } from 'electron' import micromatch from 'micromatch' import safeRegex from 'safe-regex2' @@ -1114,7 +1115,7 @@ export class LocalFilesystemService { regex = rawPattern !== undefined ? new RegExp(expression, ignoreCase ? 'i' : '') - : new RegExp(expression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ignoreCase ? 'i' : '') + : new RegExp(escapeRegExp(expression), ignoreCase ? 'i' : '') } catch { // An empty result set would tell the model the string appears nowhere in // the user's files — a factual claim it will act on, when in truth the diff --git a/apps/docs/content/docs/academy/agents/memory.mdx b/apps/docs/content/docs/academy/agents/memory.mdx index 48a11d441d7..f8908ae383e 100644 --- a/apps/docs/content/docs/academy/agents/memory.mdx +++ b/apps/docs/content/docs/academy/agents/memory.mdx @@ -16,7 +16,9 @@ import { AV_MEMORY_WORKFLOW } from '@/components/workflow-preview/academy-video-

-By default, an agent keeps nothing between runs: every conversation starts completely fresh. The Memory setting changes that: choose Conversation, give it a conversation ID, and everything said under that key is kept and loaded back before the model runs. +By default, an agent keeps nothing between runs: every conversation starts completely fresh. The Memory setting changes that: choose Conversation, give it a conversation ID, and the agent saves the conversation under that key. Later runs load history according to the memory mode and the model's context limits. + +When durable tool history is enabled for your workspace, that history also includes completed tool calls and their results or errors. The agent can remember what it did, such as looking up an order, even when the run failed before giving its final answer. See the [Agent block's memory settings](/workflows/blocks/agent#memory) for tool-history and retry behavior. Uploaded attachments stay linked to the message that included them. Memory stores file references; each later run reads the accessible files again and prepares them for the selected provider. Attachments follow the selected memory window and the source file's storage retention. A replay can include up to 20 attachment references; use a smaller memory window for longer file-heavy conversations. Files omitted by older versions of memory need to be attached again. @@ -32,7 +34,7 @@ Uploaded attachments stay linked to the message that included them. Memory store }, { title: 'Recall happens before the model runs', - body: 'On the next run, everything stored under the key is loaded back into the conversation first: so the agent answers like no time has passed.', + body: 'On the next run, selected history under the key is loaded into the conversation before the model answers.', }, { title: 'Keys are separate threads', @@ -65,20 +67,22 @@ Here is the agent from the video with Memory set on the block: ## The same agent, with and without memory -The video runs the same agent twice, side by side: once with no memory and once with the conversation ID. The same follow-up question arrives in both. Without the key, the agent starts from zero and has to ask for everything again; with it, everything stored under the key was loaded back before the model saw the new message, and the answer picks up exactly where yesterday stopped. +The video runs the same agent twice, side by side: once with no memory and once with the conversation ID. The same follow-up question arrives in both. Without the key, the agent starts from zero and has to ask for everything again; with it, the earlier conversation supplies the context needed to answer the follow-up. ## When conversations grow Memory can also be a sliding window, keeping the most recent messages, or the most recent tokens, while the oldest quietly fall away. The stored transcript keeps every turn; the window controls how much of it rides into the model on each run. +Tool exchanges stay together when history is selected. They do not each count as a message in a message window, but their contents still use input tokens. Large results may appear as previews, and history is not automatically summarized. A token window gives more direct control over recalled context than a message count; neither setting caps the total cost of a run that makes further model or tool calls. + ## When to use memory Enable memory when a follow-up needs earlier conversation context, such as a support ticket or sales conversation. Keep classification and extraction stateless when each input contains everything the task needs. diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index e952bf299b6..db168e20bfe 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -174,6 +174,164 @@ sim files delete [options] +## Permanently delete a previous version of a file + +```bash +sim files versions delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Show the metadata of one version of a file + +```bash +sim files versions describe +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +## List the recorded versions of a file + +```bash +sim files versions list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `version`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +## Read the text content of one version of a file + +```bash +sim files versions read [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | +| `--offset ` | No | First line to return, 1-based. Absent starts at the first line. | +| `--limit ` | No | How many lines to return from `offset`. Absent reads to the end. | + + + +## Make a previous version of a file current again + +```bash +sim files versions revert [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--expected-current-version ` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content. | +| `--expected-revision ` | No | Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version. | + + + +## Download the content of one version of a file + +```bash +sim files versions download [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + ## Apply one exact or anchor-based edit to a text file ```bash @@ -197,6 +355,7 @@ sim files edit [options] | Option | Required | Description | | --- | --- | --- | | `--edit ` | Yes | One edit object: {"mode":"search_replace","search":"old","content":"new","replaceAll":false}, {"mode":"replace_between","beforeAnchor":"start line","afterAnchor":"end line","content":"new"}, {"mode":"insert_after","anchor":"line","content":"new"}, or {"mode":"delete_between","startAnchor":"first line deleted","endAnchor":"ending line kept"}. Anchored modes also accept occurrence starting at 1 (JSON, or @path / @- to read a file or stdin). | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | @@ -456,6 +615,7 @@ sim files set-content [options] | --- | --- | --- | | `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | | `--encoding ` | No | Content encoding. Accepted values: `utf-8`, `base64`. | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 0a92390a38c..483d1677144 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -909,6 +909,176 @@ sim files delete [options] +### sim files versions delete + +Permanently delete a previous version of a file + +```bash +sim files versions delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim files versions describe + +Show the metadata of one version of a file + +```bash +sim files versions describe +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +### sim files versions list + +List the recorded versions of a file + +```bash +sim files versions list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `version`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +### sim files versions read + +Read the text content of one version of a file + +```bash +sim files versions read [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | +| `--offset ` | No | First line to return, 1-based. Absent starts at the first line. | +| `--limit ` | No | How many lines to return from `offset`. Absent reads to the end. | + + + +### sim files versions revert + +Make a previous version of a file current again + +```bash +sim files versions revert [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--expected-current-version ` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content. | +| `--expected-revision ` | No | Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version. | + + + +### sim files versions download + +Download the content of one version of a file + +```bash +sim files versions download [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + ### sim files edit Apply one exact or anchor-based edit to a text file @@ -934,6 +1104,7 @@ sim files edit [options] | Option | Required | Description | | --- | --- | --- | | `--edit ` | Yes | One edit object: {"mode":"search_replace","search":"old","content":"new","replaceAll":false}, {"mode":"replace_between","beforeAnchor":"start line","afterAnchor":"end line","content":"new"}, {"mode":"insert_after","anchor":"line","content":"new"}, or {"mode":"delete_between","startAnchor":"first line deleted","endAnchor":"ending line kept"}. Anchored modes also accept occurrence starting at 1 (JSON, or @path / @- to read a file or stdin). | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | @@ -1213,6 +1384,7 @@ sim files set-content [options] | --- | --- | --- | | `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | | `--encoding ` | No | Content encoding. Accepted values: `utf-8`, `base64`. | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index cee24c8b1c2..0fc151aad4e 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -139,6 +139,7 @@ Create a new workspace file, either from text content or from an existing file. | `fileInput` | file | No | An existing file to store in the workspace, such as one produced by an earlier tool. Use this for anything that is not text — PDFs, images, audio, archives. Provide exactly one of content or fileInput. | | `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from the file extension, or taken from the stored file, if omitted. | | `overwrite` | boolean | No | Replace the contents of an existing file at the exact target path \(folder and name\) instead of creating a suffixed copy. Creates the file when that path does not exist yet. | +| `expectedRevision` | string | No | Refuse the write unless the file still holds the content this revision names, as returned by Get File or an earlier write. Use it so an edit computed from what you read cannot overwrite someone else’s change. | #### Output @@ -148,6 +149,8 @@ Create a new workspace file, either from text content or from an existing file. | `name` | string | File name | | `size` | number | File size in bytes | | `url` | string | URL to access the file | +| `version` | number | Version number of the content this write recorded | +| `revision` | string | Opaque token for the content this write produced. Pass it back as expectedRevision to make a later write conditional on nothing having changed since. | ### File Append @@ -171,6 +174,8 @@ Append content to an existing workspace file. The file must already exist. Conte | `name` | string | File name | | `size` | number | File size in bytes | | `url` | string | URL to access the file | +| `version` | number | Version number of the content this write recorded | +| `revision` | string | Opaque token for the content this write produced. Pass it back as expectedRevision to make a later write conditional on nothing having changed since. | ### Apply File Edit @@ -194,6 +199,7 @@ Apply one precise edit to an existing text file without rewriting it. Use search | `startAnchor` | string | No | For delete_between, the complete first line to delete. The start anchor is removed. | | `endAnchor` | string | No | For delete_between, the complete ending boundary line. The end anchor remains in the file. | | `occurrence` | number | No | For anchored edits, which matching anchor occurrence to use, starting at 1. Defaults to 1. | +| `expectedRevision` | string | No | Refuse the edit unless the file still holds the content this revision names, as returned by Get File or an earlier write. Use it so an edit computed from what you read cannot overwrite someone else’s change. | #### Output @@ -203,6 +209,8 @@ Apply one precise edit to an existing text file without rewriting it. Use search | `name` | string | File name | | `size` | number | File size in bytes | | `lineCount` | number | Lines in the file after the edit | +| `version` | number | Version number of the content this edit recorded | +| `revision` | string | Opaque token for the content this edit produced. Pass it back as expectedRevision to make a later write conditional on nothing having changed since. | ### File Compress diff --git a/apps/docs/content/docs/integrations/google_calendar.mdx b/apps/docs/content/docs/integrations/google_calendar.mdx index c0a7b8356ec..77b6fa90c50 100644 --- a/apps/docs/content/docs/integrations/google_calendar.mdx +++ b/apps/docs/content/docs/integrations/google_calendar.mdx @@ -311,6 +311,35 @@ Invite attendees to an existing Google Calendar event. Returns API-aligned field | `creator` | json | Event creator | | `organizer` | json | Event organizer | +### Google Calendar Respond to Invitation + +RSVP to a Google Calendar event (accept, decline, or tentative) as the connected account. Only your own response changes; other guests are left untouched. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | Google Calendar ID the invitation appears on \(e.g., primary or calendar@group.calendar.google.com\) | +| `eventId` | string | Yes | Google Calendar event ID to respond to. Use a recurring-event instance ID \(as returned by List Events or Get Recurring Instances\) to respond to a single occurrence; the series ID responds to every occurrence. | +| `responseStatus` | string | Yes | Your response: accepted, declined, or tentative | +| `comment` | string | No | Optional note to include with your response | +| `sendUpdates` | string | No | Who to notify about your response: all, externalOnly, or none | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Event ID | +| `htmlLink` | string | Event link | +| `status` | string | Event status | +| `summary` | string | Event title | +| `start` | json | Event start | +| `end` | json | Event end | +| `responseStatus` | string | Your confirmed response \(accepted, declined, or tentative\) | +| `comment` | string | Your response comment | +| `attendees` | json | Event attendees | +| `organizer` | json | Event organizer | + ### Google Calendar Free/Busy Query free/busy information for one or more Google Calendars. Returns API-aligned fields only. diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index 8aade70fc02..dd74389e69e 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -78,7 +78,7 @@ Each connector has source-specific fields that control what gets synced. Example - **Notion** — sync an entire workspace, a specific database, or a single page tree - **GitHub** — specify a repository, branch, and optional file extension filter -- **Confluence** — enter your Atlassian domain and choose spaces, or **All** for all spaces accessible at each sync. Optionally filter by content type or label. PDF and Word (`.docx`, Word 97–2003 `.doc`) attachments on matching pages and blog posts are included as separate documents. +- **Confluence** — enter your Atlassian domain and choose spaces, or **All** for all spaces accessible at each sync. Optionally filter by content type or label. PDF, Word (`.docx`, Word 97–2003 `.doc`), Excel (`.xlsx`), and PowerPoint (`.pptx`) attachments on matching pages and blog posts are included as separate documents. - **Azure DevOps** — choose what to sync (wiki pages, work items, repository files, or all), with optional work item type/state filters, a custom WIQL query, and repository/branch/path filters - **Amazon S3** — point at a bucket with an optional key prefix and a customizable file extension allowlist; S3-compatible stores (Cloudflare R2, MinIO) are supported via a custom endpoint - **YouTube** — sync a channel (by `@handle` or ID) or playlist, with an optional published-after date filter and the option to exclude Shorts @@ -88,7 +88,7 @@ Each connector has source-specific fields that control what gets synced. Example Configuration is validated on save — if a repository doesn't exist or a domain is unreachable, you'll see an error immediately. -Confluence attachment indexing requires `read:attachment:confluence`. For a service account, include it when creating the scoped API token; see the [Confluence scope list](/search/confluence#using-a-service-account). Attachments are checked even when the parent page has not changed. Files over 100 MB appear as skipped; convert Word 6/95 files to `.docx` before attaching them. +Confluence attachment indexing requires `read:attachment:confluence`. For a service account, include it when creating the scoped API token; see the [Confluence scope list](/search/confluence#using-a-service-account). Attachments are checked even when the parent page has not changed. Files over 100 MB appear as skipped; convert Word 6/95 files to `.docx`, and `.xls` and `.ppt` files to `.xlsx` and `.pptx`, before attaching them. Spaces that were already connected pick up newly supported formats on their next sync. diff --git a/apps/docs/content/docs/platform/enterprise/data-retention.mdx b/apps/docs/content/docs/platform/enterprise/data-retention.mdx index aa3fbe8b578..4b57215fced 100644 --- a/apps/docs/content/docs/platform/enterprise/data-retention.mdx +++ b/apps/docs/content/docs/platform/enterprise/data-retention.mdx @@ -71,6 +71,12 @@ Controls how long **Chat data** is kept, including: - Run checkpoints and async tool calls - Inbox tasks +### Previous file versions + +Every change to a file's content keeps the previous content as a version you can read or revert to through the API, CLI, or MCP server. This setting controls how long a version is kept after a newer one replaces it. The newest ten versions of each file are always kept, whatever their age. + +Without a setting, previous versions are kept until a file reaches 500 of them. The setting isn't on the settings page yet: set `fileVersionRetentionHours` through the data retention API, either for the organization or in a workspace override. + Each setting is independent. You can configure a short log retention period alongside a long soft deletion cleanup period, or any combination that fits your compliance requirements. --- @@ -186,16 +192,17 @@ Once enabled, retention settings are configurable through **Settings → Organiz ### Scheduling the deletion pass -`DATA_RETENTION_ENABLED` permits deletion; it does not perform it. Deletion runs when a scheduled request reaches one of three endpoints, each authenticated with a bearer token equal to `CRON_SECRET`: +`DATA_RETENTION_ENABLED` permits deletion; it does not perform it. Deletion runs when a scheduled request reaches one of four endpoints, each authenticated with a bearer token equal to `CRON_SECRET`: | Category | Endpoint | |----------|----------| | Execution and job logs | `GET /api/logs/cleanup` | | Soft-deleted resources | `GET /api/cron/cleanup-soft-deletes` | | Chats and Chat runs | `GET /api/cron/cleanup-tasks` | +| Previous file versions | `GET /api/cron/cleanup-file-versions` | -Neither shipped deployment schedules these three endpoints — not the Helm chart, not Docker Compose's `cron` service. An operator who sets `DATA_RETENTION_ENABLED=true` alone still deletes nothing. Add them to `cronjobs.jobs`, or call them daily from an external scheduler. +Neither shipped deployment schedules these four endpoints — not the Helm chart, not Docker Compose's `cron` service. An operator who sets `DATA_RETENTION_ENABLED=true` alone still deletes nothing. Add them to `cronjobs.jobs`, or call them daily from an external scheduler. ```bash diff --git a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx index c623ecd169c..7de297e137a 100644 --- a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx +++ b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx @@ -86,10 +86,11 @@ Persist that value as `CRON_SECRET` on the app **and** on whatever calls these e | Retention — logs | `GET /api/logs/cleanup` | Daily | **No** — schedule it yourself | | Retention — soft deletes | `GET /api/cron/cleanup-soft-deletes` | Daily | **No** — schedule it yourself | | Retention — Chat tasks | `GET /api/cron/cleanup-tasks` | Daily | **No** — schedule it yourself | +| Retention — file versions | `GET /api/cron/cleanup-file-versions` | Daily | **No** — schedule it yourself | | OAuth token cleanup | `GET /api/cron/cleanup-oauth-tokens` | Hourly | Yes — Helm and Docker Compose both call it | - Both shipped deployments schedule the data-drain dispatcher and OAuth token cleanup, but **not** the three configurable data-retention endpoints. Setting `DATA_RETENTION_ENABLED=true` alone deletes no retained product data — those windows are evaluated only when one of the three endpoints is called. Add them to `cronjobs.jobs` yourself, or drive them from an external scheduler. + Both shipped deployments schedule the data-drain dispatcher and OAuth token cleanup, but **not** the four configurable data-retention endpoints. Setting `DATA_RETENTION_ENABLED=true` alone deletes no retained product data — those windows are evaluated only when one of the four endpoints is called. Add them to `cronjobs.jobs` yourself, or drive them from an external scheduler. OAuth token cleanup runs independently of sign-in activity, removing expired and revoked credentials. See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim) for provider configuration. diff --git a/apps/docs/content/docs/platform/self-hosting/docker.mdx b/apps/docs/content/docs/platform/self-hosting/docker.mdx index 9fa470a7bb1..56cfe3a5027 100644 --- a/apps/docs/content/docs/platform/self-hosting/docker.mdx +++ b/apps/docs/content/docs/platform/self-hosting/docker.mdx @@ -43,13 +43,15 @@ EOF Do not set `DATABASE_URL` or `BETTER_AUTH_URL` in `.env` — `docker-compose.prod.yml` composes both on the service definition, and a value set here is ignored. Change `POSTGRES_*` and `NEXT_PUBLIC_APP_URL` instead. + + Because `DATABASE_URL` is composed from them, keep `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` URL-safe — letters, digits, and `.` `_` `~` `-`. They are inserted into the connection string as written, so a value containing `@`, `/`, `?`, `#`, `%`, or a space can initialize the database while leaving the app and migrations unable to connect. `openssl rand -hex` output is always safe. Save `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` somewhere outside this server. `ENCRYPTION_KEY` encrypts workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets; `API_ENCRYPTION_KEY` encrypts user-generated Sim API keys. Neither can be regenerated — a database restore paired with a different key leaves the data it protected permanently unreadable. -The compose file refuses to start if `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, or `INTERNAL_API_SECRET` is missing, rather than booting with empty values. `CRON_SECRET` is treated more gently: without it the `cron` service prints what to set and exits, leaving the rest of the stack running — so upgrading from a compose file that predates the scheduler still works. +The compose file refuses to start if `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `INTERNAL_API_SECRET`, or `POSTGRES_PASSWORD` is missing, rather than booting with empty or well-known values. Postgres applies `POSTGRES_PASSWORD` only when it first creates the database volume — see [Postgres on Compose](/platform/self-hosting/security#postgres-on-compose) before changing it on an existing install. `CRON_SECRET` is treated more gently: without it the `cron` service prints what to set and exits, leaving the rest of the stack running — so upgrading from a compose file that predates the scheduler still works. Images track `latest` unless you pin them. For production, see [Upgrades](/platform/self-hosting/upgrades). @@ -65,7 +67,7 @@ Six services start: |---|---|---| | `simstudio` | 3000 | Main application (8 GB memory limit) | | `realtime` | 3002 | WebSocket server (1 GB memory limit) | -| `db` | 5432 | PostgreSQL 17 with pgvector | +| `db` | internal | PostgreSQL 17 with pgvector — not published to the host | | `redis` | internal | Pub/sub and shared cache — not published to the host | | `cron` | — | Runs the [background jobs](/platform/self-hosting/background-jobs) on a schedule | | `migrations` | — | Applies schema migrations once, then exits | @@ -206,5 +208,5 @@ npx sim-setup update { question: "Do scheduled workflows work on Docker Compose?", answer: "Yes. The cron service runs the same jobs the Helm chart schedules as Kubernetes CronJobs, using the schedules in docker/crontab. It needs CRON_SECRET — without it the service prints what to set and exits, and the rest of the stack keeps running."}, { question: "Why is there a Redis container?", answer: "Redis backs pub/sub for live Chat task status and table events, plus shared caches. Pub/sub has no fallback that works across processes, so live status would not stream without it. The port is deliberately not published so it cannot collide with a local Redis."}, { question: "How do I back up and restore the database?", answer: "Back up with: docker compose -f docker-compose.prod.yml exec -T db pg_dump -U postgres simstudio > backup.sql. The -T matters — without it exec allocates a TTY and corrupts the redirected dump. Restore with: docker compose -f docker-compose.prod.yml exec -T db psql -U postgres simstudio < backup.sql. The database data is persisted in a Docker volume named postgres_data."}, - { question: "Can I customize the PostgreSQL credentials?", answer: "Yes. The docker-compose.prod.yml uses environment variable defaults: POSTGRES_USER (default: postgres), POSTGRES_PASSWORD (default: postgres), POSTGRES_DB (default: simstudio), and POSTGRES_PORT (default: 5432). Set these in your .env file to override them." }, + { question: "Can I customize the PostgreSQL credentials?", answer: "Yes. Set POSTGRES_USER (default: postgres) and POSTGRES_DB (default: simstudio) in .env before the first start. POSTGRES_PASSWORD has no default — the compose file will not start without it. Postgres applies all three only when it creates the database volume, so changing them later does not change the existing database; to rotate the password, see Postgres on Compose in the security guide." }, ]} /> diff --git a/apps/docs/content/docs/platform/self-hosting/security.mdx b/apps/docs/content/docs/platform/self-hosting/security.mdx index 1bd8b0fb7a4..a60da61710a 100644 --- a/apps/docs/content/docs/platform/self-hosting/security.mdx +++ b/apps/docs/content/docs/platform/self-hosting/security.mdx @@ -271,19 +271,29 @@ networkPolicy: The service bundles ~2.2 GB of spaCy models, so first start takes around three minutes and it needs at least 4 GB of memory. -## The shipped Compose file publishes Postgres +## Postgres on Compose - - `docker-compose.prod.yml` maps the database to the host: `${POSTGRES_PORT:-5432}:5432`, with `POSTGRES_USER` and `POSTGRES_PASSWORD` both defaulting to `postgres`. A plain `docker compose up -d` against that file, on a machine with a public interface, therefore exposes an open Postgres on 5432 with credentials anyone can guess. The local and Ollama stacks map the database the same way, so apply the fix to whichever file started your install. +The Compose files do not publish the `db` service to the host: `simstudio`, `realtime`, and `migrations` reach it over the Compose network as `db:5432`, and nothing outside the stack can. `docker-compose.prod.yml` also refuses to start without `POSTGRES_PASSWORD`, the same way it refuses to start without `BETTER_AUTH_SECRET`. - The [Docker guide](/platform/self-hosting/docker#1-configure-environment) tells you to generate `POSTGRES_PASSWORD` before the first start — do that, and additionally close the port: + + Installs created from an earlier Compose file published the database on every interface of the host (`5432:5432`), and a `POSTGRES_PASSWORD` left unset fell back to `postgres`. A Docker `ports:` mapping writes its own iptables rules, so a host firewall that looks like it blocks 5432 usually does not. Update the Compose file, then check what your database was created with: - - **Do not need host access.** Delete the `ports:` block from the `db` service. Every other service reaches it over the Compose network by name. - - **Need host access.** Bind it to loopback only — `127.0.0.1:${POSTGRES_PORT:-5432}:5432` — and reach it over an SSH tunnel. + - **You set `POSTGRES_PASSWORD` before the first start.** Nothing else to do — updating the file closes the port. + - **You never set it.** Postgres applies `POSTGRES_PASSWORD` only when it creates the data volume, so the database still uses `postgres`. Set `POSTGRES_PASSWORD=postgres` in `.env` so the stack starts, then rotate it: run `docker compose -f docker-compose.prod.yml exec db psql -U postgres -c "ALTER ROLE CURRENT_USER PASSWORD ''"` (with your `POSTGRES_USER` in place of `postgres` if you set one), set `POSTGRES_PASSWORD` to the same value, and run `docker compose -f docker-compose.prod.yml up -d`. Setting a new value in `.env` alone does not change the database's password and locks the app out. - A Docker `ports:` mapping writes its own iptables rules, so a host firewall that looks like it blocks 5432 usually does not. + `npx sim-setup start` and `npx sim-setup update` write the right value for you and print the rotation steps. +To reach the database from the host — `psql`, a desktop client, a backup job — use `docker compose -f docker-compose.prod.yml exec db psql -U postgres simstudio`, or pass a second Compose file that publishes it on loopback only and connect over an SSH tunnel: + +```yaml +# db-port.yml — docker compose -f docker-compose.prod.yml -f db-port.yml up -d +services: + db: + ports: + - '127.0.0.1:5432:5432' +``` + ## Pre-launch checklist - All five secrets generated fresh, stored in a secret manager, and **`ENCRYPTION_KEY` backed up separately** @@ -297,7 +307,7 @@ The service bundles ~2.2 GB of spaCy models, so first start takes around three m - NetworkPolicy enabled and `ingressFrom` scoped to the ingress controller - Namespace labelled `pod-security.kubernetes.io/enforce=restricted` - Object storage buckets private, with CORS limited to your Sim origin -- Database reachable only from the deployment — on Compose, the `db` service's host `ports:` mapping removed or bound to `127.0.0.1` — with a generated `POSTGRES_PASSWORD` +- Database reachable only from the deployment — on Compose, no host `ports:` mapping on the `db` service, or one bound to `127.0.0.1` — with a generated `POSTGRES_PASSWORD` - TLS enforced (`sslMode: require`) on an externally managed database, or on the bundled one once you have configured it for TLS — the shipped Compose database does not enable it - Backups configured **and a restore rehearsed** - Sandbox strategy decided for user code diff --git a/apps/docs/content/docs/platform/self-hosting/upgrades.mdx b/apps/docs/content/docs/platform/self-hosting/upgrades.mdx index 23a59933898..1006325ae40 100644 --- a/apps/docs/content/docs/platform/self-hosting/upgrades.mdx +++ b/apps/docs/content/docs/platform/self-hosting/upgrades.mdx @@ -193,11 +193,13 @@ install uses: | Install | What it runs | |---|---| -| `docker-compose.prod.yml` | Refreshes its managed copy of the Compose file, then `docker compose pull` — the versions configured by `SIM_VERSION`, or `latest` when unset | +| `docker-compose.prod.yml` | Refreshes its managed copy of the Compose file, writes `POSTGRES_PASSWORD` to `.env` if it is missing, then `docker compose pull` — the versions configured by `SIM_VERSION`, or `latest` when unset | | `docker-compose.local.yml` | `docker compose build --pull` — rebuilds from source against refreshed base images, no pull of published images | Inspect the result with `npx sim-setup logs`, which targets whichever Compose file the install uses. +`docker-compose.prod.yml` requires `POSTGRES_PASSWORD`. If you manage the file yourself and Compose stops with `required variable POSTGRES_PASSWORD is missing a value`, your database was created with the password `postgres` — set exactly that in `.env`, not a new value, then see [Postgres on Compose](/platform/self-hosting/security#postgres-on-compose) to rotate it. + The CLI detects only those two files. An install started from `docker-compose.ollama.yml` is invisible to it: `update`, `logs`, and `status` report no install, or — in a source checkout that also carries per-application env files — report that checkout's dev install instead. Upgrade that stack directly: ```bash diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index aab0393b058..968b0b24e3f 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -7,7 +7,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Search pages, blog posts, and their PDF and Word attachments from selected Confluence Cloud spaces. A Sim organization admin enables Confluence; **each teammate connects their own account**. +Search pages, blog posts, and their PDF, Word, Excel, and PowerPoint attachments from selected Confluence Cloud spaces. A Sim organization admin enables Confluence; **each teammate connects their own account**. | Method | How it works | | --- | --- | @@ -125,9 +125,9 @@ See Atlassian's [account setup](https://support.atlassian.com/user-management/do | **Filter by Label** | Optional comma-separated labels; content can match any listed label. | | **Metadata tags** | Labels, version, and last-modified tags. | -Search manages the schedule and hides item limits. It indexes published/current content and each page's own text, including supported local callouts and code blocks. PDF, Word `.docx`, and Word 97–2003 `.doc` attachments on the selected pages and blog posts are indexed as separate documents with their parent content's permissions. Space, content-type, and label filters apply to the parent content. Attachment changes are checked on each sync, even when the parent text has not changed. +Search manages the schedule and hides item limits. It indexes published/current content and each page's own text, including supported local callouts and code blocks. PDF, Word `.docx`, Word 97–2003 `.doc`, Excel `.xlsx`, and PowerPoint `.pptx` attachments on the selected pages and blog posts are indexed as separate documents with their parent content's permissions. Space, content-type, and label filters apply to the parent content. Attachment changes are checked on each sync, even when the parent text has not changed. -Archived content, comments, other attachment formats, and expanded Include Page, Excerpt Include, or third-party macro output are excluded. Referenced pages can be indexed separately with their own permissions. Attachments over 100 MB are shown as skipped; convert older Word 6/95 files to `.docx` before attaching them. +Archived content, comments, other attachment formats, and expanded Include Page, Excerpt Include, or third-party macro output are excluded. Referenced pages can be indexed separately with their own permissions. Attachments over 100 MB are shown as skipped; convert older Word 6/95 files to `.docx`, and `.xls` and `.ppt` files to `.xlsx` and `.pptx`, before attaching them. Spaces that were already connected pick up newly supported formats on their next sync. ## Manage access and sync @@ -151,7 +151,7 @@ In **Sync history**, **Continuing** means a healthy listing needs another batch. | A new page, blog post, or label is missing | Confluence search can take time to update. Once the content appears in Confluence search with the selected label, sync again. | | A restricted page is missing | Both your account and the crawling account need access to the page and its ancestors. | | Embedded content is missing | Index the referenced page separately; remote macro output is excluded. | -| PDF or Word attachments are missing | Check `read:attachment:confluence` and access to the parent page. Existing service-account tokens may need to be replaced with one that includes this scope. Attachment access failures are reported as a partial sync. | +| Attachments are missing | Check `read:attachment:confluence` and access to the parent page. Existing service-account tokens may need to be replaced with one that includes this scope. Attachment access failures are reported as a partial sync. | | **Reconnect** or email mismatch | Authorize with the Atlassian account matching your verified Sim email and grant all requested permissions. | Open a missing page as the affected teammate, check its space and page restrictions, then sync again after correcting access. See Atlassian's [content access](https://support.atlassian.com/confluence-cloud/docs/add-or-remove-page-restrictions/) and [permission inspection](https://support.atlassian.com/confluence-cloud/docs/inspect-a-users-permissions/) guides. diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 4d540e11521..be46d622a9b 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -56,7 +56,7 @@ Open **Settings → Sources → Add source** and select **Slack**. Complete **Se ### Configure an app in Slack -Select **Install Sim Search** to open setup. In **Create Slack app**, select **Create app** and choose the target workspace. Sim supplies a manifest with the required scopes, redirects, events, and interactivity URL. Keep **Token Rotation** disabled. +Select **Install Sim Search** to open setup. In **Create Slack app**, select **Create app**, choose the target workspace, and complete Slack's app creation and installation flow. Sim supplies a manifest with the required scopes, redirects, events, and interactivity URL. Keep **Token Rotation** disabled and complete any required Slack administrator approval. You can also open this wizard from **Settings → Sim Search in Slack → Set up**. @@ -64,7 +64,7 @@ Return to Sim and select **Continue**. In **Slack app credentials**, paste **Cli Sim Search in Slack setup with placeholders for Client ID, Client Secret, and Signing Secret -Select **Continue**, then **Install in Slack**. Approve the installation in Slack. Sim saves the bot connection and opens **Settings → Sim Search in Slack**. Complete any required Slack administrator approval before continuing. +Select **Continue**. In **Connect installed Slack app**, paste the **Bot User OAuth Token** from the app's **OAuth & Permissions** page, then select **Connect app**. If Slack requests updated permissions, approve them there first. Sim validates and saves the existing bot connection without starting another installation. diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index 0b28b7feb88..d6c270a289b 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -54,12 +54,30 @@ To pick the mode when the workflow runs, use the switch next to Permission Mode Built-in conversation memory, kept across runs by a conversation ID: - **None.** Each run is independent. -- **Conversation.** The full history for that conversation ID. +- **Conversation.** Stored history for that conversation ID, subject to history-loading and model context limits. - **Sliding window (messages).** The most recent N messages. -- **Sliding window (tokens).** Recent messages up to a token budget. +- **Sliding window (tokens).** Recent history selected against a token budget, keeping tool exchanges together. Memory needs a conversation ID to persist between runs. For memory that's shared across workflows or managed as its own store, use the [Memory](/integrations/memory) block instead. +When durable tool history is enabled for your workspace, memory also keeps the assistant messages that led to tool calls, the calls' original arguments, and their results or errors. Later runs can use completed tool exchanges even if the run that produced them failed before answering. Older conversations remain readable; tool history is captured on new runs after the feature is enabled. + +Tool calls and their results are selected together, including parallel calls. They do not each use another slot in a message-count window, but their arguments and results still consume input tokens. Use a token window when the amount of recalled context matters more than the number of messages. Windowing changes what the model receives, not what is stored. + +Large results are retained separately, with their first 8,000 characters in model context and a notice when the result is truncated. The built-in `agent_memory_read` tool lets the agent search retained history and read omitted result details in small pages. It can only read the current conversation. When exact details matter, ask the agent to check the original result instead of relying on its preview. + +Sim normally targets up to 16,000 estimated tokens of recalled history, subject to your memory window and the model's available context. Large or difficult-to-tokenize content uses a conservative estimate. It checks the input before every model generation, including generations after tool calls and on fallback models, leaving room for instructions, tool definitions, attachments, and output. The current request and required tool exchanges stay intact. If their estimated size uses up the available budget, Sim omits optional history and still sends the current request; the provider enforces its actual context limit. These estimates guide recalled context per generation, not the total tokens used across a run. + +When a generation would omit older history, Sim can create a concise summary while keeping recent exchanges, including during long tool loops. Summaries can omit details and do not replace the stored conversation. Generating one uses an additional model call whose tokens and cost are included in the Agent's usage; a cached summary can be reused when its source history is unchanged. If summarization is unavailable, the Agent continues with bounded history selection. + +The Memory API and Memory block still return plain conversation messages. Internal tool history, retry state, and cached summaries are not added to their `data` responses. + +#### Retries and fallbacks + +With durable tool history enabled, retries and fallback models continue the same Agent invocation using recorded tool results. For example, if a tool returns an order number and final generation fails, the fallback receives that result without calling the tool again. A new workflow execution or loop iteration is a separate invocation. + +A recorded terminal outcome is kept even when its stored details become unavailable; the Agent does not repeat that action merely to recover the missing details. A call whose outcome was not recorded can execute again, including when an external action succeeded just before a failure. Use tools that safely handle repeated requests for actions that must not happen twice. If durable history is disabled or persistence is unavailable, saved-progress recovery is not guaranteed. This does not restart crashed workflows, override cancellation, or retry failures marked nonretryable. A failure after streaming output has started is not restarted on another model. + ### Response Format Give the agent a JSON Schema to force structured output. The response is constrained to match the schema, and each field becomes its own output you read by name, like ``. Without a response format, the agent returns plain text in `content`. @@ -88,7 +106,7 @@ Some settings live under advanced, or appear only for models that support them: - **Prompt caching.** For Anthropic Claude models, reuses the system prompt and tool definitions between runs instead of re-reading them every time. Cached input costs a tenth of the normal rate, but writing the cache costs 1.25x, so leave it off for one-off runs and turn it on when the same agent runs repeatedly. The cache covers a prefix only if it reaches 1,024 tokens (2,048 on Haiku) — below that Anthropic ignores it and nothing changes. Entries expire after five minutes of no use. - **API key.** Your key for the chosen provider. Hidden on hosted Sim, which supplies one. - **Fallback models.** An ordered list of up to five models to try when the request to the selected model fails, whether the provider is overloaded, rate-limited, or down. Sim tries the 2nd choice, then the 3rd, and so on, once each, and `` reports the model that answered. On hosted Sim, hosted models use your workspace's BYOK or platform credentials; local and self-hosted installations may still require a key. A model that needs its own key takes it from a workspace environment variable you pick on the row; a model on the same provider as the selected model reuses the block's key. A stored row key stops applying when its key field is hidden. Providers that require family-specific credentials, such as Vertex, can only be fallbacks for a selected model of the same family. The Auto model cannot be a fallback. A fallback runs with the selected model's settings where its provider accepts them: temperature and max output tokens are clamped to the fallback's limits, and when the fallback has a reasoning effort, thinking level, or verbosity setting that the selected model's value does not fit, the row shows that field so you can pick a value for it; leave it empty and the provider's default applies. -- **Retry on fail.** Retries the selected model after a failure, up to a maximum number of tries with a wait between them. When its tries run out, the fallback models are tried in order, once each, with no wait before the first of them. A fallback is never retried. A failure that happens after the model already called a tool runs that conversation again on the next try or the next model, so keep fallbacks and retry off for agents whose tools must not repeat. +- **Retry on fail.** Retries the selected model after a failure, up to a maximum number of tries with a wait between them. When its tries run out, the fallback models are tried in order, once each, with no wait before the first of them. A fallback is never retried. See [Retries and fallbacks](#retries-and-fallbacks) for how recorded tool results are reused and when a tool can execute again. OpenAI and Gemini cache automatically at no extra cost and need no setting; their discount is already reflected in what you are charged. diff --git a/apps/docs/content/docs/workflows/deployment/agent-events.mdx b/apps/docs/content/docs/workflows/deployment/agent-events.mdx index f49d278d6d5..d1aa8a7a273 100644 --- a/apps/docs/content/docs/workflows/deployment/agent-events.mdx +++ b/apps/docs/content/docs/workflows/deployment/agent-events.mdx @@ -79,7 +79,7 @@ During a live tool loop, the model can’t be classified mid-turn: text it emits - **Clients sending the protocol header** (no event policy required) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content. - **Clients without the header** never see provisional text: only settled final-turn text is emitted as `chunk`, delivered in one piece when the turn completes. Honoring `chunk_reset` is what buys live cadence, so send the header if you want it. -Logs, memory, and the block’s `content` output always contain final-turn text only — intermediate preamble is never persisted. +The block's `content` output and plain-message memory view contain the final response text. With [durable tool history](/workflows/blocks/agent#memory) enabled, internal memory also preserves complete assistant messages that lead to tool calls and their results. It records completed provider messages, not individual streamed text deltas, and does not add these internal exchanges to the Memory API's `data` response. ### Abort diff --git a/apps/docs/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index 7a2bcdb26f9..5fccfe6ce59 100644 --- a/apps/docs/lib/openapi-download.test.ts +++ b/apps/docs/lib/openapi-download.test.ts @@ -33,7 +33,7 @@ describe('OpenAPI download', () => { const tags = document.tags as Array<{ name: string }> expect(document.openapi).toBe('3.1.0') - expect(Object.keys(paths)).toHaveLength(152) + expect(Object.keys(paths)).toHaveLength(157) expect(tags.map((tag) => tag.name)).toEqual([ 'Workspace Sync', 'Workflows', diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 66a916f5767..77844ff2cbc 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -23,7 +23,7 @@ "tags": [ { "name": "Files", - "description": "Create, upload, download, organize, share, and delete workspace files." + "description": "Create, upload, download, organize, share, version, and delete workspace files." }, { "name": "Audit Logs", @@ -864,6 +864,667 @@ } } }, + "/api/v2/files/{fileId}/versions": { + "get": { + "operationId": "listFileVersions", + "summary": "List File Versions", + "description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.list", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "version", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["version"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "A page of file versions.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileVersionListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/versions/{version}": { + "get": { + "operationId": "getFileVersion", + "summary": "Get File Version", + "description": "Get one version of a file. A version removed by retention, or one that never existed, returns `404`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.read", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + } + ], + "responses": { + "200": { + "description": "The file version.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileVersionResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteFileVersion", + "summary": "Delete File Version", + "description": "Permanently delete one earlier version and its stored content, for example to purge a leaked value from history before retention removes it. The current version returns `409`; revert to another version first. A version that does not exist returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.versions.delete", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + } + ], + "responses": { + "200": { + "description": "Deletion confirmation.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileVersionDeleteResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/versions/{version}/text": { + "get": { + "operationId": "readFileVersionText", + "summary": "Read File Version Text", + "description": "Extract the text of one version, exactly as Read File Text extracts the current content. Unsupported types return `400`, compiling documents return `409`, and oversized versions return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.read_content", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "maxBytes", + "in": "query", + "required": false, + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "schema": { + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "type": "integer", + "minimum": 1, + "maximum": 26214400 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "First line to return, 1-based. Absent starts at the first line.", + "schema": { + "description": "First line to return, 1-based. Absent starts at the first line.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "How many lines to return from `offset`. Absent reads to the end.", + "schema": { + "description": "How many lines to return from `offset`. Absent reads to the end.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "The extracted text of the version and its extraction-quality flags.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileVersionTextResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/versions/{version}/content": { + "get": { + "operationId": "downloadFileVersion", + "summary": "Download File Version", + "description": "Download the bytes of one version, served exactly as Download File serves the current bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.download", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + } + ], + "responses": { + "200": { + "description": "The version bytes.", + "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/versions/{version}/revert": { + "post": { + "operationId": "revertFileVersion", + "summary": "Revert File Version", + "description": "Make the content of a version current again by writing it as a new `revert` version, so the revert can itself be reverted. Open editors receive the change. Reverting to the current version writes nothing and returns `reverted: false`. A concurrent write, or an `expectedCurrentVersion` that is no longer current, returns `409`; a version above 100 MB returns `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.versions.revert", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and an optional current-version precondition.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevertFileVersionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The file and its current version after the revert.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileVersionRevertResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/files/bulk-download": { "get": { "operationId": "bulkDownloadFiles", @@ -1407,7 +2068,7 @@ "get": { "operationId": "getFile", "summary": "Get File Metadata", - "description": "Get file metadata and its public-share configuration. The `share` field is null when the file has never been shared.\n\nOAuth scope: `api:read`.", + "description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.\n\nOAuth scope: `api:read`.", "x-sim-operation": "files.read_metadata", "x-oauth-scope": "api:read", "tags": ["Files"], @@ -2083,7 +2744,7 @@ "put": { "operationId": "updateFileContent", "summary": "Replace File Content", - "description": "Replace the complete contents of an existing file from UTF-8 or base64 input.\n\nOAuth scope: `api:write`.", + "description": "Replace the complete contents of an existing file from UTF-8 or base64 input. A stale `expectedRevision`, or a write that raced this one, returns `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", "x-sim-operation": "files.update_content", "x-oauth-scope": "api:write", "tags": ["Files"], @@ -2130,7 +2791,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileResponse" + "$ref": "#/components/schemas/V2WrittenFileResponse" } } } @@ -2147,6 +2808,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -3727,45 +4391,407 @@ "title": "Upload part URLs", "description": "Signed transfer URLs for the requested multipart upload parts." }, - "CreateFileUploadPartUrlsResponse": { + "CreateFileUploadPartUrlsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PartUrlsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create upload part URLs response", + "description": "Signed multipart upload URLs." + }, + "CreateFileUploadPartUrlsRequest": { + "type": "object", + "properties": { + "partNumbers": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "description": "Multipart part numbers for which signed URLs should be created." + } + }, + "required": ["partNumbers"], + "additionalProperties": false, + "title": "Create upload part URLs request", + "description": "Multipart part numbers requiring signed URLs.", + "examples": [ + { + "partNumbers": [1, 2] + } + ] + }, + "V2FileText": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File the text was extracted from." + }, + "name": { + "type": "string", + "description": "File name, including its extension." + }, + "type": { + "type": "string", + "description": "Stored MIME type of the source file." + }, + "text": { + "type": "string", + "description": "Extracted text." + }, + "truncated": { + "type": "boolean", + "description": "True when a parser limit stopped extraction before the input was exhausted." + }, + "degraded": { + "type": "boolean", + "description": "True when text extraction did not fully succeed and `text` may be incomplete or synthesized from the raw bytes rather than read from the document. Never treat degraded text as authoritative content." + }, + "degradedReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Why extraction degraded, or null when it did not." + }, + "charCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Length of `text` in characters." + }, + "byteCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Source bytes read from storage before extraction." + }, + "lineRange": { + "description": "Present when `offset` or `limit` narrowed the response. `totalLines` is what separates a file that ended from a window that stopped early.", + "type": "object", + "properties": { + "offset": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "First line returned, 1-based." + }, + "lineCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lines returned." + }, + "totalLines": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lines the whole file holds." + }, + "totalLinesExact": { + "type": "boolean", + "description": "False when text extraction was truncated, so `totalLines` counts only the extracted prefix and is not the end of the file." + } + }, + "required": ["offset", "lineCount", "totalLines", "totalLinesExact"], + "additionalProperties": false + } + }, + "required": [ + "fileId", + "name", + "type", + "text", + "truncated", + "degraded", + "degradedReason", + "charCount", + "byteCount" + ], + "additionalProperties": false, + "title": "Extracted file text", + "description": "Text extracted from a workspace file, with extraction-quality flags." + }, + "FileTextResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileText" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "File text response", + "description": "Text extracted from a workspace file." + }, + "V2FileVersion": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File this version belongs to." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", + "examples": [3] + }, + "isCurrent": { + "type": "boolean", + "description": "Whether this version holds the current content of the file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Size in bytes of the stored content of this version." + }, + "contentType": { + "type": "string", + "description": "MIME type of the stored content of this version." + }, + "source": { + "type": "string", + "enum": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"], + "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." + }, + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User identifier." + }, + "email": { + "anyOf": [ + { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + }, + { + "type": "null" + } + ], + "description": "Current email address of the user, or null when the account no longer exists." + } + }, + "required": ["id", "email"], + "additionalProperties": false + }, + "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." + }, + "restoredFromVersion": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + { + "type": "null" + } + ], + "description": "For a `revert` version, the version whose content it restored; otherwise null." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this content became current.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", + "format": "date-time", + "examples": ["2026-01-15T10:38:00Z"] + }, + "supersededAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "fileId", + "version", + "isCurrent", + "size", + "contentType", + "source", + "authors", + "restoredFromVersion", + "createdAt", + "updatedAt", + "supersededAt" + ], + "additionalProperties": false, + "title": "File version", + "description": "One recorded version of the content of a workspace file." + }, + "V2FileVersionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2FileVersion" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "File version list response", + "description": "A cursor-paginated page of file versions.", + "examples": [ + { + "data": [ + { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "version": 3, + "isCurrent": true, + "size": 1024, + "contentType": "text/csv", + "source": "api", + "authors": [ + { + "id": "usr_4kJ9mN2pQ7rS", + "email": "jane@example.com" + } + ], + "restoredFromVersion": null, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "supersededAt": null + } + ], + "nextCursor": null + } + ] + }, + "V2FileVersionResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PartUrlsData" + "$ref": "#/components/schemas/V2FileVersion" } }, "required": ["data"], "additionalProperties": false, - "title": "Create upload part URLs response", - "description": "Signed multipart upload URLs." + "title": "File version response", + "description": "A single file version.", + "examples": [ + { + "data": { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "version": 3, + "isCurrent": true, + "size": 1024, + "contentType": "text/csv", + "source": "api", + "authors": [ + { + "id": "usr_4kJ9mN2pQ7rS", + "email": "jane@example.com" + } + ], + "restoredFromVersion": null, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "supersededAt": null + } + } + ] }, - "CreateFileUploadPartUrlsRequest": { + "V2FileVersionDeleteResult": { "type": "object", "properties": { - "partNumbers": { - "minItems": 1, - "maxItems": 100, - "type": "array", - "items": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "description": "Multipart part numbers for which signed URLs should be created." + "fileId": { + "type": "string", + "description": "File whose version was deleted." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number that was deleted." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Always true: the version and its stored content are gone." } }, - "required": ["partNumbers"], + "required": ["fileId", "version", "deleted"], "additionalProperties": false, - "title": "Create upload part URLs request", - "description": "Multipart part numbers requiring signed URLs.", + "title": "Delete file version result", + "description": "Deletion acknowledgement for one file version." + }, + "V2FileVersionDeleteResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileVersionDeleteResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete file version response", + "description": "Deletion confirmation for one file version.", "examples": [ { - "partNumbers": [1, 2] + "data": { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "version": 2, + "deleted": true + } } ] }, - "V2FileText": { + "V2FileVersionText": { "type": "object", "properties": { "fileId": { @@ -3847,6 +4873,12 @@ }, "required": ["offset", "lineCount", "totalLines", "totalLinesExact"], "additionalProperties": false + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version the text was extracted from." } }, "required": [ @@ -3858,24 +4890,133 @@ "degraded", "degradedReason", "charCount", - "byteCount" + "byteCount", + "version" ], "additionalProperties": false, - "title": "Extracted file text", - "description": "Text extracted from a workspace file, with extraction-quality flags." + "title": "Extracted file version text", + "description": "Text extracted from one version of a workspace file, with extraction-quality flags." }, - "FileTextResponse": { + "FileVersionTextResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2FileText" + "$ref": "#/components/schemas/V2FileVersionText" } }, "required": ["data"], "additionalProperties": false, - "title": "File text response", - "description": "Text extracted from a workspace file." + "title": "File version text response", + "description": "Text extracted from one version of a workspace file." + }, + "V2FileVersionRevertResult": { + "type": "object", + "properties": { + "reverted": { + "type": "boolean", + "description": "False when the requested version was already current, in which case nothing was written." + }, + "file": { + "$ref": "#/components/schemas/V2File" + }, + "version": { + "description": "The current version of the file after the revert: a new `revert` version; the requested version when it was already current; or the unchanged current version when its content already matched the requested one.", + "$ref": "#/components/schemas/V2FileVersion" + }, + "revision": { + "description": "Opaque token for the content the file holds after the revert — the one it just wrote, or the unchanged current content when `reverted` is false. Send it back as `expectedRevision` on the next write.", + "type": "string" + } + }, + "required": ["reverted", "file", "version"], + "additionalProperties": false, + "title": "Revert file version result", + "description": "The file and its current version after a revert." + }, + "V2FileVersionRevertResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileVersionRevertResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revert file version response", + "description": "The file and its current version after the revert.", + "examples": [ + { + "data": { + "reverted": true, + "file": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + }, + "version": { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "version": 5, + "isCurrent": true, + "size": 1024, + "contentType": "text/csv", + "source": "revert", + "authors": [ + { + "id": "usr_4kJ9mN2pQ7rS", + "email": "jane@example.com" + } + ], + "restoredFromVersion": 3, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "supersededAt": null + }, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" + } + } + ] + }, + "RevertFileVersionRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + }, + "expectedCurrentVersion": { + "description": "Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.", + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "expectedRevision": { + "description": "Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version.", + "type": "string", + "minLength": 1 + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Revert file version request", + "description": "Workspace scope and an optional current-version precondition.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "expectedCurrentVersion": 4 + } + ] }, "V2FileUnzipResult": { "type": "object", @@ -4186,6 +5327,16 @@ } ], "description": "Current public-share state, or null when the file has never been shared." + }, + "revision": { + "description": "Opaque token for the file's current content. Send it back as `expectedRevision` so a write or revert is refused when the content moved on. Absent for a file with no recorded content version.", + "type": "string" + }, + "currentVersion": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number of the current content. List File Versions returns the history; pass this as `expectedCurrentVersion` to revert only if nothing changed since." } }, "required": [ @@ -4200,7 +5351,8 @@ "uploadedAt", "updatedAt", "deletedAt", - "share" + "share", + "currentVersion" ], "additionalProperties": false, "title": "File metadata", @@ -4232,7 +5384,9 @@ "uploadedAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z", "deletedAt": null, - "share": null + "share": null, + "currentVersion": 1, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" } }, { @@ -4246,7 +5400,7 @@ "folderPath": "/Engineering", "uploadedByEmail": "jane@example.com", "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-16T09:12:00Z", "deletedAt": null, "share": { "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", @@ -4258,7 +5412,9 @@ "authType": "public", "hasPassword": false, "allowedEmails": [] - } + }, + "currentVersion": 3, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTZUMDk6MTI6MDAuMDAwWg" } } ] @@ -4662,6 +5818,10 @@ "minimum": 0, "maximum": 9007199254740991, "description": "Lines the file holds after the edit." + }, + "revision": { + "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", + "type": "string" } }, "required": ["file", "lineCount"], @@ -4828,6 +5988,11 @@ } ], "description": "One exact or anchor-based edit: search_replace, replace_between, insert_after, or delete_between." + }, + "expectedRevision": { + "description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.", + "type": "string", + "minLength": 1 } }, "required": ["workspaceId", "edit"], @@ -4984,6 +6149,131 @@ } ] }, + "V2WrittenFile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "examples": ["wf_V1StGXR8z5jdHi6BmyT91"] + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Canonical absolute URL for opening this resource in the Sim web application." + }, + "name": { + "type": "string", + "description": "Original file name.", + "examples": ["data.csv"] + }, + "size": { + "type": "number", + "minimum": 0, + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", + "examples": [1024] + }, + "type": { + "type": "string", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", + "examples": ["text/csv"] + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "examples": ["workspace/example/data.csv"] + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "maxLength": 4096 + }, + "uploadedByEmail": { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "description": "Current email address of the uploader.", + "examples": ["jane@example.com"] + }, + "uploadedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the file was uploaded.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last content or metadata write.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + }, + "revision": { + "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", + "type": "string" + } + }, + "required": [ + "id", + "webUrl", + "name", + "size", + "type", + "key", + "folderPath", + "uploadedByEmail", + "uploadedAt", + "updatedAt", + "deletedAt" + ], + "additionalProperties": false, + "title": "Written file", + "description": "A workspace file after a content replacement, with the revision it produced." + }, + "V2WrittenFileResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2WrittenFile" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Written file response", + "description": "A workspace file after a content replacement, with the revision the write produced.", + "examples": [ + { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" + } + } + ] + }, "UpdateFileContentRequest": { "type": "object", "properties": { @@ -5003,6 +6293,11 @@ "description": "Encoding of the content field.", "type": "string", "enum": ["utf-8", "base64"] + }, + "expectedRevision": { + "description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.", + "type": "string", + "minLength": 1 } }, "required": ["workspaceId", "content"], diff --git a/apps/realtime/src/handlers/presence.test.ts b/apps/realtime/src/handlers/presence.test.ts new file mode 100644 index 00000000000..934e163157d --- /dev/null +++ b/apps/realtime/src/handlers/presence.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setupPresenceHandlers } from '@/handlers/presence' +import type { IRoomManager } from '@/rooms' + +const WORKFLOW_ROOM = { type: ROOM_TYPES.WORKFLOW, id: 'workflow-1' } + +const SESSION = { + userId: 'user-1', + userName: 'Test User', + avatarUrl: 'avatar.png', +} + +function createSocket() { + const handlers: Record Promise | void> = {} + const toEmit = vi.fn() + const socket = { + id: 'socket-1', + on: vi.fn((event: string, handler: (payload: unknown) => Promise | void) => { + handlers[event] = handler + }), + to: vi.fn().mockReturnValue({ emit: toEmit }), + } + return { handlers, socket, toEmit } +} + +function createRoomManager(): IRoomManager { + return { + getRoomForSocket: vi.fn().mockResolvedValue(WORKFLOW_ROOM), + getUserSession: vi.fn().mockResolvedValue(SESSION), + updateUserActivity: vi.fn().mockResolvedValue(undefined), + } as unknown as IRoomManager +} + +describe('presence handlers', () => { + let handlers: Record Promise | void> + let toEmit: ReturnType + let roomManager: IRoomManager + + beforeEach(() => { + vi.clearAllMocks() + const created = createSocket() + handlers = created.handlers + toEmit = created.toEmit + roomManager = createRoomManager() + setupPresenceHandlers(created.socket as never, roomManager) + }) + + describe('cursor-update', () => { + it('stores and broadcasts a well-formed cursor', async () => { + await handlers['cursor-update']({ cursor: { x: 12.5, y: -3 } }) + + expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', { + cursor: { x: 12.5, y: -3 }, + }) + expect(toEmit).toHaveBeenCalledWith( + 'cursor-update', + expect.objectContaining({ cursor: { x: 12.5, y: -3 } }) + ) + }) + + it('preserves a cleared cursor', async () => { + await handlers['cursor-update']({ cursor: null }) + + expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', { + cursor: null, + }) + expect(toEmit).toHaveBeenCalledWith( + 'cursor-update', + expect.objectContaining({ cursor: null }) + ) + }) + + it('strips unexpected keys instead of storing them', async () => { + await handlers['cursor-update']({ + cursor: { x: 1, y: 2, pad: 'A'.repeat(100_000) }, + }) + + expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', { + cursor: { x: 1, y: 2 }, + }) + const broadcast = toEmit.mock.calls[0][1] as { cursor: Record } + expect(broadcast.cursor).toEqual({ x: 1, y: 2 }) + expect(broadcast.cursor).not.toHaveProperty('pad') + }) + + it.each([ + ['an oversized string', 'A'.repeat(100_000)], + ['a non-numeric x', { x: 'A'.repeat(100_000), y: 1 }], + ['a missing y', { x: 1 }], + ['NaN coordinates', { x: Number.NaN, y: Number.NaN }], + ['Infinity coordinates', { x: Number.POSITIVE_INFINITY, y: 0 }], + ['an array', [1, 2, 3]], + ['undefined', undefined], + ])('drops %s without storing or broadcasting it', async (_label, cursor) => { + await handlers['cursor-update']({ cursor }) + + expect(roomManager.updateUserActivity).not.toHaveBeenCalled() + expect(toEmit).not.toHaveBeenCalled() + }) + }) + + describe('selection-update', () => { + it('stores and broadcasts a well-formed selection', async () => { + await handlers['selection-update']({ selection: { type: 'block', id: 'block-1' } }) + + expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', { + selection: { type: 'block', id: 'block-1' }, + }) + expect(toEmit).toHaveBeenCalledWith( + 'selection-update', + expect.objectContaining({ selection: { type: 'block', id: 'block-1' } }) + ) + }) + + it('keeps an id-less selection id-less', async () => { + await handlers['selection-update']({ selection: { type: 'none' } }) + + expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', { + selection: { type: 'none' }, + }) + }) + + it('strips unexpected keys instead of storing them', async () => { + await handlers['selection-update']({ + selection: { type: 'edge', id: 'edge-1', pad: 'A'.repeat(100_000) }, + }) + + expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', { + selection: { type: 'edge', id: 'edge-1' }, + }) + }) + + it.each([ + ['an unknown type', { type: 'evil', id: 'x' }], + ['a missing type', { id: 'x' }], + ['an oversized id', { type: 'block', id: 'A'.repeat(100_000) }], + ['a non-string id', { type: 'block', id: { nested: 'A'.repeat(100_000) } }], + ['null', null], + ['an oversized string', 'A'.repeat(100_000)], + ])('drops %s without storing or broadcasting it', async (_label, selection) => { + await handlers['selection-update']({ selection }) + + expect(roomManager.updateUserActivity).not.toHaveBeenCalled() + expect(toEmit).not.toHaveBeenCalled() + }) + }) + + it('does not touch room state when the socket has no room', async () => { + ;(roomManager.getRoomForSocket as ReturnType).mockResolvedValue(null) + + await handlers['cursor-update']({ cursor: { x: 1, y: 1 } }) + + expect(roomManager.updateUserActivity).not.toHaveBeenCalled() + expect(toEmit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/realtime/src/handlers/presence.ts b/apps/realtime/src/handlers/presence.ts index 78b53176e2f..e57d7f17f25 100644 --- a/apps/realtime/src/handlers/presence.ts +++ b/apps/realtime/src/handlers/presence.ts @@ -1,19 +1,66 @@ import { createLogger } from '@sim/logger' +import type { CursorPosition, PresenceSelection } from '@sim/realtime-protocol/events' import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import type { AuthenticatedSocket } from '@/middleware/auth' import type { IRoomManager } from '@/rooms' const logger = createLogger('PresenceHandlers') +/** Longest accepted selection id — real ids are UUIDs/short ids; this bounds a hostile payload. */ +const MAX_SELECTION_ID_LENGTH = 200 + +/** The selection kinds a client may publish, mirroring {@link PresenceSelection}. */ +const SELECTION_TYPES = new Set(['block', 'edge', 'none']) + +/** + * Validate + whitelist an untrusted peer's cursor before it is stored and rebroadcast. + * Returns the normalized position — `null` for a legitimately cleared cursor — or + * `undefined` for anything malformed, so the caller drops it. Only `x`/`y` survive, so a + * hostile client can't amplify an oversized object through the room or the presence record. + */ +function normalizeCursor(cursor: unknown): CursorPosition | null | undefined { + if (cursor === null) return null + if (typeof cursor !== 'object') return undefined + const candidate = cursor as { x?: unknown; y?: unknown } + if (!Number.isFinite(candidate.x) || !Number.isFinite(candidate.y)) return undefined + return { x: candidate.x as number, y: candidate.y as number } +} + +/** + * Validate + whitelist an untrusted peer's selection before it is stored and rebroadcast. + * Returns the normalized selection, or `undefined` for anything malformed, so the caller + * drops it. A cleared selection is expressed as `type: 'none'`, not `null`. Rebuilding from + * a fixed field set means unexpected keys can't ride along into the shared presence record. + */ +function normalizeSelection(selection: unknown): PresenceSelection | undefined { + if (typeof selection !== 'object' || selection === null) return undefined + const candidate = selection as { type?: unknown; id?: unknown } + if (!SELECTION_TYPES.has(candidate.type as PresenceSelection['type'])) return undefined + if ( + candidate.id !== undefined && + (typeof candidate.id !== 'string' || candidate.id.length > MAX_SELECTION_ID_LENGTH) + ) { + return undefined + } + return { + type: candidate.type as PresenceSelection['type'], + ...(typeof candidate.id === 'string' ? { id: candidate.id } : {}), + } +} + export function setupPresenceHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { - socket.on('cursor-update', async ({ cursor }) => { + socket.on('cursor-update', async ({ cursor: rawCursor }: { cursor: unknown }) => { try { + // Drop a malformed/oversized cursor from an untrusted peer before it is stored or + // rebroadcast (`undefined` = invalid; `null` = a legitimately cleared cursor). + const cursor = normalizeCursor(rawCursor) + if (cursor === undefined) return + const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW) const session = await roomManager.getUserSession(socket.id) if (!room || !session) return - // Update cursor in room state await roomManager.updateUserActivity(room, socket.id, { cursor }) // Broadcast to other users in the room (workflow room name is the bare id) @@ -29,14 +76,18 @@ export function setupPresenceHandlers(socket: AuthenticatedSocket, roomManager: } }) - socket.on('selection-update', async ({ selection }) => { + socket.on('selection-update', async ({ selection: rawSelection }: { selection: unknown }) => { try { + // Drop a malformed/oversized selection from an untrusted peer before it is stored + // or rebroadcast. + const selection = normalizeSelection(rawSelection) + if (selection === undefined) return + const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW) const session = await roomManager.getUserSession(socket.id) if (!room || !session) return - // Update selection in room state await roomManager.updateUserActivity(room, socket.id, { selection }) // Broadcast to other users in the room (workflow room name is the bare id) diff --git a/apps/realtime/src/rooms/redis-manager.ts b/apps/realtime/src/rooms/redis-manager.ts index 7282f4a6778..17d67377531 100644 --- a/apps/realtime/src/rooms/redis-manager.ts +++ b/apps/realtime/src/rooms/redis-manager.ts @@ -122,6 +122,41 @@ redis.call('EXPIRE', socketSessionKey, sessionTtl) return 1 ` +/** + * Ceiling on a single presence field, measured in the UTF-8 bytes Redis actually stores + * rather than UTF-16 code units, so a multi-byte payload can't pass a character-based check + * and still land several times larger in the room hash. + * + * The largest legitimate payload is a table cell selection: four ids capped at 200 + * characters each. Multi-byte characters and JSON escaping can expand those well past + * their character count, so the realistic worst case approaches 5 KB — this sits comfortably + * above that, and a backstop that could trim real presence would be worse than a loose one. + * It bounds what one socket can park in the shared room hash and fan out to every peer when + * a presence-bearing event's handler validation is missing or regresses. + */ +const MAX_PRESENCE_FIELD_BYTES = 16384 + +/** + * Serialize one presence field for the activity script. Returns `''` when there is no + * update (the script skips the field) and, defensively, when the value exceeds + * {@link MAX_PRESENCE_FIELD_BYTES} — dropping just that field rather than the whole + * update, so a single oversized field can't suppress the others or the activity refresh. + */ +function serializePresenceField( + field: 'cursor' | 'selection' | 'cell', + value: unknown, + socketId: string +): string { + if (value === undefined) return '' + const serialized = JSON.stringify(value) + const bytes = Buffer.byteLength(serialized, 'utf8') + if (bytes > MAX_PRESENCE_FIELD_BYTES) { + logger.warn('Dropping oversized presence field', { field, socketId, bytes }) + return '' + } + return serialized +} + /** * Redis-backed room manager for multi-pod deployments. Domain-neutral: keyed by * {@link RoomRef}, supports a socket in multiple rooms (one per {@link RoomType}). @@ -370,14 +405,14 @@ export class RedisRoomManager implements IRoomManager { keys: [KEYS.roomUsers(room), KEYS.socketRooms(socketId), KEYS.socketSession(socketId)], arguments: [ socketId, - updates.cursor !== undefined ? JSON.stringify(updates.cursor) : '', - updates.selection !== undefined ? JSON.stringify(updates.selection) : '', + serializePresenceField('cursor', updates.cursor, socketId), + serializePresenceField('selection', updates.selection, socketId), (updates.lastActivity ?? Date.now()).toString(), SOCKET_ROOMS_TTL.toString(), SESSION_TTL.toString(), // Trailing arg (ARGV[7]) so existing indices stay stable. `null` (cleared // selection) serializes to 'null'; `undefined` (no cell change) to '' (skip). - updates.cell !== undefined ? JSON.stringify(updates.cell) : '', + serializePresenceField('cell', updates.cell, socketId), ], }) } catch (error) { diff --git a/apps/realtime/src/rooms/types.ts b/apps/realtime/src/rooms/types.ts index f1a67d66abb..9ef4e77fa70 100644 --- a/apps/realtime/src/rooms/types.ts +++ b/apps/realtime/src/rooms/types.ts @@ -17,7 +17,8 @@ export interface UserPresence { joinedAt: number lastActivity: number role: string - cursor?: { x: number; y: number } + /** The viewer's pointer position. `null` clears it (the pointer left the canvas). */ + cursor?: { x: number; y: number } | null selection?: { type: 'block' | 'edge' | 'none'; id?: string } /** The viewer's current table cell selection, for table presence rooms. */ cell?: TableCellSelection diff --git a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx index 31aa3527fb9..c91c687b41e 100644 --- a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx +++ b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx @@ -1,6 +1,6 @@ 'use client' -import { Button } from '@sim/emcn' +import { Button, StatusPageContent } from '@sim/emcn' import { useRouter } from 'next/navigation' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' @@ -13,19 +13,16 @@ export function ChatErrorState({ error }: ChatErrorStateProps) { return (
-
-

- Chat Unavailable -

-

{error}

+ -
+
) } diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index 2cc1050d9b0..bb09919dc63 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -131,7 +131,7 @@ function truncateForPreview(text: string): { text: string; truncated: boolean } function renderStructuredValuePreview(value: unknown) { if (value === null || value === undefined || value === '') { - return + return } if (typeof value === 'object') { @@ -158,7 +158,7 @@ function renderStructuredValuePreview(value: unknown) { const { text: stringValue, truncated } = truncateForPreview(String(value)) return (
-
+
{truncated ? `${stringValue}…` : stringValue}
{truncated && ( @@ -806,7 +806,7 @@ export default function ResumeExecutionPage({
{pausePoints.length === 0 ? ( -
+
No pause points
) : ( @@ -821,7 +821,7 @@ export default function ResumeExecutionPage({ }} className='w-full justify-between rounded-none px-4 py-3' > - {getBlockName(pause)} + {getBlockName(pause)} )) @@ -833,17 +833,17 @@ export default function ResumeExecutionPage({
{loadingDetail && !selectedDetail ? (
- Loading… + Loading…
) : !selectedContextId ? (
- + Select a pause point
) : !selectedDetail ? (
- + Could not load details
@@ -853,7 +853,7 @@ export default function ResumeExecutionPage({
-

+

Paused at {formatDate(selectedDetail.pausePoint.registeredAt)}

@@ -870,10 +870,10 @@ export default function ResumeExecutionPage({ {selectedDetail.pausePoint.automaticResumeWaitingReason && (
-

+

{selectedDetail.pausePoint.automaticResumeWaitingReason}

-

+

Sim will retry automatically.

@@ -898,7 +898,7 @@ export default function ResumeExecutionPage({
{field.description && ( -

+

{field.description}

)} @@ -922,7 +922,7 @@ export default function ResumeExecutionPage({ rows={6} /> ) : ( -

+

No input data provided

)} @@ -963,7 +963,7 @@ export default function ResumeExecutionPage({
-

+

No display data configured

@@ -986,7 +986,7 @@ export default function ResumeExecutionPage({ )} {field.description && ( -

+

{field.description}

)} diff --git a/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx b/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx index 6798b467cf9..0237f09be38 100644 --- a/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx +++ b/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx @@ -38,6 +38,7 @@ vi.mock('@/app/(landing)/comparisons/components/comparison-cards', () => ({ ComparisonCards: () => null, })) +import { escapeRegExp } from '@sim/utils/string' import type { Prose } from '@/lib/compare/data' import { dustProfile } from '@/lib/compare/data' import ComparisonProviderPage from '@/app/(landing)/comparisons/[provider]/page' @@ -64,7 +65,7 @@ function countMatches(markup: string, pattern: RegExp): number { * against the wrong anchor. */ function anchorWrapping(markup: string, text: string): string { - const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const escaped = escapeRegExp(text) return markup.match(new RegExp(`]*>${escaped}`))?.[0] ?? '' } diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx index bb8d64f5d8f..cc03a9ae5b5 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx @@ -9,6 +9,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, TabStrip, + TabStripAction, type TabStripItem, Tooltip, } from '@sim/emcn' @@ -25,7 +26,6 @@ import type { LeadRecord } from '@/app/(landing)/tables/components/tables-record import { TablesRecordsTable } from '@/app/(landing)/tables/components/tables-records-preview/tables-records-table' import { RESOURCE_HEADER_CLASSES, - RESOURCE_TAB_ICON_BUTTON_CLASS, RESOURCE_TAB_ICON_CLASS, resourceTabWidthClass, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' @@ -133,15 +133,14 @@ export function HeroResourcePanel({ activeId === 'workflow' ? ( - + Run workflow @@ -162,14 +161,9 @@ export function HeroResourcePanel({ - + Add resource diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx index f920c55ec56..168d44601bc 100644 --- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx +++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx @@ -1,5 +1,5 @@ import { ChipLink } from '@sim/emcn' -import { truncate } from '@sim/utils/string' +import { escapeRegExp, truncate } from '@sim/utils/string' import type { Metadata } from 'next' import Link from 'next/link' import { notFound } from 'next/navigation' @@ -127,10 +127,6 @@ function sentenceWithTerminalPunctuation(value: string): string { return /[.!?]$/.test(trimmedValue) ? trimmedValue : `${trimmedValue}.` } -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - /** * Server-side rewrite of bare integration names in a curated template prompt * to `@`-mention form (`Slack` → `@Slack`) so the prompt chips with brand @@ -152,7 +148,7 @@ function mentionifyPromptForNames(prompt: string, names: readonly string[]): str ) if (unique.length === 0) return prompt const regex = new RegExp( - `(? `@${match}`) diff --git a/apps/sim/app/api/auth/sso/providers/route.test.ts b/apps/sim/app/api/auth/sso/providers/route.test.ts new file mode 100644 index 00000000000..47c7cfdcae1 --- /dev/null +++ b/apps/sim/app/api/auth/sso/providers/route.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +import { GET } from '@/app/api/auth/sso/providers/route' + +const providerRow = { + id: 'row-1', + providerId: 'acme-okta', + domain: 'acme.com', + issuer: 'https://acme.okta.test', + oidcConfig: JSON.stringify({ clientId: 'client', clientSecret: 'a-long-client-secret-wxyz' }), + samlConfig: null, + userId: 'user-1', + organizationId: 'org-1', + jitProvisioningEnabled: true, + domainVerified: true, + domainKey: 'acme.com', + isNamedPrimary: false, +} + +describe('GET /api/auth/sso/providers', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + + it('refuses a caller without a session before reading any provider', async () => { + mockGetSession.mockResolvedValue(null) + const res = await GET(createMockRequest('GET')) + expect(res.status).toBe(401) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('lists only the providers the caller registered when no organization is named', async () => { + queueTableRows(schemaMock.ssoProvider, [providerRow]) + const res = await GET(createMockRequest('GET')) + expect(res.status).toBe(200) + const { providers } = await res.json() + expect(providers).toHaveLength(1) + expect(providers[0]).toMatchObject({ providerId: 'acme-okta', providerType: 'oidc' }) + expect(JSON.parse(providers[0].oidcConfig)).toMatchObject({ clientSecretHint: 'wxyz' }) + expect(providers[0].oidcConfig).not.toContain('a-long-client-secret') + const condition = JSON.stringify(dbChainMockFns.where.mock.calls[0][0]) + expect(condition).toContain('user-1') + }) + + it('refuses an organization the caller does not administer', async () => { + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'member' }]) + const res = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/auth/sso/providers?organizationId=org-1' + ) + ) + expect(res.status).toBe(403) + }) +}) diff --git a/apps/sim/app/api/auth/sso/providers/route.ts b/apps/sim/app/api/auth/sso/providers/route.ts index e2f2767dc5e..9a447da9df0 100644 --- a/apps/sim/app/api/auth/sso/providers/route.ts +++ b/apps/sim/app/api/auth/sso/providers/route.ts @@ -11,7 +11,6 @@ import { listSsoProvidersContract } from '@/lib/api/contracts/auth' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { markSignInProviders } from '@/lib/auth/sso/primary-provider' -import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { REDACTED_MARKER } from '@/lib/core/security/redaction' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -32,102 +31,87 @@ function buildClientSecretHint(clientSecret: unknown): string | null { return clientSecret.slice(-4) } +/** + * Lists the identity providers the caller administers: an organization's when an + * owner or admin names it, otherwise the ones the caller registered. + * + * Signed-in only. Sign-in resolves one address at a time through + * `/api/auth/sso/resolve`; nothing needs every configured domain, and listing + * them would publish which organizations use SSO. + */ export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { - const rateLimited = await enforceIpRateLimit('sso-providers', request, { - maxTokens: 20, - refillRate: 20, - refillIntervalMs: 60_000, - }) - if (rateLimited) return rateLimited + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const parsed = await parseRequest(listSsoProvidersContract, request, {}) if (!parsed.success) return parsed.response const { organizationId } = parsed.data.query + const userId = session.user.id - let providers - if (session?.user?.id) { - const userId = session.user.id - - let verifiedOrganizationId: string | null = null - if (organizationId) { - const [membership] = await db - .select({ organizationId: member.organizationId, role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) - .limit(1) - if (!membership) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - if (membership.role !== 'owner' && membership.role !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - verifiedOrganizationId = membership.organizationId + let verifiedOrganizationId: string | null = null + if (organizationId) { + const [membership] = await db + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) + .limit(1) + if (!membership) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + if (membership.role !== 'owner' && membership.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + verifiedOrganizationId = membership.organizationId + } - const whereClause = verifiedOrganizationId - ? eq(ssoProvider.organizationId, verifiedOrganizationId) - : eq(ssoProvider.userId, userId) - - const results = await db - .select({ - id: ssoProvider.id, - providerId: ssoProvider.providerId, - domain: ssoProvider.domain, - issuer: ssoProvider.issuer, - oidcConfig: ssoProvider.oidcConfig, - samlConfig: ssoProvider.samlConfig, - userId: ssoProvider.userId, - organizationId: ssoProvider.organizationId, - jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled, - domainVerified: ssoProvider.domainVerified, - domainKey: ssoProviderDomainKey, - isNamedPrimary, - }) - .from(ssoProvider) - .leftJoin(ssoDomain, verifiedDomainOfProvider) - .where(whereClause) - .orderBy(asc(ssoProvider.providerId)) + const whereClause = verifiedOrganizationId + ? eq(ssoProvider.organizationId, verifiedOrganizationId) + : eq(ssoProvider.userId, userId) - providers = markSignInProviders(results).map((provider) => { - let oidcConfig = provider.oidcConfig - if (oidcConfig) { - try { - const parsed = JSON.parse(oidcConfig) - const hint = buildClientSecretHint(parsed.clientSecret) - parsed.clientSecret = REDACTED_MARKER - if (hint) parsed.clientSecretHint = hint - oidcConfig = JSON.stringify(parsed) - } catch { - oidcConfig = null - } - } - return { - ...provider, - oidcConfig, - providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml', - } + const results = await db + .select({ + id: ssoProvider.id, + providerId: ssoProvider.providerId, + domain: ssoProvider.domain, + issuer: ssoProvider.issuer, + oidcConfig: ssoProvider.oidcConfig, + samlConfig: ssoProvider.samlConfig, + userId: ssoProvider.userId, + organizationId: ssoProvider.organizationId, + jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled, + domainVerified: ssoProvider.domainVerified, + domainKey: ssoProviderDomainKey, + isNamedPrimary, }) - } else { - const results = await db - .select({ - domain: ssoProvider.domain, - }) - .from(ssoProvider) - - providers = results.map((provider) => ({ - domain: provider.domain, - })) - } + .from(ssoProvider) + .leftJoin(ssoDomain, verifiedDomainOfProvider) + .where(whereClause) + .orderBy(asc(ssoProvider.providerId)) - logger.info('Fetched SSO providers', { - userId: session?.user?.id, - authenticated: !!session?.user?.id, - providerCount: providers.length, + const providers = markSignInProviders(results).map((provider) => { + let oidcConfig = provider.oidcConfig + if (oidcConfig) { + try { + const parsed = JSON.parse(oidcConfig) + const hint = buildClientSecretHint(parsed.clientSecret) + parsed.clientSecret = REDACTED_MARKER + if (hint) parsed.clientSecretHint = hint + oidcConfig = JSON.stringify(parsed) + } catch { + oidcConfig = null + } + } + return { + ...provider, + oidcConfig, + providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml', + } }) + logger.info('Fetched SSO providers', { userId, providerCount: providers.length }) + return NextResponse.json({ providers }) } catch (error) { logger.error('Failed to fetch SSO providers', { error }) diff --git a/apps/sim/app/api/auth/sso/resolve/route.test.ts b/apps/sim/app/api/auth/sso/resolve/route.test.ts index 90567f03bbd..0c1cf725ccd 100644 --- a/apps/sim/app/api/auth/sso/resolve/route.test.ts +++ b/apps/sim/app/api/auth/sso/resolve/route.test.ts @@ -26,17 +26,17 @@ describe('POST /api/auth/sso/resolve', () => { }) it('names the provider that serves the address domain', async () => { - queueTableRows(schemaMock.ssoProvider, [{ providerId: 'acme-okta', samlConfig: null }]) + queueTableRows(schemaMock.ssoProvider, [{ providerId: 'acme-okta' }]) const res = await POST(createMockRequest('POST', { email: 'Ada@Acme.com' })) expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ providerId: 'acme-okta', providerType: 'oidc' }) + await expect(res.json()).resolves.toEqual({ providerId: 'acme-okta' }) const [condition] = dbChainMockFns.where.mock.calls[0] expect(JSON.stringify(condition)).toContain('acme.com') expect(JSON.stringify(condition)).toContain('domainVerified') }) it('prefers the provider the verified domain names, then provider id', async () => { - queueTableRows(schemaMock.ssoProvider, [{ providerId: 'acme-okta', samlConfig: null }]) + queueTableRows(schemaMock.ssoProvider, [{ providerId: 'acme-okta' }]) await POST(createMockRequest('POST', { email: 'ada@acme.com' })) expect(dbChainMockFns.leftJoin).toHaveBeenCalledWith(schemaMock.ssoDomain, expect.anything()) const [named, byId] = dbChainMockFns.orderBy.mock.calls[0] @@ -46,7 +46,7 @@ describe('POST /api/auth/sso/resolve', () => { }) it('honors a test link only for a provider that serves the address domain', async () => { - queueTableRows(schemaMock.ssoProvider, [{ providerId: 'acme-entra', samlConfig: null }]) + queueTableRows(schemaMock.ssoProvider, [{ providerId: 'acme-entra' }]) const res = await POST( createMockRequest('POST', { email: 'ada@acme.com', providerId: 'acme-entra' }) ) @@ -65,12 +65,6 @@ describe('POST /api/auth/sso/resolve', () => { expect(res.status).toBe(404) }) - it('reports SAML providers as such', async () => { - queueTableRows(schemaMock.ssoProvider, [{ providerId: 'acme-adfs', samlConfig: '{}' }]) - const res = await POST(createMockRequest('POST', { email: 'ada@acme.com' })) - await expect(res.json()).resolves.toMatchObject({ providerType: 'saml' }) - }) - it('answers 404 when no provider serves the domain', async () => { queueTableRows(schemaMock.ssoProvider, []) const res = await POST(createMockRequest('POST', { email: 'ada@nowhere.test' })) diff --git a/apps/sim/app/api/auth/sso/resolve/route.ts b/apps/sim/app/api/auth/sso/resolve/route.ts index bd038bee469..8f5b0480043 100644 --- a/apps/sim/app/api/auth/sso/resolve/route.ts +++ b/apps/sim/app/api/auth/sso/resolve/route.ts @@ -16,11 +16,11 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' * Names the identity provider that signs in an email address. * * Unauthenticated by nature, like the sign-in page that calls it, and admitted - * per address. It discloses nothing the public provider list does not: which - * domains have SSO, and the provider id that already appears in the callback - * URL. Only a provider whose domain is verified is named: an unverified claim - * has no authority over the address, and sending someone to its IdP would fail - * at the callback anyway. + * per address. It answers for the one domain asked about, and names only the + * provider id that the sign-in redirect and callback URL expose anyway; there is + * deliberately no way to list every domain with SSO. Only a provider whose domain + * is verified is named: an unverified claim has no authority over the address, + * and sending someone to its IdP would fail at the callback anyway. * * The provider the domain names as primary wins, then the first verified by id, * which is also the only one when a domain has a single provider. A test sign-in @@ -46,7 +46,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const requestedProviderId = parsed.data.body.providerId const [provider] = await db - .select({ providerId: ssoProvider.providerId, samlConfig: ssoProvider.samlConfig }) + .select({ providerId: ssoProvider.providerId }) .from(ssoProvider) .leftJoin(ssoDomain, verifiedDomainOfProvider) .where( @@ -68,8 +68,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - return NextResponse.json({ - providerId: provider.providerId, - providerType: provider.samlConfig ? 'saml' : 'oidc', - }) + return NextResponse.json({ providerId: provider.providerId }) }) diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index d6aa709cd63..c52623c9278 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -453,6 +453,27 @@ describe('Chat Identifier API Route', () => { }) }) + it('should return 403 for an inactive chat without loading the workflow or writing a log', async () => { + dbChainMockFns.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ ...mockChatResult[0], isActive: false }]), + }), + }), + })) + const req = createMockNextRequest('POST', { input: 'x' }) + + const response = await POST(req, { params: Promise.resolve({ identifier: 'paused-chat' }) }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data).toHaveProperty('message', 'This chat is currently unavailable') + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(loggingSessionMockFns.mockSafeStart).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSafeCompleteWithError).not.toHaveBeenCalled() + expect(mockValidateChatAuth).not.toHaveBeenCalled() + }) + it('should return 400 for requests without input', async () => { const req = createMockNextRequest('POST', {}) const params = Promise.resolve({ identifier: 'test-chat' }) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index ec5abdec4cc..e83a6c0a632 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { chat, workflow } from '@sim/db/schema' +import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' @@ -156,44 +156,6 @@ export const POST = withRouteHandler( if (!deployment.isActive) { logger.warn(`[${requestId}] Chat is not active: ${identifier}`) - - const [workflowRecord] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt))) - .limit(1) - - const workspaceId = workflowRecord?.workspaceId - if (!workspaceId) { - logger.warn( - `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace` - ) - return createErrorResponse('This chat is currently unavailable', 403) - } - - const executionId = generateId() - const loggingSession = new LoggingSession( - deployment.workflowId, - executionId, - 'chat', - requestId - ) - - await loggingSession.safeStart({ - userId: deployment.userId, - workspaceId, - variables: {}, - }) - - await loggingSession.safeCompleteWithError({ - error: { - message: 'This chat is currently unavailable. The chat has been disabled.', - stackTrace: undefined, - }, - traceSpans: [], - skipCost: true, - }) - return createErrorResponse('This chat is currently unavailable', 403) } diff --git a/apps/sim/app/api/copilot/tools/execute/route.test.ts b/apps/sim/app/api/copilot/tools/execute/route.test.ts index 716fbeb9ffd..faacd5934fb 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.test.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.test.ts @@ -4,10 +4,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const { mockCheckInternalApiKey, mockPrepareEnvironmentContext, mockHandler } = vi.hoisted(() => ({ +const { + mockCheckInternalApiKey, + mockPrepareEnvironmentContext, + mockHandler, + mockToolRequiresApprovalLane, +} = vi.hoisted(() => ({ mockCheckInternalApiKey: vi.fn(), mockPrepareEnvironmentContext: vi.fn(), mockHandler: vi.fn(), + mockToolRequiresApprovalLane: vi.fn().mockReturnValue(false), })) vi.mock('@/lib/copilot/request/http', () => ({ @@ -20,6 +26,7 @@ vi.mock('@/lib/copilot/environment-context', () => ({ vi.mock('@/lib/copilot/tool-executor', () => ({ ensureHandlersRegistered: vi.fn(), + toolRequiresApprovalLane: mockToolRequiresApprovalLane, })) vi.mock('@/lib/copilot/tool-executor/executor', () => ({ @@ -67,6 +74,7 @@ describe('POST /api/copilot/tools/execute (in-band)', () => { beforeEach(() => { vi.clearAllMocks() mockCheckInternalApiKey.mockReturnValue({ success: true }) + mockToolRequiresApprovalLane.mockReturnValue(false) // A fresh, complete registry per test: the module-level turn cache is keyed // by messageId, so each test uses a distinct messageId to avoid cross-test // cache hits. @@ -125,4 +133,95 @@ describe('POST /api/copilot/tools/execute (in-band)', () => { ) expect(mockPrepareEnvironmentContext).toHaveBeenCalledTimes(1) }) + + /** + * The generated key is a browser-only artifact. This lane's response is what Go feeds the + * model, so it must carry the same projection the resume lane produces — the status message + * alone. A `key` field here would put the plaintext credential in model context. + */ + it('returns only the status message for generate_api_key, never the key', async () => { + const message = 'API key "demo" created. You did NOT receive the key value' + mockHandler.mockResolvedValue({ + success: true, + output: { id: 'key-1', name: 'demo', key: 'sk_live_plaintext', workspaceId: 'ws-1', message }, + }) + + const res = await POST( + makeRequest({ + ...BASE_BODY, + toolName: 'generate_api_key', + params: { name: 'demo' }, + messageId: 'msg-api-key', + }) as never + ) + const body = await res.json() + + expect(body).toEqual({ success: true, output: message }) + expect(JSON.stringify(body)).not.toContain('sk_live_plaintext') + }) + + it('leaves a non-generate_api_key result carrying a key field untouched', async () => { + mockHandler.mockResolvedValue({ success: true, output: { key: 'lookup-key', value: 42 } }) + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-other-tool-key' }) as never) + await expect(res.json()).resolves.toEqual({ + success: true, + output: { key: 'lookup-key', value: 42 }, + }) + }) + + /** + * Whether a tool needs an approval-capable lane is decided by + * `toolRequiresApprovalLane` (covered against the real flag and catalog in + * the tool-executor router tests). What matters here is what the route does + * with that answer. + */ + describe('approval-gated tools', () => { + /** + * This lane cannot hold an approval prompt: the dispatch handler owns the gate and + * declines to dispatch in-band calls, so a gated tool arriving here has no waiter behind + * it. Refuse before running anything rather than execute on consent nobody gave. + */ + it('refuses a tool that needs an approval-capable lane, without executing it', async () => { + mockToolRequiresApprovalLane.mockReturnValue(true) + mockHandler.mockResolvedValue({ success: true, output: { ran: true } }) + + const res = await POST( + makeRequest({ + ...BASE_BODY, + toolName: 'run_function', + params: { code: 'return 1' }, + messageId: 'msg-gated', + }) as never + ) + const body = await res.json() + + expect(mockHandler).not.toHaveBeenCalled() + expect(body.success).toBe(false) + expect(body.output).toEqual({ resultWithheld: true, effect: 'not_attempted' }) + expect(body.error).toContain('requires user approval') + expect(body.error).toContain('checkpoint lane') + }) + + it('still runs a tool that does not need an approval-capable lane', async () => { + mockHandler.mockResolvedValue({ success: true, output: { content: 'hello' } }) + + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-ungated' }) as never) + + expect(mockHandler).toHaveBeenCalledTimes(1) + await expect(res.json()).resolves.toEqual({ success: true, output: { content: 'hello' } }) + }) + }) + + it('passes a failed generate_api_key call through with its error', async () => { + mockHandler.mockResolvedValue({ success: false, error: 'name is required' }) + const res = await POST( + makeRequest({ + ...BASE_BODY, + toolName: 'generate_api_key', + params: {}, + messageId: 'msg-api-key-error', + }) as never + ) + await expect(res.json()).resolves.toEqual({ success: false, error: 'name is required' }) + }) }) diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index b6a0c57c2ec..d68ac58ba59 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' +import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction' import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -16,7 +17,7 @@ import { } from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import type { ToolCallResult } from '@/lib/copilot/request/types' -import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor' +import { ensureHandlersRegistered, toolRequiresApprovalLane } from '@/lib/copilot/tool-executor' import { executeTool } from '@/lib/copilot/tool-executor/executor' import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -125,6 +126,29 @@ export const POST = withRouteHandler((request: NextRequest) => [TraceAttr.UserId]: userId, }) + /** + * Cheap admission, before any work: this lane cannot hold an approval prompt. The + * dispatch handler gates `requiresApproval` tools against a streaming context and a + * decision row, then deliberately declines to dispatch anything the mothership marks + * in-band — so a gated tool arriving here has no waiter behind it and would run on + * consent nobody gave. Refuse instead, and let the mothership take the checkpoint lane + * where the gate lives. Inert while copilot tool permissions are disabled, which keeps + * enabling the flag from silently leaving background lanes ungated. + */ + if (toolRequiresApprovalLane(toolName)) { + logger.warn('Refusing an approval-gated tool on the in-band lane', { + toolName, + toolCallId, + userId, + }) + rootSpan.setAttributes({ [TraceAttr.ToolOutcome]: MothershipStreamV1ToolOutcome.error }) + return NextResponse.json({ + success: false, + error: `${toolName} was not run: it requires user approval, and this lane cannot hold an approval prompt. Dispatch it on the checkpoint lane instead.`, + output: { resultWithheld: true, effect: TOOL_EFFECT_PHASE.notAttempted }, + }) + } + let toolRegistry: ResolvedSecretTraceRegistry let turnRegistry: ResolvedSecretTraceRegistry try { @@ -243,9 +267,17 @@ export const POST = withRouteHandler((request: NextRequest) => }) }) } + /** + * The response IS the model-facing channel on this lane — Go relays it straight into + * the turn — so it carries the same projection the resume lane's + * `getToolCallTerminalData` produces, not the raw handler output. Without this, + * `generate_api_key`'s freshly minted plaintext key crossed to the model here while + * the redaction held on the other lane. Every other tool is returned unchanged. + */ + const modelOutput = toolResultForModel(toolName, projected.output) return NextResponse.json({ success: projected.success, - ...(projected.output !== undefined ? { output: projected.output } : {}), + ...(modelOutput !== undefined ? { output: modelOutput } : {}), ...(projected.error ? { error: projected.error } : {}), }) } catch (err) { diff --git a/apps/sim/app/api/cron/cleanup-file-versions/route.ts b/apps/sim/app/api/cron/cleanup-file-versions/route.ts new file mode 100644 index 00000000000..a4b312347ce --- /dev/null +++ b/apps/sim/app/api/cron/cleanup-file-versions/route.ts @@ -0,0 +1,26 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('FileVersionCleanupAPI') + +/** GET /api/cron/cleanup-file-versions — dispatch retention for superseded workspace file versions. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const authError = verifyCronAuth(request, 'file version cleanup') + if (authError) return authError + + const result = await dispatchCleanupJobs('cleanup-file-versions') + + logger.info('File version cleanup jobs dispatched', result) + + return NextResponse.json({ triggered: true, ...result }) + } catch (error) { + logger.error('Failed to dispatch file version cleanup jobs:', { error }) + return NextResponse.json({ error: 'Failed to dispatch file version cleanup' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts index e9771609833..3121691a068 100644 --- a/apps/sim/app/api/files/authorization.test.ts +++ b/apps/sim/app/api/files/authorization.test.ts @@ -18,13 +18,21 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetadata } = vi.hoisted( - () => ({ - mockGetFileMetadataByKey: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetFileMetadata: vi.fn(), - }) -) +const { + mockGetFileMetadataByKey, + mockGetUserEntityPermissions, + mockGetFileMetadata, + mockFindWorkspaceFileVersionKeys, +} = vi.hoisted(() => ({ + mockGetFileMetadataByKey: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetFileMetadata: vi.fn(), + mockFindWorkspaceFileVersionKeys: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-versions', () => ({ + findWorkspaceFileVersionKeys: mockFindWorkspaceFileVersionKeys, +})) vi.mock('@/lib/uploads', () => ({ getFileMetadata: mockGetFileMetadata, @@ -257,6 +265,7 @@ describe('workspace-scoped access (workspace files and mothership attachments)', // come from the binding itself rather than a fallback happening to grant. dbChainMockFns.limit.mockResolvedValue([]) mockGetFileMetadata.mockResolvedValue({}) + mockFindWorkspaceFileVersionKeys.mockResolvedValue(new Set()) }) function read(cloudKey: string, context: 'workspace' | 'mothership') { @@ -343,6 +352,25 @@ describe('workspace-scoped access (workspace files and mothership attachments)', await expect(read(ATTACHMENT_KEY, 'workspace')).resolves.toBe(false) }) + it('still authorizes an unbound key from its object metadata', async () => { + mockGetFileMetadataByKey.mockResolvedValue(null) + mockGetFileMetadata.mockResolvedValue({ workspaceId: 'ws-1' }) + mockGetUserEntityPermissions.mockResolvedValue('read') + + await expect(read(ATTACHMENT_KEY, 'workspace')).resolves.toBe(true) + }) + + it('denies a retained version key instead of authorizing it from its object metadata', async () => { + mockGetFileMetadataByKey.mockResolvedValue(null) + mockGetFileMetadata.mockResolvedValue({ workspaceId: 'ws-1' }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockFindWorkspaceFileVersionKeys.mockResolvedValue(new Set([ATTACHMENT_KEY])) + + await expect(read(ATTACHMENT_KEY, 'workspace')).resolves.toBe(false) + expect(mockFindWorkspaceFileVersionKeys).toHaveBeenCalledWith([ATTACHMENT_KEY]) + expect(mockGetFileMetadata).not.toHaveBeenCalled() + }) + it('does not accept a binding whose context is not workspace-scoped', async () => { bindRow({ workspaceId: 'ws-1', diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 647e5b5792b..b1dcde923cb 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -13,6 +13,7 @@ import { import { type KnowledgeReadAccess, knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' import { getFileMetadata } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' +import { findWorkspaceFileVersionKeys } from '@/lib/uploads/contexts/workspace/workspace-file-versions' import type { StorageConfig } from '@/lib/uploads/core/storage-client' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { isWorkspaceScopedContext } from '@/lib/uploads/shared/types' @@ -219,6 +220,18 @@ export async function verifyFileAccess( } } +/** + * A retained file version keeps the storage metadata of the write that created it, so a metadata + * fallback would authorize a replaced or archived file's old bytes by key. Versions are served only + * through the version routes, which authorize against their file, so key-addressed access refuses + * them before any metadata fallback. + */ +async function isRetainedVersionKey(cloudKey: string, userId: string): Promise { + if ((await findWorkspaceFileVersionKeys([cloudKey])).size === 0) return false + logger.warn('File access denied for a retained version key', { userId, cloudKey }) + return true +} + /** * Verify access to workspace files * Priority: Database lookup > Metadata > Deny @@ -269,6 +282,8 @@ async function verifyWorkspaceFileAccess( return false } + if (await isRetainedVersionKey(cloudKey, userId)) return false + // Priority 2: Check metadata (works for both local and cloud files) const config: StorageConfig = customConfig || {} const metadata = await getFileMetadata(cloudKey, config) @@ -745,6 +760,8 @@ async function verifyRegularFileAccess( return false } + if (await isRetainedVersionKey(cloudKey, userId)) return false + // Priority 2: Check metadata (works for both local and cloud files) const config: StorageConfig = customConfig || {} const metadata = await getFileMetadata(cloudKey, config) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 445fe171c63..34af2782b41 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -47,6 +47,9 @@ describe('workspace search route', () => { expect(call.input.signal).toBe(request.signal) expect(call.input.allowPartialResults).toBe(true) expect(call.input.vectorBudgetMs).toBe(3000) + /** A person's search asks for reranking; the use case reranks when a credential exists. */ + expect(call.input.rerankerEnabled).toBe(true) + expect(call.input.rerankerModel).toBe('rerank-v4.0-fast') controller.abort() expect(call.input.signal.aborted).toBe(true) await expect(response.json()).resolves.toEqual({ diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index b1db5e04d39..0e2ed73f3ef 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -7,6 +7,7 @@ import { import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search' +import { DEFAULT_RERANKER_MODEL } from '@/lib/knowledge/reranker-models' import { sourceAuthor } from '@/lib/knowledge/search/author' const DIRECT_SEARCH_VECTOR_BUDGET_MS = 3000 @@ -28,6 +29,13 @@ export const POST = defineInternalJsonRoute({ topK: body.topK, allowPartialResults: true, vectorBudgetMs: DIRECT_SEARCH_VECTOR_BUDGET_MS, + /** + * A person's search is reranked by a cross-encoder whenever the workspace or the platform + * holds a key for one; the use case checks that before spending a call, and reranking stays + * best-effort, so a provider outage leaves the fused order in place. + */ + rerankerEnabled: true, + rerankerModel: DEFAULT_RERANKER_MODEL, surface: 'dashboard' as const, signal: request.signal, }), diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 83217cbd6b2..e0eac74f0a6 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -216,7 +216,8 @@ describe('Knowledge Search Utils', () => { const statement = (query as { toSQL: () => { sql: string } }).toSQL().sql if (statement.includes('AS visible')) return [] if (statement.includes(') + 0 LIMIT')) return [{ id: 'first' }, { id: 'second' }] - if (statement.includes('WITH scored_search_candidates')) + /** The page reads the pool slice's identities; the walk's order is kept client-side. */ + if (statement.includes('AS "connectorId"') && statement.includes('= ANY(')) return [makeResult('second', 0.2), makeResult('first', 0.1)] return [{ id: 'doc-first' }, { id: 'doc-second' }] }) @@ -240,7 +241,7 @@ describe('Knowledge Search Utils', () => { const exact = dbChainMockFns.execute.mock.calls .map(([query]) => (query as { toSQL: () => { sql: string; params: unknown[] } }).toSQL()) .find((statement) => statement.sql.includes(') + 0 LIMIT'))! - expect(exact.params).toContain(400) + expect(exact.params).toContain(200) }) it('should throw error when no filters provided', async () => { diff --git a/apps/sim/app/api/knowledge/slack/setup/connect/route.ts b/apps/sim/app/api/knowledge/slack/setup/connect/route.ts new file mode 100644 index 00000000000..2a9d8ee35c0 --- /dev/null +++ b/apps/sim/app/api/knowledge/slack/setup/connect/route.ts @@ -0,0 +1,19 @@ +import { connectCustomSlackSearchContract } from '@/lib/api/contracts/knowledge/slack' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { connectCustomSlackSearch } from '@/lib/knowledge/application/slack-search/setup' + +export const POST = defineInternalJsonRoute({ + contract: connectCustomSlackSearchContract, + auth: internalSessionAuth, + operation: knowledgeOperations.connectCustomSlackInstallation, + rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: connectCustomSlackSearch, +}) diff --git a/apps/sim/app/api/knowledge/slack/setup/route.test.ts b/apps/sim/app/api/knowledge/slack/setup/route.test.ts index 49c21d524ce..249f5b1aacf 100644 --- a/apps/sim/app/api/knowledge/slack/setup/route.test.ts +++ b/apps/sim/app/api/knowledge/slack/setup/route.test.ts @@ -2,10 +2,14 @@ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn() })) +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn(), connect: vi.fn() })) vi.mock('@/lib/knowledge/application/slack-search/setup', async () => { const { knowledgeOperations } = await import('@/lib/knowledge/application/operations') return { + connectCustomSlackSearch: { + operation: knowledgeOperations.connectCustomSlackInstallation, + execute: mocks.connect, + }, prepareSlackSearchSetup: { operation: knowledgeOperations.prepareSlackInstallation, execute: mocks.prepare, @@ -19,9 +23,15 @@ vi.mock('@/lib/knowledge/application/slack-search/setup', async () => { import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST as start } from '@/app/api/knowledge/slack/oauth/route' +import { POST as connect } from '@/app/api/knowledge/slack/setup/connect/route' import { POST as prepare } from '@/app/api/knowledge/slack/setup/route' -const input = { organizationId: 'organization-1', name: 'Sim Search', description: 'Search' } +const input = { + organizationId: 'organization-1', + name: 'Sim Search', + description: 'Search', + botToken: 'xoxb-existing', +} beforeEach(() => { vi.clearAllMocks() @@ -34,6 +44,7 @@ beforeEach(() => { describe.each([ ['prepare', prepare, mocks.prepare], ['OAuth', start, mocks.start], + ['connect installed app', connect, mocks.connect], ] as const)('Slack %s route errors', (_name, route, execute) => { it('returns application validation errors', async () => { execute.mockRejectedValue( @@ -59,3 +70,23 @@ describe.each([ expect(execute).not.toHaveBeenCalled() }) }) + +it('connects an installed app with the current session and returns no install URL', async () => { + mocks.connect.mockResolvedValueOnce({ organizationId: input.organizationId }) + const response = await connect(createMockRequest('POST', input)) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ organizationId: input.organizationId }) + expect(mocks.connect).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'admin', sessionId: 'session' }, + input, + }) + ) + expect(mocks.start).not.toHaveBeenCalled() +}) + +it.each(['', ' ', undefined])('requires an existing bot token: %s', async (botToken) => { + const response = await connect(createMockRequest('POST', { ...input, botToken })) + expect(response.status).toBe(400) + expect(mocks.connect).not.toHaveBeenCalled() +}) diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index cb70d83805d..36cc51084b6 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -25,6 +25,7 @@ function enterpriseDefaults(): OrganizationRetentionValues { logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise, softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise, taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise, + fileVersionRetentionHours: CLEANUP_CONFIG['cleanup-file-versions'].defaults.enterprise, piiRedaction: null, retentionOverrides: null, } @@ -37,6 +38,7 @@ function normalizeConfigured( logRetentionHours: settings?.logRetentionHours ?? null, softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null, taskCleanupHours: settings?.taskCleanupHours ?? null, + fileVersionRetentionHours: settings?.fileVersionRetentionHours ?? null, piiRedaction: settings?.piiRedaction?.rules ? { rules: settings.piiRedaction.rules.map((rule) => ({ @@ -192,6 +194,9 @@ export const PUT = withRouteHandler( if (body.taskCleanupHours !== undefined) { merged.taskCleanupHours = body.taskCleanupHours } + if (body.fileVersionRetentionHours !== undefined) { + merged.fileVersionRetentionHours = body.fileVersionRetentionHours + } if (body.piiRedaction !== undefined) { merged.piiRedaction = body.piiRedaction } diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts index 97224cb2b25..09657aec72a 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts @@ -9,8 +9,8 @@ * `DATA_RETENTION_ENABLED` (or `ENTERPRISE_ENABLED`) when billing is off. * * Body: any subset of `logRetentionHours`, `softDeleteRetentionHours`, - * `taskCleanupHours`, `piiRedaction`, `retentionOverrides`. Omitted keys keep - * their current value; `null` means "forever" for an hours field. + * `taskCleanupHours`, `fileVersionRetentionHours`, `piiRedaction`, `retentionOverrides`. + * Omitted keys keep their current value; `null` means "forever" for an hours field. * * Response: AdminSingleResponse<{ success, organizationId }> */ @@ -77,6 +77,9 @@ export const PATCH = withRouteHandler( merged.softDeleteRetentionHours = body.softDeleteRetentionHours } if (body.taskCleanupHours !== undefined) merged.taskCleanupHours = body.taskCleanupHours + if (body.fileVersionRetentionHours !== undefined) { + merged.fileVersionRetentionHours = body.fileVersionRetentionHours + } if (body.piiRedaction !== undefined) { merged.piiRedaction = body.piiRedaction diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts index 6849e05ffe6..f2bb5177a65 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts @@ -9,12 +9,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, - mockValidateEnterpriseAuditAccess, + mockValidateV1EnterpriseAuditAccess, mockBuildOrgScopeCondition, mockGetOrgWorkspaceIds, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), - mockValidateEnterpriseAuditAccess: vi.fn(), + mockValidateV1EnterpriseAuditAccess: vi.fn(), mockBuildOrgScopeCondition: vi.fn(), mockGetOrgWorkspaceIds: vi.fn(), })) @@ -25,7 +25,7 @@ vi.mock('@/app/api/v1/middleware', () => ({ })) vi.mock('@/app/api/v1/audit-logs/auth', () => ({ - validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, + validateV1EnterpriseAuditAccess: mockValidateV1EnterpriseAuditAccess, })) vi.mock('@/lib/audit-logs/query', () => ({ @@ -76,8 +76,9 @@ describe('GET /api/v1/audit-logs/[id]', () => { beforeEach(() => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' }) - mockValidateEnterpriseAuditAccess.mockResolvedValue({ + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: true, + userId: 'admin-1', context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS }, }) mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS) @@ -124,4 +125,28 @@ describe('GET /api/v1/audit-logs/[id]', () => { expect(body.data.ipAddress).toBeUndefined() expect(body.data.userAgent).toBeUndefined() }) + + it('returns the refusal for a workspace key without querying', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'admin-1', + keyType: 'workspace', + workspaceId: 'ws-org-1', + }) + const denied = new Response( + JSON.stringify({ error: 'Audit logs require a personal API key' }), + { + status: 403, + } + ) + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied }) + + const response = await callRoute('log-1') + + expect(response.status).toBe(403) + expect(mockValidateV1EnterpriseAuditAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: 'workspace' }) + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index 3ba25fdbfb1..2cfc9b27606 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -22,7 +22,7 @@ import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { validateV1EnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -49,7 +49,6 @@ export const GET = withRouteHandler( return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1GetAuditLogContract, request, context, { validationErrorResponse: () => NextResponse.json({ error: 'Invalid audit log ID' }, { status: 400 }), @@ -58,11 +57,12 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - const authResult = await validateEnterpriseAuditAccess(userId) + const authResult = await validateV1EnterpriseAuditAccess(rateLimit) if (!authResult.success) { return authResult.response } + const { userId } = authResult const { organizationId, orgMemberIds } = authResult.context const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts index d9aa8f48455..0d9230a6e1c 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.test.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts @@ -11,21 +11,34 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsOrganizationBillingBlocked } = vi.hoisted(() => ({ - mockIsOrganizationBillingBlocked: vi.fn(), -})) +const { mockIsOrganizationBillingBlocked, mockCheckOrganizationPersonalKeyRefusal } = vi.hoisted( + () => ({ + mockIsOrganizationBillingBlocked: vi.fn(), + mockCheckOrganizationPersonalKeyRefusal: vi.fn(), + }) +) vi.mock('@/lib/billing/core/access', () => ({ isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked, })) -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +vi.mock('@/app/api/v1/middleware', () => ({ + capabilityGovernedUserId: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null, + checkOrganizationPersonalKeyRefusal: mockCheckOrganizationPersonalKeyRefusal, +})) + +import { + validateEnterpriseAuditAccess, + validateV1EnterpriseAuditAccess, +} from '@/app/api/v1/audit-logs/auth' describe('enterprise audit access', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() mockIsOrganizationBillingBlocked.mockResolvedValue(false) + mockCheckOrganizationPersonalKeyRefusal.mockResolvedValue(null) }) afterAll(() => { @@ -112,4 +125,72 @@ describe('enterprise audit access', () => { }) }) }) + + describe('v1 API-key access', () => { + const personalKey = { + allowed: true, + remaining: 1, + limit: 1, + resetAt: new Date(), + userId: 'viewer', + keyType: 'personal' as const, + } + + beforeEach(() => { + setEnvFlags({ isBillingEnabled: false, isAuditLogsEnabled: true }) + }) + + it('refuses a workspace key before resolving its creator as the subject', async () => { + const result = await validateV1EnterpriseAuditAccess({ + ...personalKey, + keyType: 'workspace', + workspaceId: 'workspace-a', + }) + + if (result.success) throw new Error('Expected the workspace key to be refused') + expect(result.response.status).toBe(403) + await expect(result.response.json()).resolves.toEqual({ + error: 'Audit logs require a personal API key', + }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + expect(mockCheckOrganizationPersonalKeyRefusal).not.toHaveBeenCalled() + }) + + it('authorizes a personal key held by an organization admin', async () => { + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'admin' }]) + queueTableRows(schemaMock.member, [{ userId: 'viewer' }]) + + await expect(validateV1EnterpriseAuditAccess(personalKey)).resolves.toEqual({ + success: true, + userId: 'viewer', + context: { organizationId: 'org-1', orgMemberIds: ['viewer'] }, + }) + expect(mockCheckOrganizationPersonalKeyRefusal).toHaveBeenCalledWith(personalKey) + }) + + it('refuses a personal key its permission group withholds', async () => { + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'admin' }]) + queueTableRows(schemaMock.member, [{ userId: 'viewer' }]) + const refusal = new Response(null, { status: 403 }) + mockCheckOrganizationPersonalKeyRefusal.mockResolvedValue(refusal) + + const result = await validateV1EnterpriseAuditAccess(personalKey) + + if (result.success) throw new Error('Expected the withheld personal key to be refused') + expect(result.response).toBe(refusal) + }) + + it('answers a non-admin with the role refusal, not the group configuration', async () => { + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'member' }]) + mockCheckOrganizationPersonalKeyRefusal.mockResolvedValue(new Response(null, { status: 403 })) + + const result = await validateV1EnterpriseAuditAccess(personalKey) + + if (result.success) throw new Error('Expected the non-admin to be refused') + await expect(result.response.json()).resolves.toEqual({ + error: 'Organization admin or owner role required', + }) + expect(mockCheckOrganizationPersonalKeyRefusal).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 739e1c39918..0679d20b444 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -3,11 +3,20 @@ import { type EnterpriseAuditContext, resolveEnterpriseAuditAccess, } from '@/lib/audit-logs/authorization' +import { + capabilityGovernedUserId, + checkOrganizationPersonalKeyRefusal, + type RateLimitResult, +} from '@/app/api/v1/middleware' type AuthResult = | { success: true; context: EnterpriseAuditContext } | { success: false; response: NextResponse } +type V1AuthResult = + | { success: true; userId: string; context: EnterpriseAuditContext } + | { success: false; response: NextResponse } + /** * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }` * response body. @@ -23,3 +32,37 @@ export async function validateEnterpriseAuditAccess( response: NextResponse.json({ error: result.message }, { status: result.status }), } } + +/** + * Authorizes a v1 API-key read of the organization audit trail with the same + * policy as `auditLogOperations`, which v1 does not route through. + * + * Workspace keys are refused (`workspaceApiKey: 'deny'`): their `userId` is the + * key's creator, so authorizing it would let a credential scoped to one + * workspace read every workspace in the organization whenever its creator is an + * organization admin. A personal key is then held to the user-global + * `personal_api_key.use` group decision, checked after the admin role so the + * refusal never describes an organization's configuration to a non-admin. + */ +export async function validateV1EnterpriseAuditAccess( + rateLimit: RateLimitResult +): Promise { + const userId = capabilityGovernedUserId(rateLimit) + if (!userId) { + return { + success: false, + response: NextResponse.json( + { error: 'Audit logs require a personal API key' }, + { status: 403 } + ), + } + } + + const access = await validateEnterpriseAuditAccess(userId) + if (!access.success) return access + + const personalKeyRefusal = await checkOrganizationPersonalKeyRefusal(rateLimit) + if (personalKeyRefusal) return { success: false, response: personalKeyRefusal } + + return { success: true, userId, context: access.context } +} diff --git a/apps/sim/app/api/v1/audit-logs/route.test.ts b/apps/sim/app/api/v1/audit-logs/route.test.ts index 2644d07132f..3ec68264bff 100644 --- a/apps/sim/app/api/v1/audit-logs/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/route.test.ts @@ -9,14 +9,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, - mockValidateEnterpriseAuditAccess, + mockValidateV1EnterpriseAuditAccess, mockBuildOrgScopeCondition, mockGetOrgWorkspaceIds, mockQueryAuditLogs, mockBuildFilterConditions, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), - mockValidateEnterpriseAuditAccess: vi.fn(), + mockValidateV1EnterpriseAuditAccess: vi.fn(), mockBuildOrgScopeCondition: vi.fn(), mockGetOrgWorkspaceIds: vi.fn(), mockQueryAuditLogs: vi.fn(), @@ -31,7 +31,7 @@ vi.mock('@/app/api/v1/middleware', () => ({ })) vi.mock('@/app/api/v1/audit-logs/auth', () => ({ - validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, + validateV1EnterpriseAuditAccess: mockValidateV1EnterpriseAuditAccess, })) vi.mock('@/lib/audit-logs/query', () => ({ @@ -61,8 +61,9 @@ describe('GET /api/v1/audit-logs', () => { beforeEach(() => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' }) - mockValidateEnterpriseAuditAccess.mockResolvedValue({ + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: true, + userId: 'admin-1', context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS }, }) mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS) @@ -122,11 +123,35 @@ describe('GET /api/v1/audit-logs', () => { it('returns the auth failure response when enterprise access is denied', async () => { const denied = new Response(JSON.stringify({ error: 'nope' }), { status: 403 }) - mockValidateEnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied }) + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied }) const response = await GET(makeRequest('')) expect(response.status).toBe(403) expect(mockQueryAuditLogs).not.toHaveBeenCalled() }) + + it('returns the refusal for a workspace key without querying', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'admin-1', + keyType: 'workspace', + workspaceId: 'ws-org-1', + }) + const denied = new Response( + JSON.stringify({ error: 'Audit logs require a personal API key' }), + { + status: 403, + } + ) + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied }) + + const response = await GET(makeRequest('?workspaceId=ws-org-2')) + + expect(response.status).toBe(403) + expect(mockValidateV1EnterpriseAuditAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: 'workspace' }) + ) + expect(mockQueryAuditLogs).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index e1ddc69d9b1..26498d298a8 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -32,7 +32,7 @@ import { queryAuditLogs, } from '@/lib/audit-logs/query' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { validateV1EnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { @@ -63,13 +63,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! - - const authResult = await validateEnterpriseAuditAccess(userId) + const authResult = await validateV1EnterpriseAuditAccess(rateLimit) if (!authResult.success) { return authResult.response } + const { userId } = authResult const { organizationId, orgMemberIds } = authResult.context const parsed = await parseRequest( diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index 7be0518423e..018e2040a4c 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -14,6 +14,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockResolveV1KnowledgeReadAccess, mockExecuteKnowledgeSearch, + mockRetrievalStatus, mockGenerateSearchEmbedding, mockGetDocumentMetadataByIds, mockGetDocumentTagDefinitions, @@ -25,6 +26,7 @@ const { } = vi.hoisted(() => ({ mockResolveV1KnowledgeReadAccess: vi.fn(), mockExecuteKnowledgeSearch: vi.fn(), + mockRetrievalStatus: vi.fn(() => ({ status: 'complete', timedOutLegs: [] })), mockGenerateSearchEmbedding: vi.fn(), mockGetDocumentMetadataByIds: vi.fn(), mockGetDocumentTagDefinitions: vi.fn(), @@ -54,7 +56,12 @@ vi.mock('@/lib/knowledge/access/availability', () => ({ })) vi.mock('@/lib/knowledge/search/queries', () => ({ - executeKnowledgeSearch: mockExecuteKnowledgeSearch, + /** The route reads the retrieval result; the rows come from the same mock the tests drive. */ + retrieveKnowledgeSearch: async (params: { access: unknown }) => ({ + rows: await mockExecuteKnowledgeSearch(params), + retrieval: mockRetrievalStatus(), + readAccess: params.access, + }), getDocumentMetadataByIds: mockGetDocumentMetadataByIds, })) @@ -139,6 +146,27 @@ describe('v1 knowledge search route — per-KB embedding model', () => { mockRecordSearchEmbeddingUsage.mockResolvedValue(undefined) }) + it('fails a search whose retrieval ran out of time instead of returning partial rows', async () => { + const access = { kind: 'user' as const, userId: 'user-1', tokens: ['reader-token'] } + mockResolveV1KnowledgeReadAccess.mockResolvedValue({ + get: vi.fn().mockResolvedValue(access), + getForConnectors: vi.fn(), + getForDocuments: vi.fn(), + }) + mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-1', 'text-embedding-3-small'), + }) + mockRetrievalStatus.mockReturnValueOnce({ status: 'partial', timedOutLegs: ['vector'] }) + mockExecuteKnowledgeSearch.mockResolvedValue([]) + const response = await POST( + createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: 'kb-1', query: 'hello' }) + ) + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledOnce() + expect(response.status).toBe(500) + expect(mockGetDocumentMetadataByIds).not.toHaveBeenCalled() + }) + it('retains the reader provider for ranked results and returned document metadata', async () => { const access = { kind: 'user' as const, userId: 'user-1', tokens: ['reader-token'] } const provider = { @@ -165,7 +193,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => { accessProvider: provider, }) ) - expect(mockGetDocumentMetadataByIds).toHaveBeenCalledWith([], access, provider) + expect(mockGetDocumentMetadataByIds).toHaveBeenCalledWith([], access) }) it.each([ @@ -224,8 +252,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => { expect(response.status).toBe(200) expect(mockGetDocumentMetadataByIds).toHaveBeenCalledWith( ['revoked-document', 'allowed-document'], - access, - provider + access ) expect(body.data.results).toEqual( allDenied diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index ddb66d241cc..7a63690de18 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -14,10 +14,12 @@ import { type KbEmbeddingTarget, recordSearchEmbeddingUsage, } from '@/lib/knowledge/embeddings' +import { SearchDeadlineError } from '@/lib/knowledge/search/budget' import { resolveKnowledgeSearchDefaults } from '@/lib/knowledge/search/defaults' import { - executeKnowledgeSearch, getDocumentMetadataByIds, + type KnowledgeRetrievalResult, + retrieveKnowledgeSearch, type SearchResult, } from '@/lib/knowledge/search/queries' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' @@ -226,7 +228,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } : undefined - let results: SearchResult[] + let retrieved: KnowledgeRetrievalResult let queryEmbeddingIsBYOK: boolean | null = null const [readAccess, { searchMode, boostRecency }] = await Promise.all([ resolveV1KnowledgeReadAccess(userId, rateLimit, workspaceId), @@ -242,7 +244,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = 'get' in readAccess ? await readAccess.get() : readAccess if (!hasQuery && hasFilters) { - results = await executeKnowledgeSearch({ + retrieved = await retrieveKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK, access, @@ -258,7 +260,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId ) queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - results = await executeKnowledgeSearch({ + retrieved = await retrieveKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK, access, @@ -311,8 +313,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { tagDefinitionsMap[kbId] = map }) + /** v1 cannot express an incomplete search, so a leg that ran out of time fails the request. */ + if (retrieved.retrieval.status === 'partial') throw new SearchDeadlineError() + const results = retrieved.rows const documentIds = results.map((r) => r.documentId) - const documentMetadataMap = await getDocumentMetadataByIds(documentIds, access, accessProvider) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds, retrieved.readAccess) const readableResults = results.filter((result) => documentMetadataMap[result.documentId]) return NextResponse.json({ diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index 3c92fdb58cd..7f06854ab59 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -30,6 +30,7 @@ const { mockGetUserEntityPermissions, mockGetWorkspaceBillingSettings, mockGetWorkspaceBilledAccountUserId, + mockIsCapabilityWithheldForUser, } = vi.hoisted(() => ({ mockAuthenticateV1Request: vi.fn(), mockGetSubscription: vi.fn(), @@ -38,6 +39,7 @@ const { mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceBillingSettings: vi.fn(), mockGetWorkspaceBilledAccountUserId: vi.fn(), + mockIsCapabilityWithheldForUser: vi.fn(), })) vi.mock('@/app/api/v1/auth', () => ({ @@ -57,6 +59,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({ vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/lib/permission-groups/user-scope.server', () => ({ + isCapabilityWithheldForUser: mockIsCapabilityWithheldForUser, +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) @@ -69,6 +75,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { authenticateRequest, + checkOrganizationPersonalKeyRefusal, checkRateLimit, checkWorkspaceScope, createRateLimitResponse, @@ -425,6 +432,48 @@ describe('checkWorkspaceScope', () => { }) }) +describe('checkOrganizationPersonalKeyRefusal', () => { + const USER_ID = 'user-1' + const BASE = { allowed: true, remaining: 1, limit: 1, resetAt: new Date(), userId: USER_ID } + + beforeEach(() => { + vi.clearAllMocks() + mockIsCapabilityWithheldForUser.mockResolvedValue(false) + }) + + it("refuses a personal key its user-global group withholds, with the group's detail code", async () => { + mockIsCapabilityWithheldForUser.mockResolvedValue(true) + + const response = await checkOrganizationPersonalKeyRefusal({ ...BASE, keyType: 'personal' }) + + expect(mockIsCapabilityWithheldForUser).toHaveBeenCalledWith(USER_ID, 'personal_api_key.use') + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ + error: expect.stringMatching(/personal API key/i), + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + }) + + it('allows a personal key its group does not withhold', async () => { + await expect( + checkOrganizationPersonalKeyRefusal({ ...BASE, keyType: 'personal' }) + ).resolves.toBeNull() + }) + + it("never evaluates a workspace key against its creator's group", async () => { + mockIsCapabilityWithheldForUser.mockResolvedValue(true) + + const response = await checkOrganizationPersonalKeyRefusal({ + ...BASE, + keyType: 'workspace', + workspaceId: 'workspace-a', + }) + + expect(response).toBeNull() + expect(mockIsCapabilityWithheldForUser).not.toHaveBeenCalled() + }) +}) + describe('requireWorkspaceRequestActor', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index d390a2403f9..9c9aa5110b3 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -19,6 +19,7 @@ import { capabilityRefusal, isWorkspaceCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId, @@ -512,6 +513,34 @@ export async function checkWorkspaceScope( return failure ? workspaceAccessErrorResponse(failure) : null } +/** + * The `personal_api_key.use` refusal for a v1 surface that authorizes against + * an organization rather than a workspace, such as the audit log. + * + * {@link checkWorkspaceScope} has no workspace to key the group decision on + * there, so this applies the user-global form, which falls back to the + * organization's default group — the same decision the v2 audit-log use case + * makes. Call it only after the caller's organization role verified, for the + * same disclosure reason {@link resolvePersonalKeyGroupRefusal} runs after the + * workspace role. + */ +export async function checkOrganizationPersonalKeyRefusal( + rateLimit: RateLimitResult +): Promise { + const governedUserId = capabilityGovernedUserId(rateLimit) + if (!governedUserId) return null + + // permission-group-enforced: personal_api_key.use — organization-scoped v1 surfaces have no workspace for the funnel to key on + if (!(await isCapabilityWithheldForUser(governedUserId, 'personal_api_key.use'))) return null + + return workspaceAccessErrorResponse({ + status: 403, + code: 'FORBIDDEN', + message: PERSONAL_KEY_DENIED, + details: { code: CAPABILITY_RULES['personal_api_key.use'].detailCode }, + }) +} + /** * The response a surface that conceals an inaccessible workspace should answer * a {@link resolveWorkspaceAccess} failure with. diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index f829f3a44d6..6c160f29915 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -191,6 +191,8 @@ describe('PUT /api/v2/files/[fileId]/content', () => { uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-03T00:00:00.000Z', deletedAt: null, + /** The token for the content this write produced, for the caller's next conditional write. */ + revision: expect.any(String), }, }) expect(mocks.updateContent).toHaveBeenCalledWith({ diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 673e0a3d3ed..55c405528e1 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -5,6 +5,7 @@ import { import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { editWorkspaceFileContent } from '@/lib/workspace-files/application/edit-workspace-file-content' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' import { fileOperations } from '@/lib/workspace-files/application/operations' import { admitUpdateWorkspaceFileContent, @@ -36,9 +37,12 @@ export const PUT = defineV2JsonRoute({ assertedWorkspaceId: body.workspaceId, content: body.content, encoding: body.encoding, + expectedRevision: body.expectedRevision, }), useCase: updateWorkspaceFileContent, - present: async ({ file }) => ({ data: await toV2File(file) }), + present: async ({ file }) => ({ + data: { ...(await toV2File(file)), ...workspaceFileRevisionField(file) }, + }), }) /** @@ -70,7 +74,10 @@ export const PATCH = defineV2JsonRoute({ fileId: params.fileId, assertedWorkspaceId: body.workspaceId, edit: body.edit, + expectedRevision: body.expectedRevision, }), useCase: editWorkspaceFileContent, - present: async ({ file, lineCount }) => ({ data: { file: await toV2File(file), lineCount } }), + present: async ({ file, lineCount }) => ({ + data: { file: await toV2File(file), lineCount, ...workspaceFileRevisionField(file) }, + }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 1cc17405ad3..6efbae5b9e3 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ - readWorkspaceFileMetadata: { + readWorkspaceFileMetadataWithVersion: { operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' }, execute: mocks.readMetadata, }, @@ -83,7 +83,10 @@ const callGet = (query: string) => */ const archivedFileUseCase = async ({ input }: { input: { includeDeleted?: boolean } }) => { if (!input.includeDeleted) throw new OrchestrationError('not_found', 'File not found') - return { file: { ...buildRecord(), deletedAt: new Date('2024-01-03T00:00:00Z') }, share: SHARE } + return { + file: { ...buildRecord(), deletedAt: new Date('2024-01-03T00:00:00Z'), currentVersion: 3 }, + share: SHARE, + } } describe('GET /api/v2/files/[fileId]/metadata', () => { @@ -92,7 +95,10 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { v2RouteMocks.authenticate.mockResolvedValue(auth) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.readMetadata.mockResolvedValue({ file: buildRecord(), share: SHARE }) + mocks.readMetadata.mockResolvedValue({ + file: { ...buildRecord(), currentVersion: 3 }, + share: SHARE, + }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) @@ -141,6 +147,8 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { updatedAt: '2024-01-02T00:00:00.000Z', deletedAt: null, share: SHARE, + currentVersion: 3, + revision: expect.any(String), }, }) expect(mocks.readMetadata).toHaveBeenCalledWith({ @@ -198,6 +206,8 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { updatedAt: '2024-01-02T00:00:00.000Z', deletedAt: '2024-01-03T00:00:00.000Z', share: SHARE, + currentVersion: 3, + revision: expect.any(String), }, }) expect(mocks.readMetadata).toHaveBeenCalledWith( @@ -237,7 +247,10 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { }) it('returns a null share when the file has no share configuration', async () => { - mocks.readMetadata.mockResolvedValueOnce({ file: buildRecord(), share: null }) + mocks.readMetadata.mockResolvedValueOnce({ + file: { ...buildRecord(), currentVersion: 3 }, + share: null, + }) const response = await callGet(`workspaceId=${WORKSPACE_ID}`) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 722d125e4da..02a57449037 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,8 +1,9 @@ import { v2GetFileContract } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { readWorkspaceFileMetadataWithVersion } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { toV2File } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' @@ -28,6 +29,13 @@ export const GET = defineV2JsonRoute({ assertedWorkspaceId: query.workspaceId, includeDeleted: query.scope === 'archived', }), - useCase: readWorkspaceFileMetadata, - present: async ({ file, share }) => ({ data: { ...(await toV2File(file)), share } }), + useCase: readWorkspaceFileMetadataWithVersion, + present: async ({ file, share }) => ({ + data: { + ...(await toV2File(file)), + share, + currentVersion: file.currentVersion, + ...workspaceFileRevisionField(file), + }, + }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/text/route.ts b/apps/sim/app/api/v2/files/[fileId]/text/route.ts index ded0298ecf0..2e88aef2f18 100644 --- a/apps/sim/app/api/v2/files/[fileId]/text/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/text/route.ts @@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text' +import { toV2FileText } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' @@ -39,18 +40,5 @@ export const GET = defineV2JsonRoute({ limit: query.limit, }), useCase: readWorkspaceFileText, - present: ({ file, text, truncated, degraded, degradedReason, byteCount, lineRange }) => ({ - data: { - fileId: file.id, - name: file.name, - type: file.type, - text, - truncated, - degraded, - degradedReason, - charCount: text.length, - byteCount, - ...(lineRange ? { lineRange } : {}), - }, - }), + present: (result) => ({ data: toV2FileText(result) }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts new file mode 100644 index 00000000000..d2f978f1572 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts @@ -0,0 +1,38 @@ +import { v2DownloadFileVersionContract } from '@/lib/api/contracts/v2/file-versions' +import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { downloadWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { encodeFilenameForHeader } from '@/app/api/files/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/files/[fileId]/versions/[version]/content — Download one version's bytes. + * + * Served exactly as `GET /api/v2/files/[fileId]` serves the current bytes: ordinary files stream, + * and a generated document resolves to its rendered artifact (`CONFLICT` while it compiles). + * + * `headSafe: false` because downloading records a `FILE_DOWNLOADED` audit event. + */ +export const GET = defineV2BinaryRoute({ + contract: v2DownloadFileVersionContract, + auth: v2ApiKeyAuth, + headSafe: false, + operation: fileOperations.downloadVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + version: params.version, + }), + useCase: downloadWorkspaceFileVersion, + present: ({ file, stream, contentType, contentLength }) => ({ + body: stream, + contentType, + contentDisposition: `attachment; ${encodeFilenameForHeader(file.name)}`, + contentLength, + }), +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.test.ts new file mode 100644 index 00000000000..5de5043dff7 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + revertVersion: vi.fn(), + getUserEmailsByIds: vi.fn(), + findUserEmailsByIds: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/file-versions', () => ({ + revertWorkspaceFileVersion: { + operation: { id: 'files.versions.revert', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.revertVersion, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) + +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + findUserEmailsByIds: mocks.findUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) + +import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision' +import { POST } from '@/app/api/v2/files/[fileId]/versions/[version]/revert/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const record = { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 8, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-03T00:00:00Z'), + contentUpdatedAt: new Date('2024-01-04T00:00:00Z'), +} + +const versionRecord = { + fileId: FILE_ID, + version: 4, + isCurrent: true, + size: 8, + contentType: 'text/csv', + source: 'revert' as const, + authorUserIds: ['user-1'], + restoredFromVersion: 2, + createdAt: new Date('2024-01-04T00:00:00Z'), + updatedAt: new Date('2024-01-04T00:00:00Z'), + supersededAt: null, +} + +const callRevert = (body: unknown) => + POST( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/versions/2/revert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ fileId: FILE_ID, version: '2' }) } + ) + +describe('POST /api/v2/files/[fileId]/versions/[version]/revert', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.revertVersion.mockResolvedValue({ + file: record, + version: versionRecord, + reverted: true, + revertedFrom: 3, + }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + mocks.findUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + }) + + /** + * A revert consumes the caller's revision, so the response has to issue its replacement — + * otherwise chaining a second conditional write needs a metadata re-read, and the window + * between the two is exactly what the revision is meant to close. + */ + it('returns the revision naming the content the revert produced', async () => { + const expectedRevision = workspaceFileRevision(record)! + + const response = await callRevert({ workspaceId: WORKSPACE_ID, expectedRevision }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.reverted).toBe(true) + expect(body.data.revision).toBe(expectedRevision) + expect(mocks.revertVersion).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + version: 2, + expectedRevision, + }), + }) + ) + }) + + it('returns the current content revision when the version was already current', async () => { + mocks.revertVersion.mockResolvedValue({ + file: record, + version: { ...versionRecord, version: 3, source: 'api', restoredFromVersion: null }, + reverted: false, + revertedFrom: 3, + }) + + const body = await (await callRevert({ workspaceId: WORKSPACE_ID })).json() + + expect(body.data.reverted).toBe(false) + expect(body.data.revision).toBe(workspaceFileRevision(record)) + }) + + it('forwards the caller revision precondition to the use case', async () => { + const expectedRevision = workspaceFileRevision(record)! + + await callRevert({ workspaceId: WORKSPACE_ID, expectedRevision }) + + expect(mocks.revertVersion).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + version: 2, + expectedRevision, + }), + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.ts b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.ts new file mode 100644 index 00000000000..672b4150354 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.ts @@ -0,0 +1,46 @@ +import { v2RevertFileVersionContract } from '@/lib/api/contracts/v2/file-versions' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +import { revertWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2File, toV2FileVersion } from '@/app/api/v2/files/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/[fileId]/versions/[version]/revert — Make a version's content current again. + * + * Writes the version's bytes as a new version, so the revert can itself be reverted. Reverting to + * the current version is a no-op that reports `reverted: false`. + * + * A revert invalidates the revision the caller guarded it with, so the response carries the one + * naming the content the file now holds. + */ +export const POST = defineV2JsonRoute({ + contract: v2RevertFileVersionContract, + auth: v2ApiKeyAuth, + operation: fileOperations.revertVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + version: params.version, + expectedCurrentVersion: body.expectedCurrentVersion, + expectedRevision: body.expectedRevision, + }), + useCase: revertWorkspaceFileVersion, + present: async ({ file, version, reverted }) => { + const [v2File, v2Version] = await Promise.all([toV2File(file), toV2FileVersion(version)]) + return { + data: { + reverted, + file: v2File, + version: v2Version, + ...workspaceFileRevisionField(file), + }, + } + }, +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/route.ts b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/route.ts new file mode 100644 index 00000000000..be7b535ae24 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/route.ts @@ -0,0 +1,47 @@ +import { + v2DeleteFileVersionContract, + v2GetFileVersionContract, +} from '@/lib/api/contracts/v2/file-versions' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { + deleteWorkspaceFileVersion, + readWorkspaceFileVersion, +} from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileVersion } from '@/app/api/v2/files/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/files/[fileId]/versions/[version] — Read one version's metadata. */ +export const GET = defineV2JsonRoute({ + contract: v2GetFileVersionContract, + auth: v2ApiKeyAuth, + operation: fileOperations.readVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + version: params.version, + }), + useCase: readWorkspaceFileVersion, + present: async ({ version }) => ({ data: await toV2FileVersion(version) }), +}) + +/** DELETE /api/v2/files/[fileId]/versions/[version] — Permanently delete a superseded version. */ +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteFileVersionContract, + auth: v2ApiKeyAuth, + operation: fileOperations.deleteVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + version: params.version, + }), + useCase: deleteWorkspaceFileVersion, + present: ({ file, version }) => ({ data: { fileId: file.id, version, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/text/route.ts b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/text/route.ts new file mode 100644 index 00000000000..6caf4f9e531 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/text/route.ts @@ -0,0 +1,32 @@ +import { v2ReadFileVersionTextContract } from '@/lib/api/contracts/v2/file-versions' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { readWorkspaceFileVersionText } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileText } from '@/app/api/v2/files/utils' + +export const dynamic = 'force-dynamic' + +/** + * GET /api/v2/files/[fileId]/versions/[version]/text — extract one version's text. + * + * Extracts exactly as `GET /api/v2/files/[fileId]/text` does, including rendering a generated + * document before parsing it. Head-safe for the same reason: nothing is audited or written. + */ +export const GET = defineV2JsonRoute({ + contract: v2ReadFileVersionTextContract, + auth: v2ApiKeyAuth, + operation: fileOperations.readVersionContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + version: params.version, + maxBytes: query.maxBytes, + offset: query.offset, + limit: query.limit, + }), + useCase: readWorkspaceFileVersionText, + present: (result) => ({ data: { ...toV2FileText(result), version: result.version.version } }), +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/route.ts b/apps/sim/app/api/v2/files/[fileId]/versions/route.ts new file mode 100644 index 00000000000..999323890d2 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/versions/route.ts @@ -0,0 +1,50 @@ +import { v2ListFileVersionsContract } from '@/lib/api/contracts/v2/file-versions' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { listWorkspaceFileVersions } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileVersions } from '@/app/api/v2/files/utils' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Binds a cursor to the file whose history it pages. Version numbers restart at 1 per file, so an + * unbound cursor would resume another file's history at the same number. + */ +function versionCursorFilters(fileId: string) { + return cursorScopeKey(cursorRoute(v2ListFileVersionsContract, { fileId })) +} + +/** GET /api/v2/files/[fileId]/versions — List a file's versions, newest first by default. */ +export const GET = defineV2JsonRoute({ + contract: v2ListFileVersionsContract, + auth: v2ApiKeyAuth, + operation: fileOperations.listVersions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + versionCursorFilters(params.fileId) + ), + }), + useCase: listWorkspaceFileVersions, + present: async ({ versions, nextKeys }, { params, query }) => ({ + data: await toV2FileVersions(versions), + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + versionCursorFilters(params.fileId) + ), + }), +}) diff --git a/apps/sim/app/api/v2/files/utils.test.ts b/apps/sim/app/api/v2/files/utils.test.ts new file mode 100644 index 00000000000..8af46a22b96 --- /dev/null +++ b/apps/sim/app/api/v2/files/utils.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFindUserEmailsByIds } = vi.hoisted(() => ({ mockFindUserEmailsByIds: vi.fn() })) + +vi.mock('@/lib/users/queries', () => ({ + findUserEmailsByIds: mockFindUserEmailsByIds, + getUserEmailsByIds: vi.fn(), + requireResolvedUserEmail: vi.fn(), +})) + +import type { WorkspaceFileVersionRecord } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { toV2FileVersions } from '@/app/api/v2/files/utils' + +const version: WorkspaceFileVersionRecord = { + fileId: 'file-1', + version: 2, + key: 'workspace/ws/2-notes.md', + size: 10, + contentType: 'text/markdown', + source: 'collab', + authorUserIds: ['user-1', 'deleted-user'], + restoredFromVersion: null, + isCurrent: true, + createdAt: new Date('2026-01-02T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:05:00Z'), + supersededAt: null, +} + +describe('toV2FileVersions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps authors whose accounts no longer exist, with a null email', async () => { + mockFindUserEmailsByIds.mockResolvedValueOnce(new Map([['user-1', 'ada@example.com']])) + + const [serialized] = await toV2FileVersions([version]) + + expect(serialized.authors).toEqual([ + { id: 'user-1', email: 'ada@example.com' }, + { id: 'deleted-user', email: null }, + ]) + expect(serialized).not.toHaveProperty('secretProvenance') + expect(serialized).not.toHaveProperty('key') + }) +}) diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index 66c1872f1dc..124050edfc8 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -1,9 +1,16 @@ -import type { V2File } from '@/lib/api/contracts/v2/files' +import type { V2FileVersion } from '@/lib/api/contracts/v2/file-versions' +import type { V2File, V2FileText } from '@/lib/api/contracts/v2/files' import { getBaseUrl } from '@/lib/core/utils/urls' import { buildFolderPath } from '@/lib/folders/paths' import { workspaceResourceWebUrl } from '@/lib/resources' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' +import type { WorkspaceFileVersionRecord } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { + findUserEmailsByIds, + getUserEmailsByIds, + requireResolvedUserEmail, +} from '@/lib/users/queries' +import type { ReadWorkspaceFileTextResult } from '@/lib/workspace-files/application/read-workspace-file-text' import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' /** Shared serialization for the v2 files surface. */ @@ -59,3 +66,59 @@ export async function toV2Files(records: WorkspaceFileRecord[]): Promise +): V2FileVersion { + return { + fileId: record.fileId, + version: record.version, + isCurrent: record.isCurrent, + size: record.size, + contentType: record.contentType, + source: record.source, + authors: record.authorUserIds.map((id) => ({ id, email: emailByUserId.get(id) ?? null })), + restoredFromVersion: record.restoredFromVersion, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + supersededAt: record.supersededAt?.toISOString() ?? null, + } +} + +/** Serializes file versions, resolving every author's email in one query. */ +export async function toV2FileVersions( + records: WorkspaceFileVersionRecord[] +): Promise { + const emailByUserId = await findUserEmailsByIds(records.flatMap((record) => record.authorUserIds)) + return records.map((record) => serializeV2FileVersion(record, emailByUserId)) +} + +export async function toV2FileVersion(record: WorkspaceFileVersionRecord): Promise { + const [version] = await toV2FileVersions([record]) + return version +} diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 117fa5bfa35..2f1e1af27ba 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { V2ApiTable, V2EnrichmentProviderOutcome, @@ -238,11 +239,6 @@ function storedNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } -/** Reads a stored field that the published shape declares as a nullable string. */ -function storedNullableString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - /** * Reads a stored timestamp, keeping only a value the published `date-time` * format will accept. A Postgres literal or a half-written blob becomes `null` @@ -257,13 +253,13 @@ function storedTimestamp(value: unknown): string | null { function toApiEnrichmentProvider(value: unknown): V2EnrichmentProviderOutcome { const provider = (value ?? {}) as Record return { - id: storedNullableString(provider.id) ?? '', - label: storedNullableString(provider.label) ?? '', - toolId: storedNullableString(provider.toolId) ?? '', - status: storedNullableString(provider.status) ?? 'not_run', + id: toStringOrNull(provider.id) ?? '', + label: toStringOrNull(provider.label) ?? '', + toolId: toStringOrNull(provider.toolId) ?? '', + status: toStringOrNull(provider.status) ?? 'not_run', cost: storedNumber(provider.cost), durationMs: storedNumber(provider.durationMs), - error: storedNullableString(provider.error), + error: toStringOrNull(provider.error), } } @@ -287,7 +283,7 @@ export function toApiEnrichmentDetail( completedAt: storedTimestamp(stored.completedAt), durationMs: storedNumber(stored.durationMs), totalCost: storedNumber(stored.totalCost), - matchedProvider: storedNullableString(stored.matchedProvider), + matchedProvider: toStringOrNull(stored.matchedProvider), aborted: stored.aborted === true, providers: Array.isArray(stored.providers) ? stored.providers.map(toApiEnrichmentProvider) : [], } diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index e6a8332ef7b..4749438df7f 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -86,7 +86,7 @@ export function PublicFileView({
{provenance ? ( - {provenance} + {provenance} ) : null}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index 2915b67c981..b349e283201 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -12,6 +12,7 @@ import { SidebarSection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' +import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' import { @@ -78,8 +79,7 @@ function ChatRow({ ) : undefined } > - + ) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx index faae4ebd437..3cb648f6f33 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx @@ -18,7 +18,10 @@ import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context- import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' -import { SidebarRowActions } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' +import { + SidebarRowAction, + SidebarRowActions, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' import { useFlyoutInlineRename } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename' import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu' import { useToggleWorkspacePin, useUpdateWorkspace } from '@/hooks/queries/workspace' @@ -198,15 +201,13 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis ) : undefined } > - + ) diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx index ddf950eb08b..e61511d7e27 100644 --- a/apps/sim/app/o/[organizationId]/search/search.test.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx @@ -135,7 +135,7 @@ async function editDraft(value: string) { function expectVisibleQuery(query: string) { expect(searchInput().value).toBe(query) expect(container.querySelector('a[data-source-link]')?.textContent).toBe(`${query} launch plan`) - expect(mocks.search).toHaveBeenLastCalledWith(scope, query, {}) + expect(mocks.search).toHaveBeenLastCalledWith(scope, query, {}, 20) expect(document.activeElement).toBe(searchInput()) } diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx index a350cb46405..0122377ac84 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ configure: vi.fn(), remove: vi.fn(), install: vi.fn(), + connect: vi.fn(), refetch: vi.fn(), copy: vi.fn(), removeError: null as Error | null, @@ -25,6 +26,7 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: mocks.context, })) vi.mock('@/hooks/queries/slack-search', () => ({ + useConnectCustomSlackSearch: () => ({ mutate: mocks.connect, isPending: false, reset: vi.fn() }), useSlackSearchInstallations: mocks.list, useSlackSearchManifest: mocks.manifest, useConfigureSlackSearch: () => ({ mutate: mocks.configure, isPending: false }), @@ -395,8 +397,17 @@ describe('Slack Search settings and shared wizard', () => { document.querySelectorAll('input[placeholder="Leave blank to keep the saved value"]') ).toHaveLength(3) await click('Continue') - await click('Reconnect in Slack') - expect(mocks.install).toHaveBeenCalledWith( + expect(button('Connect app')).toBeDisabled() + await act(async () => { + const input = document.querySelector('input[placeholder="xoxb-..."]')! + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'xoxb-installed' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await click('Connect app') + expect(mocks.connect).toHaveBeenCalledWith( expect.objectContaining({ installationId: 'installation-1', organizationId: 'org-1', @@ -404,7 +415,8 @@ describe('Slack Search settings and shared wizard', () => { }), expect.any(Object) ) - expect(mocks.install.mock.calls[0][0]).not.toHaveProperty('clientSecret') + expect(mocks.connect.mock.calls[0][0]).not.toHaveProperty('clientSecret') + expect(mocks.install).not.toHaveBeenCalled() }) it('keeps the update action available when clipboard access fails', async () => { diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx index 731d2528983..df4fe94a113 100644 --- a/apps/sim/app/oauth/credential-connected/page.tsx +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -1,4 +1,4 @@ -import { ChipLink } from '@sim/emcn' +import { ChipLink, StatusPageContent } from '@sim/emcn' import type { Metadata } from 'next' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { LogoShell } from '@/app/(landing)/components/logo-shell' @@ -22,19 +22,18 @@ export default async function CredentialConnectedPage({ return ( -
-

- {connected ? 'Credential connected' : 'Connection failed'} -

-

- {connected + - + : 'The credential could not be connected. Return to the app that started the connection and try again.' + } + > + Open Sim -

+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts deleted file mode 100644 index d416af43b67..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { chipFieldSurfaceClass, chipFieldTextClass, cn } from '@sim/emcn' - -/** Pill wrapper. Override height/alignment (e.g. a textarea) via `cn`. */ -export const CHIP_FIELD_SHELL = cn('flex h-[30px] items-center gap-1.5 px-2', chipFieldSurfaceClass) - -/** Borderless input/textarea hosted inside {@link CHIP_FIELD_SHELL}. */ -export const CHIP_FIELD_INPUT = cn('h-full w-full bg-transparent', chipFieldTextClass) diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts index 3a4d337771d..89e6b5508fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts @@ -1,5 +1,4 @@ export { AddPeopleModal } from './components/add-people-modal' -export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field' export { CredentialDetailHeading } from './components/credential-detail-heading' export { CredentialDetailLayout } from './components/credential-detail-layout' export { CredentialMembersSection } from './components/credential-members-section' diff --git a/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx b/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx index 41062a49b00..5cbf9366999 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx @@ -1,3 +1,5 @@ +import { escapeRegExp } from '@sim/utils/string' + interface SearchHighlightProps { text: string searchQuery: string @@ -18,7 +20,7 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig .trim() .split(/\s+/) .filter((term) => term.length > 0) - .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .map(escapeRegExp) if (searchTerms.length === 0) { return {text} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx index b3d4606b954..c99164e58e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx @@ -28,7 +28,7 @@ function ChartErrorCard({ message, content }: { message: string; content: string {message}
-
+        
           {content}
         
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx index a3e74315364..ecdd766ee21 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx @@ -38,7 +38,7 @@ export const CsvTablePreview = memo(function CsvTablePreview({ if (data.headers.length === 0) { return (
-

No data to display

+

No data to display

) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict.tsx index 0c0cfc85813..481c4869f7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict.tsx @@ -18,7 +18,7 @@ export function FileSaveConflict({ return (

Saving paused: the file changed elsewhere. Your local draft is preserved. Reload replaces it diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index f8d82bf4e27..52407bbf750 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -327,7 +327,7 @@ const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({ return (

-
+      
         {content}
       
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx index 1eb77d5bafe..10019822f72 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx @@ -57,7 +57,7 @@ function MermaidSourcePreview({ )}
-
+        
           {definition}
         
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx index 43aaa175d3f..598e330ca5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx @@ -44,7 +44,7 @@ function PdfError({ error }: { error: string }) { return (

Failed to preview PDF

-

{error}

+

{error}

) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 66521f3e680..e7eb85188a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -502,7 +502,7 @@ const CsvPreview = memo(function CsvPreview({ if (headers.length === 0) { return (
-

No data to display

+

No data to display

) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx index 03752aa49b7..ed585e4c382 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx @@ -21,7 +21,7 @@ export const UnsupportedPreview = memo(function UnsupportedPreview({ name }: { n

Preview not available{ext ? ` for .${ext} files` : ' for this file'}

-

+

Use the download button to view this file

@@ -32,7 +32,7 @@ export function PreviewError({ label, error }: { label: string; error: string }) return (

Failed to preview {label}

-

{error}

+

{error}

) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 693cd1360c1..4ca5b4c8f72 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -776,7 +776,7 @@ export const TextEditor = memo(function TextEditor({ if (hasContentError) { return (
-

Failed to load file content

+

Failed to load file content

) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx index 11e4316c92e..83025fa92bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx @@ -138,7 +138,7 @@ export const XlsxPreview = memo(function XlsxPreview({
{(currentSheet.rowTruncated || currentSheet.columnTruncated) && ( -

+

{currentSheet.rowTruncated && currentSheet.columnTruncated ? `Showing first ${XLSX_MAX_ROWS.toLocaleString()} rows and ${XLSX_MAX_COLUMNS.toLocaleString()} columns.` : currentSheet.rowTruncated diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx index c4a857f3c7d..93e3ba1f46d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx @@ -48,10 +48,13 @@ afterEach(() => { act(() => root.unmount()) vi.unstubAllGlobals() }) -async function render(scope: ResourceScope = { kind: 'workspace', workspaceId: 'workspace' }) { +async function render( + scope: ResourceScope = { kind: 'workspace', workspaceId: 'workspace' }, + searchParams = '' +) { await act(async () => root.render( - + ) @@ -148,3 +151,133 @@ describe('source setup navigation', () => { expect(container.querySelector('a')?.getAttribute('href')).toBe(href) }) }) + +describe('result paging and the custom window', () => { + const result = (n: number) => ({ + documentId: `doc-${n}`, + knowledgeBaseId: 'kb', + knowledgeBaseName: 'Index', + documentName: `Document ${n}`, + sourceUrl: null, + connectorType: 'slack', + sourceModifiedAt: null, + author: null, + content: 'launch notes', + chunkIndex: 0, + similarity: 0.5, + }) + + it('offers more only after a full first page, and asks for the wider search on request', async () => { + mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } }) + const page = (length: number) => ({ + data: { + query: 'launch', + results: Array.from({ length }, (_, n) => result(n)), + retrieval: { status: 'complete', timedOutLegs: [] }, + }, + isPending: false, + isFetching: false, + isPlaceholderData: false, + isError: false, + refetch: mocks.retry, + }) + mocks.search.mockReturnValue(page(20)) + await render() + expect(mocks.search.mock.calls.at(-1)![3]).toBe(20) + const more = () => + [...container.querySelectorAll('button')].find((b) => b.textContent === 'Show more') + expect(more()).toBeDefined() + await act(async () => more()!.click()) + /** The wider search is its own request; the first paint was never widened. */ + expect(mocks.search.mock.calls.at(-1)![3]).toBe(50) + expect(more()).toBeUndefined() + mocks.search.mockReturnValue(page(7)) + await render() + expect(more()).toBeUndefined() + }) + + it('starts a refined search over at the first page after the reader asked for more', async () => { + mocks.overview.mockReturnValue({ + data: { + providers: [{ connectorType: 'slack', isSyncing: false }], + hasSearchableDocuments: true, + }, + }) + mocks.search.mockReturnValue({ + data: { + query: 'launch', + results: Array.from({ length: 20 }, (_, n) => result(n)), + retrieval: { status: 'complete', timedOutLegs: [] }, + }, + isPending: false, + isFetching: false, + isPlaceholderData: false, + isError: false, + refetch: mocks.retry, + }) + await render() + const button = (label: string) => + [...container.querySelectorAll('button')].find((b) => b.textContent === label)! + await act(async () => button('Show more').click()) + expect(mocks.search.mock.calls.at(-1)![3]).toBe(50) + await act(async () => button('Slack').click()) + expect(mocks.search.mock.calls.at(-1)![2]).toEqual({ source: 'slack' }) + expect(mocks.search.mock.calls.at(-1)![3]).toBe(20) + }) + + it('drops the custom days when another window is chosen', async () => { + mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } }) + mocks.search.mockReturnValue({ + data: { query: 'launch', results: [], retrieval: { status: 'complete', timedOutLegs: [] } }, + isPending: false, + isFetching: false, + isPlaceholderData: false, + isError: false, + refetch: mocks.retry, + }) + await render(undefined, '?updated=custom&from=2026-09-01&to=2026-09-10') + expect(mocks.search.mock.calls.at(-1)![2]).toHaveProperty('modifiedBefore') + const anyTime = [...container.querySelectorAll('button')].find( + (b) => b.textContent === 'Any time' + )! + await act(async () => anyTime.click()) + expect(mocks.search.mock.calls.at(-1)![2]).toEqual({}) + }) + + it('searches nothing while a custom window has no days yet', async () => { + mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } }) + mocks.search.mockReturnValue({ + data: undefined, + isPending: true, + isFetching: false, + isPlaceholderData: false, + isError: false, + refetch: mocks.retry, + }) + await render(undefined, '?updated=custom') + expect(mocks.search.mock.calls.at(-1)![1]).toBe('') + expect(container.textContent).toContain('Choose the days to search.') + /** The filters, and the picker among them, are shown so the days can be chosen. */ + expect(container.textContent).toContain('Updated between') + /** One day alone is not a window either; a deep link with only `from` waits for `to`. */ + await render(undefined, '?updated=custom&from=2026-09-01') + expect(mocks.search.mock.calls.at(-1)![1]).toBe('') + }) + + it('searches a custom window as an inclusive range of days', async () => { + mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } }) + mocks.search.mockReturnValue({ + data: { query: 'launch', results: [], retrieval: { status: 'complete', timedOutLegs: [] } }, + isPending: false, + isFetching: false, + isPlaceholderData: false, + isError: false, + refetch: mocks.retry, + }) + await render(undefined, '?updated=custom&from=2026-09-01&to=2026-09-10') + const filters = mocks.search.mock.calls.at(-1)![2] + /** The days are the reader's own: local midnight to the last millisecond of the local day. */ + expect(filters.modifiedAfter).toBe(new Date(2026, 8, 1).toISOString()) + expect(filters.modifiedBefore).toBe(new Date(2026, 8, 11, 0, 0, 0, -1).toISOString()) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index 8ac809b11ec..64ac4323031 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -1,12 +1,13 @@ 'use client' import { useState } from 'react' -import { Chip, ChipLink, cn } from '@sim/emcn' +import { Chip, ChipDatePicker, ChipLink, cn } from '@sim/emcn' import { useQueryStates } from 'nuqs' import { ActivityStatus } from '@/components/ui/activity-status' -import type { - WorkspaceKnowledgeSearchResult, - WorkspaceSearchFilters, +import { + WORKSPACE_KNOWLEDGE_SEARCH_LIMITS, + type WorkspaceKnowledgeSearchResult, + type WorkspaceSearchFilters, } from '@/lib/api/contracts/knowledge' import { useSession } from '@/lib/auth/auth-client' import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope' @@ -27,6 +28,17 @@ import { useSearchIndex, useSearchSourceOverview } from '@/hooks/queries/kb/conn import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge' const DAY_MS = 24 * 60 * 60 * 1000 + +/** + * The picker names calendar days; the URL keeps them as dates. A day's bounds are its local + * midnight and the last millisecond before the next, so "September 1" means the reader's own day. + */ +function startOfLocalDay(day: Date): Date { + return new Date(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()) +} +function endOfLocalDay(day: Date): Date { + return new Date(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate() + 1, 0, 0, 0, -1) +} /** Every result without a connector is an upload; the filter names them so. */ const UPLOAD_SOURCE = 'upload' @@ -127,6 +139,11 @@ interface SearchResultsProps { function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { const [hasShownFilters, setHasShownFilters] = useState(false) const [searchedAt] = useState(Date.now) + /** + * More results are a second, wider search: the first paint stays as quick as it is, and a + * refinement of the filters starts over at the first page. + */ + const [expandedFor, setExpandedFor] = useState(null) const { data: index, isPending: basesPending, @@ -136,12 +153,24 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { } = useSearchIndex(scope) const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys) const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated) + /** A custom window is inclusive of both days; `to` runs to the end of its day. */ + const custom = filters.updated === 'custom' const searchFilters: WorkspaceSearchFilters = { ...(filters.source ? { source: filters.source } : {}), ...(window?.days ? { modifiedAfter: new Date(searchedAt - window.days * DAY_MS).toISOString() } : {}), + ...(custom && filters.from && filters.to + ? { + modifiedAfter: startOfLocalDay(filters.from).toISOString(), + modifiedBefore: endOfLocalDay(filters.to).toISOString(), + } + : {}), } + const filtersKey = JSON.stringify(searchFilters) + const expanded = expandedFor === filtersKey + /** A custom window is two-ended: until both days are chosen, nothing is searched. */ + const awaitingRange = custom && !(filters.from && filters.to) const { data: search, isPending, @@ -149,7 +178,17 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { isPlaceholderData, isError: searchFailed, refetch: refetchSearch, - } = useWorkspaceKnowledgeSearch(scope, query, searchFilters) + } = useWorkspaceKnowledgeSearch( + scope, + awaitingRange ? '' : query, + searchFilters, + expanded + ? WORKSPACE_KNOWLEDGE_SEARCH_LIMITS.expanded + : WORKSPACE_KNOWLEDGE_SEARCH_LIMITS.initial + ) + /** A full first page may collapse to few cards, yet more documents may still match. */ + const mayHaveMore = + !expanded && (search?.results.length ?? 0) >= WORKSPACE_KNOWLEDGE_SEARCH_LIMITS.initial const { data: overview } = useSearchSourceOverview(scope) const indexing = (overview?.providers ?? []) .filter((provider) => provider.isSyncing) @@ -175,8 +214,12 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { : null const showResults = !noSources && !failed && !basesPending && documents.length > 0 + /** A custom window waiting for its days must show the filters, or the picker is unreachable. */ const showFilters = - hasShownFilters || showResults || (!noSources && !pending && !failed && !!search && !partial) + hasShownFilters || + showResults || + awaitingRange || + (!noSources && !pending && !failed && !!search && !partial) if (showFilters && !hasShownFilters) setHasShownFilters(true) return noSources ? ( @@ -196,7 +239,11 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {

- {fetching || (pending && !failed) ? ( + {awaitingRange ? ( +

+ Choose the days to search. +

+ ) : fetching || (pending && !failed) ? ( ) : (

@@ -257,11 +304,29 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { shape='round' active={filters.updated === window.id} aria-pressed={filters.updated === window.id} - onClick={() => setFilters({ updated: window.id })} + onClick={() => + setFilters( + window.id === 'custom' + ? { updated: window.id } + : { updated: window.id, from: null, to: null } + ) + } > {window.label} ))} + {custom && ( + + void setFilters({ from: new Date(start), to: new Date(end) }) + } + onClear={() => void setFilters({ from: null, to: null })} + /> + )}

)} {showResults && ( @@ -291,6 +356,17 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { /> ) })} + {mayHaveMore && ( +
+ setExpandedFor(filtersKey)} + > + Show more + +
+ )}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx index 6b435b90369..8da1cf1620f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx @@ -212,6 +212,7 @@ describe('search refinement with the real query cache and URL state', () => { expect(requests.at(-1)?.body).toEqual({ organizationId: 'organization', query: 'launch', + topK: 20, filters: expectedFilters, }) expect(container.querySelector('h1')).toBeNull() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx index a3811b418cd..5bf1c53ac4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { isBrowserToolName } from '@sim/browser-protocol' import { cn } from '@sim/emcn' import { Globe } from '@sim/emcn/icons' -import { isRecordLike } from '@sim/utils/object' +import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' @@ -40,7 +40,7 @@ export function getBrowserAgentFaviconUrl(items: AgentGroupItem[]): string | nul return typeof params?.url === 'string' ? pageFaviconUrl(params.url) : null } - const output = result?.success && isRecordLike(result.output) ? result.output : null + const output = result?.success ? toRecordOrNull(result.output) : null if (output) { if (isRecordLike(output.activeTab) && typeof output.activeTab.url === 'string') { return pageFaviconUrl(output.activeTab.url) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts index 7f8d6ef3cdd..0e87649b7f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts @@ -4,4 +4,3 @@ export type { NestedAgentGroup, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' export { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' -export { CircleStop } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx index 591745ef329..c5c9dc03bfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx @@ -40,7 +40,7 @@ function renderToken(part: string, key: number): ReactNode { } if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) { return ( - + {part.slice(1, -1)} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 7d2bec15dd5..886243b9584 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -26,22 +26,6 @@ import { BrandIcon } from '@/blocks/brand-icon' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getBlockByToolName } from '@/blocks/registry' -export function CircleStop({ className }: { className?: string }) { - return ( - - - - - ) -} - export interface ToolCallItemProps { toolName: string displayTitle: string diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts index 07f0139dc83..0bbe8af07fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts @@ -1,5 +1,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group' -export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group' +export { AgentGroup, isAgentGroupResolved } from './agent-group' export { ChatContent } from './chat-content' export { MessageSources } from './message-sources' export { Options } from './options' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index c96110df49f..1ade562dc71 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -2,7 +2,14 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { cn, Expandable, ExpandableContent, SecretReveal, Tooltip, toast } from '@sim/emcn' -import { ArrowRight, Check, ChevronDown, SquareArrowUpRight, TerminalWindow } from '@sim/emcn/icons' +import { + ArrowRight, + Check, + ChevronDown, + Lock, + SquareArrowUpRight, + TerminalWindow, +} from '@sim/emcn/icons' import { isRecordLike } from '@sim/utils/object' import { useParams } from 'next/navigation' import { useSession } from '@/lib/auth/auth-client' @@ -1986,24 +1993,6 @@ function getCredentialProviderDisplayName(provider: string): string { ) } -const LockIcon = (props: { className?: string }) => ( - - - - - -) - /** * Inline "paste a secret" widget rendered for * `{"type":"secret_input","name":"OPENAI_API_KEY"}`. @@ -2484,7 +2473,7 @@ function CredentialLinkDisplay({ // The connect link value comes from the streamed model output, so only // render it as a clickable link when it resolves to a real http(s) URL. if (!data.value || !isSafeHttpUrl(data.value)) return null - const Icon = getCredentialIcon(data.provider) ?? LockIcon + const Icon = getCredentialIcon(data.provider) ?? Lock const label = reconnectCredentialId ? `Reconnect ${reconnectCredential?.displayName ?? integrationName}` : hasExistingCredential @@ -2550,7 +2539,7 @@ function PersonalCredentialLinkDisplay({ onConnected, }) if (!provider || (provider.toLowerCase() === 'gitlab' && !canEdit)) return null - const Icon = getCredentialIcon(provider) ?? LockIcon + const Icon = getCredentialIcon(provider) ?? Lock const connected = connection.status === 'connected' const label = connected ? `Connected ${name}` @@ -3167,7 +3156,7 @@ export function CredentialDisplay({ */ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) { return ( -

{data.message}

+

{data.message}

) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-overlay.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-overlay.test.tsx index 4228bb4093b..194adc9162c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-overlay.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-overlay.test.tsx @@ -31,7 +31,6 @@ vi.mock('./components', () => ({
), ChatContent: () => null, - CircleStop: () => null, Options: () => null, PendingTagIndicator: () => null, })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index da219a9ff30..fdd9ed5c7ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -11,6 +11,7 @@ import { useState, } from 'react' import { cn } from '@sim/emcn' +import { CircleStop } from '@sim/emcn/icons' import { PrepareFileEdit, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' @@ -40,14 +41,7 @@ import type { import { SUBAGENT_LABELS } from '@/app/workspace/[workspaceId]/home/types' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import type { AgentGroupItem } from './components' -import { - AgentGroup, - ChatContent, - CircleStop, - MessageSources, - Options, - PendingTagIndicator, -} from './components' +import { AgentGroup, ChatContent, MessageSources, Options, PendingTagIndicator } from './components' import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils' const FILE_SUBAGENT_ID = 'file' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 3f6b6dbbe62..e3712297bdc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { - Button, cn, DropdownMenu, DropdownMenuContent, @@ -14,6 +13,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, NATIVE_SURFACE_OCCLUSION_PREPARE_EVENT, + TabStripAction, Tooltip, } from '@sim/emcn' import { Folder, Plus } from '@sim/emcn/icons' @@ -30,10 +30,7 @@ import { byResourceMenuOrder, getResourceConfig, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' -import { - RESOURCE_TAB_ICON_BUTTON_CLASS, - RESOURCE_TAB_ICON_CLASS, -} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' +import { RESOURCE_TAB_ICON_CLASS } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' import type { MothershipResource, MothershipResourceType, @@ -676,13 +673,9 @@ export function AddResourceDropdown({ - + diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx index 2690ae9f639..ed3ae9e2eb1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx @@ -134,21 +134,20 @@ export function BrowserDownloads({ scopeId, open, requestOpen, onClose }: Browse size='sm' aria-label={label} title={label} - className={cn( - 'relative size-[30px] shrink-0 overflow-hidden p-0', - hasUnviewedCompletion && 'text-[var(--brand-primary)]' - )} + className='relative size-[30px] shrink-0 overflow-hidden p-0' onClick={() => setHasUnviewedCompletion(false)} > {hasActiveDownloads ? ( - + ) : completionAnimationVersion > 0 ? ( ) : ( - + )} {hasActiveDownloads && aggregatePercent !== null && ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx index b1c53816c34..8df491836cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx @@ -18,7 +18,7 @@ export function BrowserThemeNotice({ scopeId }: BrowserThemeNoticeProps) { return (
-

+

Some sites apply theme changes after a reload.

diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx index c75b921183b..23d5017c7b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx @@ -24,7 +24,7 @@ export function GenericResourceContent({ data }: GenericResourceContentProps) { if (data.entries.length === 0) { return (
-

No results yet

+

No results yet

) } @@ -37,33 +37,33 @@ export function GenericResourceContent({ data }: GenericResourceContentProps) { {entry.status === 'executing' && ( )} - + {getToolStatusDisplayTitle(entry.displayTitle, entry.status, entry.toolName)} {entry.status === 'error' && ( - Error + Error )} {entry.status === 'skipped' && ( - Skipped + Skipped )} {entry.status === 'rejected' && ( - Rejected + Rejected )}
{entry.streamingArgs && ( -
+            
               {entry.streamingArgs}
             
)} {!entry.streamingArgs && entry.result?.output != null && ( -
+            
               {typeof entry.result.output === 'string'
                 ? entry.result.output
                 : JSON.stringify(entry.result.output, null, 2)}
             
)} {entry.result?.error && ( -

{entry.result.error}

+

{entry.result.error}

)}
))} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index a3d8cecd74e..5b6560b4eae 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,7 +1,7 @@ 'use client' import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Button, OverflowText, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn' +import { OverflowText, PlayOutline, Skeleton, TabStripAction, Tooltip, toast } from '@sim/emcn' import { Download, FileX, @@ -36,10 +36,7 @@ import type { BrowserPanelOverlayController } from '@/app/workspace/[workspaceId import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content' import { TerminalSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session' -import { - RESOURCE_TAB_ICON_BUTTON_CLASS, - RESOURCE_TAB_ICON_CLASS, -} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' +import { RESOURCE_TAB_ICON_CLASS } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { GenericResourceData, @@ -465,14 +462,9 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor <> - +

Open workflow

@@ -480,11 +472,10 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
- +

{isExecuting ? 'Stop' : 'Run workflow'}

@@ -520,14 +511,13 @@ export function EmbeddedKnowledgeBaseActions({ return ( - +

Open knowledge base

@@ -562,14 +552,9 @@ function EmbeddedTableActions({ workspaceId, tableId }: EmbeddedTableActionsProp <> - +

Open table

@@ -577,14 +562,13 @@ function EmbeddedTableActions({ workspaceId, tableId }: EmbeddedTableActionsProp
- +

Export CSV

@@ -639,14 +623,9 @@ function EmbeddedFileActions({ <> - +

Open in files

@@ -654,15 +633,14 @@ function EmbeddedFileActions({
- +

Download

@@ -818,7 +796,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {

{folder.name}

{folderWorkflows.length === 0 ? ( -

No workflows in this folder

+

No workflows in this folder

) : (
{folderWorkflows.map((w) => ( @@ -896,14 +874,9 @@ export function EmbeddedLogActions({ workspaceId, logId }: EmbeddedLogActionsPro return ( - +

Open in logs

diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts index 43931efbff9..8507e6beafb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts @@ -1,10 +1,3 @@ -/** - * Icon-only controls in the resource header — add, preview mode, the per-resource - * actions — fill the tab strip's control band, so they match the strip's own - * new-tab button and the panel's collapse toggle and the header reads as one row. - */ -export const RESOURCE_TAB_ICON_BUTTON_CLASS = 'size-[var(--tab-strip-band,30px)] shrink-0 p-0' - export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]' /** Shared geometry for the resource header and controls positioned over it. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 71f94b3b37b..e3f73406f8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -10,9 +10,9 @@ import { useState, } from 'react' import { - Button, cn, TabStrip, + TabStripAction, type TabStripDragContext, type TabStripItem, type TabStripSelectionSource, @@ -41,7 +41,6 @@ import { useTerminalCloseConfirmation } from '@/app/workspace/[workspaceId]/home import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { RESOURCE_HEADER_CLASSES, - RESOURCE_TAB_ICON_BUTTON_CLASS, RESOURCE_TAB_ICON_CLASS, resourceTabWidthClass, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' @@ -545,14 +544,13 @@ export function ResourceTabs({ previewMode && onCyclePreviewMode ? ( - +

{PREVIEW_MODE_LABELS[previewMode]}

diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx index de2e1b93d9b..d281ad48d0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx @@ -34,7 +34,7 @@ export const DropOverlay = memo(function DropOverlay({ imagesOnly = false }: Dro return (
- + {imagesOnly ? 'Drop images' : 'Drop files'}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 2d4048a9c33..808f6f37902 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from '@sim/emcn' import { assessTextPaste, PASTE_LIMITS, PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste' +import { escapeRegExp } from '@sim/utils/string' import { attachSelectionContextToClipboard, readSelectionContextFromClipboard, @@ -28,7 +29,6 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' import { areContextsEqual, - escapeRegex, filterContextsPresentInMessage, prepareContextForInsert, restoreSkillTriggerText, @@ -369,7 +369,7 @@ export function usePromptEditor({ const labelIsUsed = (candidate: string): boolean => { if (selectedContexts.some((selected) => selected.label === candidate)) return true - return new RegExp(`(^|\\s)@${escapeRegex(candidate)}(?![A-Za-z0-9_])`).test(currentValue) + return new RegExp(`(^|\\s)@${escapeRegExp(candidate)}(?![A-Za-z0-9_])`).test(currentValue) } while (labelIsUsed(label)) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts index 2ce6adcf83e..68aec3558e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts @@ -1,8 +1,6 @@ import { useCallback, useMemo, useRef } from 'react' -import { - escapeRegex, - SKILL_CHIP_TRIGGER, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import { escapeRegExp } from '@sim/utils/string' +import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import type { McpServer } from '@/hooks/queries/mcp' import type { SkillDefinition } from '@/hooks/queries/skills' import type { ChatContext } from '@/stores/panel' @@ -104,8 +102,8 @@ export function useSkillAutoMention({ // Match either trigger: the typed '/' or the stored sentinel, so both fresh // input and pasted/restored chips resolve. The trigger group is the match's // first char (`text[match.index]`); group 1 is the skill name. - const trigger = `(?:/|${escapeRegex(SKILL_CHIP_TRIGGER)})` - const pattern = `${trigger}(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_-])` + const trigger = `(?:/|${escapeRegExp(SKILL_CHIP_TRIGGER)})` + const pattern = `${trigger}(${names.map(escapeRegExp).join('|')})(?![A-Za-z0-9_-])` return { regex: new RegExp(pattern, 'gi'), byName } }, [skills, mcpServers]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx index 00e16bdb723..d807f00a5be 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { cn } from '@sim/emcn' +import { escapeRegExp } from '@sim/utils/string' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' import { getIntegrationMatcher } from '@/blocks/integration-matcher' @@ -22,10 +23,6 @@ interface UserMessageContentProps { compact?: boolean } -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - interface MentionRange { start: number end: number @@ -54,7 +51,7 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men const ctx = withResolvedBlockType(rawCtx) const prefix = ctx.kind === 'skill' || ctx.kind === 'mcp' ? '/' : '@' const token = `${prefix}${ctx.label}` - const pattern = new RegExp(`(^|\\s)(${escapeRegex(token)})(\\s|$)`, 'g') + const pattern = new RegExp(`(^|\\s)(${escapeRegExp(token)})(\\s|$)`, 'g') let match: RegExpExecArray | null while ((match = pattern.exec(text)) !== null) { const leadingSpace = match[1] diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 56846b001d5..be6316624d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -787,7 +787,7 @@ function HomeContent({ chatId, userName, userId }: HomeProps) { {isResourceCollapsed && resourceActivityIds.size > 0 && ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 0b3bf5992d5..14fee904b18 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -1,4 +1,4 @@ -import { isRecordLike } from '@sim/utils/object' +import { isRecordLike, toRecord } from '@sim/utils/object' import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' import { MothershipStreamV1CompletionStatus, @@ -230,7 +230,7 @@ function rebindResolvedIntegrationCall(node: ToolNode, toolName: string): void { * through the `unknown`-typed {@link isRecordLike} guard rather than a double cast. */ function payloadRecord(payload: unknown): Record { - return isRecordLike(payload) ? payload : {} + return toRecord(payload) } /** Parses a wire `ts` to epoch ms, or undefined when absent/unparseable. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts index 19401fa6e00..3c439d7a7a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts @@ -1,4 +1,4 @@ -import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { parseAsIsoDate, parseAsString, parseAsStringLiteral } from 'nuqs/server' /** * Co-located, typed URL query-param definition for the home/Chat surface. @@ -26,19 +26,23 @@ export const resourceUrlKeys = { clearOnDefault: true, } as const -/** The recency windows a search can be narrowed to. */ +/** The recency windows a search can be narrowed to; `custom` reads its bounds from `from` and `to`. */ export const UPDATED_WINDOWS = [ { id: 'any', label: 'Any time', days: null }, { id: '7d', label: 'Past week', days: 7 }, { id: '30d', label: 'Past month', days: 30 }, + { id: 'custom', label: 'Custom range', days: null }, ] as const const UPDATED_WINDOW_IDS = UPDATED_WINDOWS.map((window) => window.id) /** * Shared result filters for organization search. `source` is a connector type - * or `upload`, absent for every source. + * or `upload`, absent for every source; `from` and `to` are the days of a custom + * window, inclusive, and mean nothing unless `updated` is `custom`. */ export const searchFilterParsers = { source: parseAsString, updated: parseAsStringLiteral(UPDATED_WINDOW_IDS).withDefault('any'), + from: parseAsIsoDate, + to: parseAsIsoDate, } as const diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx index 08d8c4095f2..14b5357d18a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx @@ -135,7 +135,7 @@ export function ExecutionSnapshot({ className={cn('flex flex-col items-center justify-center gap-4 p-8', className)} style={{ height, width }} > -
+
Logged State Not Found
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx index 2cb8e023541..8d310f38b29 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx @@ -17,6 +17,8 @@ interface FileData { url: string storageProvider?: 's3' | 'blob' | 'gcs' | 'local' bucketName?: string + /** Workspace file version these bytes came from; absent on runs recorded before versioning. */ + version?: number } interface FileCardsProps { @@ -100,7 +102,10 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
- {file.type} + + {file.type} + {file.version === undefined ? '' : ` · v${file.version}`} + - - Retry - - )} - - - -
+ + {log && ( +
+ {/* Header */} +
+

Log Details

+
+ {log.status === 'failed' && + (log.workflow?.id || log.workflowId) && + log.trigger !== 'mothership' && ( + + + + + Retry + + )} + + +
- -
- )} -
- + + +
+ )} + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index 88bd457cb0d..c321b25c1cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -16,10 +16,6 @@ import { import { Eye, EyeOff, Search } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { - CHIP_FIELD_INPUT, - CHIP_FIELD_SHELL, -} from '@/app/workspace/[workspaceId]/components/credential-detail/components/chip-field' import { BYOKProviderKeysModal } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { @@ -432,34 +428,37 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { tabIndex={-1} readOnly /> -
- { - setApiKeyInput(e.target.value) - if (error) setError(null) - }} - placeholder={editingMeta?.placeholder} - className={CHIP_FIELD_INPUT} - name='byok_api_key' - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - data-lpignore='true' - data-form-type='other' - /> - -
+ { + setApiKeyInput(e.target.value) + if (error) setError(null) + }} + placeholder={editingMeta?.placeholder} + name='byok_api_key' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + endAdornment={ + + } + /> {props.multiKey && ( - {isOpen && ( -
- )} - -
- {rowId && groupId && ( -
-
-

Enrichment Details

- -
- - + + {rowId && groupId && ( +
+
+

Enrichment Details

+
- )} -
- + + +
+ )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx index 33efeb839b7..070ec159db8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx @@ -93,7 +93,7 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP isReadyExport ? ( , -})) - vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) @@ -254,3 +257,25 @@ describe('Code password masking', () => { expect(highlighted()).toContain(SECRET_MATCH) }) }) + +describe('Code copy action', () => { + it('copies the current value through the shared chip action', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + vi.useFakeTimers() + act(() => + root.render( + + ) + ) + act(() => container.querySelector('button[aria-label="Copy code"]')!.click()) + expect(writeText).toHaveBeenCalledExactlyOnceWith(SECRET) + act(() => vi.advanceTimersByTime(2000)) + vi.useRealTimers() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx index e4fe0f53f44..191fa72fb71 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react' import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { + Chip, CODE_LINE_HEIGHT_PX, Code as CodeEditor, calculateGutterWidth, @@ -10,11 +11,10 @@ import { highlight, languages, } from '@sim/emcn' -import { Check, Wand } from '@sim/emcn/icons' +import { Check } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import Editor from 'react-simple-code-editor' -import { Button } from '@/components/ui/button' import { CodeLanguage } from '@/lib/execution/languages' import { isLikelyReferenceSegment, @@ -43,6 +43,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils' +import { WandButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-button' import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' @@ -906,22 +907,12 @@ export const Code = memo(function Code({ return ( <> {showCopyButton && code && ( - + /> )} {!hideInternalWand && ( - - + /> )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx index a9a25f64059..e7d794bf22b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useMemo } from 'react' -import { ChipCombobox, ChipTag, type ComboboxOption } from '@sim/emcn' +import { ChipTag, Combobox, type ComboboxOption } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { useQueries } from '@tanstack/react-query' import { useParams } from 'next/navigation' @@ -211,7 +211,7 @@ export function KnowledgeBaseSelector({
)} - ({ })) vi.mock('@sim/emcn', () => ({ + Chip: ({ + onClick, + disabled, + 'aria-label': label, + }: React.ButtonHTMLAttributes) => ( +