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 @@
-
+
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
-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 (