Compare commits

..

2 Commits

Author SHA1 Message Date
vvzvlad 220142bef6 Merge pull request 'fix(page-tree/dnd): drop-раскрытие + guard от потери детей (#523)' (#532) from fix/523-tree-dnd into fix/525-insert-unloaded
Reviewed-on: #532
2026-07-12 05:08:42 +03:00
agent_coder 27d51303ba fix(page-tree/dnd): drag авто-раскрытие — задержка 2с, не раскрывать при drop внутрь + guard от потери детей и сигнал «уехало сюда»
Пять точечных клиентских правок (дизайн из адверсариального разбора #523):

1. Задержка hover-раскрытия при drag: `AUTO_EXPAND_MS` 500 → 2000 мс, чтобы
   проведение курсора через дерево не раскрывало ряды — только осознанное
   удержание.
2. Не раскрывать цель при drop внутрь (make-child): удалена строка
   `onToggle(target, true)` в `onDrop` (drop оставляет узел свёрнутым; раскрытие
   только по hover-hold). Восстановление раскрытости самого source не тронуто.
3. Guard от потери детей в `handleMove` (обязателен вместе с п.2): раньше эта же
   `onToggle` была ЕДИНСТВЕННЫМ триггером корректирующего lazy-load. `treeModel.move`
   материализует `target.children = [source]`; для НЕзагруженной цели (предикат
   gate `treeModel.isUnloadedBranch`) это скрыло бы остальных серверных детей папки
   (класс #159 #1). Теперь для незагруженной make-child-цели оптимистичное дерево
   строится БЕЗ материализации source: source удаляется из старого родителя, цели
   ставится `hasChildren:true`; gate остаётся взведён и раскрытие догружает полный
   набор. Для загруженной цели поведение прежнее (append сохраняет детей).
4. Инвалидация крошек: `updateCacheOnMovePage` инвалидирует
   `["breadcrumbs", pageId]` — guard делает `remove(source)`, и для текущей
   открытой страницы `findBreadcrumbPath` промахивается → крошки показывали бы
   старого родителя до refocus.
5. Сигнал «уехало сюда»: транзиентный teal-пульс на строке при make-child-drop в
   свёрнутую цель (узел НЕ раскрывается), отдельный `data-landed-child` +
   `DROP_LANDED_HIGHLIGHT_MS`, таймер чистится при анмаунте; уважает
   prefers-reduced-motion.

Тесты: `handleMove` — незагруженная make-child-цель не материализует `[source]`
(мутация guard'а краснит), загруженная цель append'ит и сохраняет детей,
genuinely-empty лист материализует; инвалидация `["breadcrumbs", pageId]`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:15:02 +03:00
201 changed files with 2760 additions and 17466 deletions
-20
View File
@@ -251,16 +251,6 @@ MCP_DOCMOST_PASSWORD=
# Default 120000 (2 min).
# AI_MCP_CALL_TIMEOUT_MS=120000
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
# guaranteed to actually flip when an operator flips it during an incident. When
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
# the api-key management endpoints return 404. The resolved state is logged at boot
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
# API_KEYS_ENABLED=true
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
# small for a long AI-chat research turn: the client resends the FULL message
# history (every tool call + search result) on each turn, so a deep conversation's
@@ -312,16 +302,6 @@ MCP_DOCMOST_PASSWORD=
# enabled for a workspace, and the same single-instance constraint applies (the
# registry is process-local).
# AI_CHAT_RESUMABLE_STREAM=false
#
# Per-run replay ring cap (#491), in BYTES, for the resumable-stream registry
# above. The registry buffers the run's recent SSE tail so a reopened tab can
# attach and continue from the step it already persisted; the ring is bounded and
# rotates on every confirmed step-persist. This caps the un-persisted tail between
# rotations — an overflow evicts the oldest frames and a late attach falls back to
# 204 -> degraded poll, so correctness never depends on the size. Default 4194304
# (4MB); a 0/invalid value falls back to the default. The per-subscriber backpressure
# cap is derived as 2x this value. Only meaningful with AI_CHAT_RESUMABLE_STREAM on.
# AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES=4194304
# --- Run lifecycle tunables (#487) ---
# These govern the universal run machinery (every turn is now a first-class run,
-7
View File
@@ -226,13 +226,6 @@ jobs:
- name: Build mcp
run: pnpm --filter @docmost/mcp build
# apps/server imports @docmost/token-estimate at runtime (history-budget.ts,
# #490); its dist/ is gitignored and `test:e2e` type-checks + runs the code,
# so build it here or tsc fails with TS2307 Cannot find module
# '@docmost/token-estimate' (mirrors the editor-ext / mcp build steps above).
- name: Build token-estimate
run: pnpm --filter @docmost/token-estimate build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
-8
View File
@@ -159,14 +159,6 @@ jobs:
- name: Build prosemirror-markdown
run: pnpm --filter @docmost/prosemirror-markdown build
# @docmost/token-estimate is a shared workspace package the client vitest
# suite resolves via its dist build (main: ./dist/index.js); dist/ is
# gitignored and `pnpm -r test` does NOT honour nx `dependsOn: ^build`, so
# build it before the recursive test run or the client suite fails with
# "Failed to resolve import '@docmost/token-estimate'" (#490).
- name: Build token-estimate
run: pnpm --filter @docmost/token-estimate build
- name: Run unit tests
run: pnpm -r test
-1
View File
@@ -463,7 +463,6 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
## Conventions
-38
View File
@@ -135,25 +135,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
new resync path re-reads the live anchor so the suggestion applies against the
current text, and orphaned anchors (whose marked run was deleted) are
reconciled rather than left blocking. (#496)
- **Save intentional page versions.** Press `Cmd/Ctrl+S` (or use the page menu)
to save a named version of a page. The history panel now distinguishes
intentional versions (a "Saved" / "Agent version" badge) from automatic
snapshots, dims autosaves, and offers an "Only versions" filter. Automatic
snapshots switched from a fixed interval to a trailing idle-flush with a
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
changes (e.g. a person's edits followed by the AI agent). (#370)
- **Open tabs pick up a new deploy on their own.** After the server is
redeployed while a tab is left open for hours, the tab now learns the new
build version over the existing WebSocket (announced per-connect, so a natural
reconnect delivers it) and shows a "A new version is available" banner with an
Update button. To avoid dropping a half-written comment or form, the tab is
not reloaded when you merely switch away from it; instead it auto-reloads at
the next safe point — the next in-app navigation (or immediately if you click
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
reload happens per 5-minute window, shared with the existing chunk-load
recovery, so a permanent version skew degrades to the banner rather than a
reload loop while a second deploy in the same tab still recovers. When the
build carries no version info the feature stays inert. (#481)
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
@@ -404,25 +385,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
activation is also cached in the chat metadata to avoid re-resolving it each
turn. (#490)
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
when a diagram is opened in the draw.io editor.** Agent-created diagrams
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
SVG's `content=` attribute as base64; the draw.io editor decodes that via
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
`ё`, ``) split into garbage and the editor's autosave then persisted the
corrupted model, breaking the page preview too. Both write paths
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
DOM as UTF-8 — so labels open intact. The decoder reads both the new
entity-encoded form and the old base64 form, so existing diagrams still open.
*Healing pre-fix diagrams:* only a diagram that still holds its original
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
same XML (rewrites the attachment in the new form); no migration script is
needed. A diagram that was already opened in the editor persisted the
mojibake at rest, so `drawioGet` reads the already-corrupted text and
`drawioUpdate` faithfully rewrites it — that text is lost and is not
recoverable by a rewrite. (#507)
- **A chat with one malformed message part no longer 500s on every turn, and a
failed send no longer duplicates the user's message.** Incoming client parts
are now whitelisted to `text` (a forged tool-result part can no longer reach
-8
View File
@@ -59,14 +59,6 @@ COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
# apps/server imports @docmost/token-estimate (workspace:*) at runtime
# (history-budget.ts, #490). tsc emits only dist/ and dist/ is gitignored, so the
# prod install would resolve a broken workspace symlink and the server would die
# with ERR_MODULE_NOT_FOUND on the first history-budget call. Ship the built
# package + its manifest, mirroring prosemirror-markdown above.
COPY --from=builder /app/packages/token-estimate/dist /app/packages/token-estimate/dist
COPY --from=builder /app/packages/token-estimate/package.json /app/packages/token-estimate/package.json
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
-131
View File
@@ -206,137 +206,6 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
existing pages are indexed on their next edit. pgvector is still required for the migration to
apply at all.
## Local embeddings server
The AI agent's semantic (RAG) search needs an **embeddings model**. Instead of paying a cloud
provider (e.g. OpenAI `text-embedding-3-*`) to embed every page, you can run a small open-weights
model yourself with Hugging Face
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI), which
serves an OpenAI-compatible `/v1/embeddings` endpoint. `intfloat/multilingual-e5-small` is a good
default: multilingual, 384-dim, and comfortable on CPU (~1–2 GB RAM, 1–2 vCPU). Point Gitmost at it
under **Workspace settings → AI → Embeddings**.
### Option A — local (same Docker network as Gitmost)
Run TEI as a container on the network Gitmost is already on. The port is never published, so the
endpoint stays internal and needs no authentication.
```yaml
services:
embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
container_name: embeddings
restart: unless-stopped
networks:
- gitmost_net # same network Gitmost is on
command:
- "--model-id"
- "intfloat/multilingual-e5-small"
- "--auto-truncate" # clamp over-long inputs instead of returning 413
volumes:
- tei-models:/data # weights are downloaded once and cached here
networks:
gitmost_net:
external: true # the network Gitmost already uses
volumes:
tei-models:
```
Gitmost settings (**Workspace settings → AI → Embeddings**):
| Field | Value |
|-------------------|-----------------------------------|
| Model | `intfloat/multilingual-e5-small` |
| Base URL | `http://embeddings:80/v1/` |
| Embedding API key | — (leave empty) |
> `embeddings` is the container name — Gitmost resolves it over DNS inside the Docker network.
> The port is not published, so the endpoint is reachable only by containers on that network and
> no authorization is required.
### Option B — separate host (public via Traefik + Let's Encrypt)
This assumes the host already runs Traefik with an ACME resolver (the example below uses
`letsEncrypt`, the `websecure` entrypoint and a shared `docker_main_net` network). Replace the
domain / network / resolver with your own.
**DNS:** add an A record `embeddings.example.com` → the IP of your Traefik host (same
challenge / port 80 as the rest of your sites).
```yaml
services:
embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
container_name: embeddings
restart: unless-stopped
networks:
- docker_main_net # the network Traefik is attached to
command:
- "--model-id"
- "intfloat/multilingual-e5-small"
- "--auto-truncate"
- "--api-key"
- "sk-emb-REPLACE_WITH_YOUR_KEY"
volumes:
- tei-models:/data
labels:
traefik.enable: "true"
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
traefik.http.routers.embeddings.entrypoints: "websecure"
traefik.http.routers.embeddings.tls: "true"
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
traefik.http.routers.embeddings.service: "embeddings"
traefik.http.services.embeddings.loadbalancer.server.port: "80"
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
networks:
docker_main_net:
external: true
volumes:
tei-models:
```
Gitmost settings (**Workspace settings → AI → Embeddings**):
| Field | Value |
|-------------------|---------------------------------------|
| Model | `intfloat/multilingual-e5-small` |
| Base URL | `https://embeddings.example.com/v1/` |
| Embedding API key | your `sk-emb-…` |
Check it from outside:
```bash
curl -s https://embeddings.example.com/v1/embeddings \
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
# -> dims: 384
```
### Embeddings server notes
- **Vector dimension is 384.** If this Gitmost was previously embedded with a different model
(e.g. `text-embedding-3-large` = 3072-dim), the old pgvector rows won't match the new dimension —
clear the existing embeddings / re-index before switching. Gitmost only compares vectors of the
same dimension, so mixed-dimension rows are silently ignored rather than searched.
- **First start downloads the weights** (hundreds of MB) from `huggingface.co` into the
`tei-models` volume; every start after that reads from the volume.
- **Pin the version.** Pin the image, and optionally the model: add `--revision <commit-sha>` to
`command` (the sha is on the model's page on Hugging Face).
- **Air-gapped / no egress:** seed the `tei-models` volume ahead of time and add
`environment: [HF_HUB_OFFLINE=1]`.
- **GPU:** use the cuda tag of the same release (e.g.
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) and start the container with `gpus: all`.
## Features
- Real-time collaboration
-131
View File
@@ -193,137 +193,6 @@ dump/restore, существующий каталог данных переис
> неизменным и бэкапьте вместе с базой данных.
## Локальный сервер эмбеддингов
Семантическому (RAG) поиску AI-агента нужна **модель эмбеддингов**. Вместо оплаты облачного
провайдера (например, OpenAI `text-embedding-3-*`) за эмбеддинг каждой страницы можно запустить
небольшую open-weights модель у себя через Hugging Face
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI) — он
отдаёт OpenAI-совместимый эндпоинт `/v1/embeddings`. Хороший дефолт — `intfloat/multilingual-e5-small`:
многоязычная, 384-мерная, комфортно работает на CPU (~1–2 ГБ RAM, 1–2 vCPU). Пропишите её в
**Настройки воркспейса → AI → Эмбеддинги**.
### Вариант A — локально (та же Docker-сеть, что и Gitmost)
Запустите TEI контейнером в той же сети, где уже работает Gitmost. Порт наружу не публикуется,
поэтому эндпоинт остаётся внутренним и не требует авторизации.
```yaml
services:
embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
container_name: embeddings
restart: unless-stopped
networks:
- gitmost_net # same network Gitmost is on
command:
- "--model-id"
- "intfloat/multilingual-e5-small"
- "--auto-truncate" # clamp over-long inputs instead of returning 413
volumes:
- tei-models:/data # weights are downloaded once and cached here
networks:
gitmost_net:
external: true # the network Gitmost already uses
volumes:
tei-models:
```
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
| Поле | Значение |
|-------------------|-----------------------------------|
| Model | `intfloat/multilingual-e5-small` |
| Base URL | `http://embeddings:80/v1/` |
| Embedding API key | — (оставить пустым) |
> `embeddings` — имя контейнера, Gitmost резолвит его по DNS внутри Docker-сети.
> Наружу порт не публикуется, эндпоинт доступен только контейнерам этой сети, поэтому
> авторизация не нужна.
### Вариант B — на отдельном хосте (наружу через Traefik + Let's Encrypt)
Предполагается, что на хосте уже есть Traefik с ACME-резолвером (в примере ниже — `letsEncrypt`,
entrypoint `websecure`, общая сеть `docker_main_net`). Замените домен / сеть / резолвер на свои.
**DNS:** заведите A-запись `embeddings.example.com` → IP хоста с Traefik (тот же challenge / порт 80,
что и у остальных сайтов).
```yaml
services:
embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
container_name: embeddings
restart: unless-stopped
networks:
- docker_main_net # the network Traefik is attached to
command:
- "--model-id"
- "intfloat/multilingual-e5-small"
- "--auto-truncate"
- "--api-key"
- "sk-emb-REPLACE_WITH_YOUR_KEY"
volumes:
- tei-models:/data
labels:
traefik.enable: "true"
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
traefik.http.routers.embeddings.entrypoints: "websecure"
traefik.http.routers.embeddings.tls: "true"
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
traefik.http.routers.embeddings.service: "embeddings"
traefik.http.services.embeddings.loadbalancer.server.port: "80"
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
networks:
docker_main_net:
external: true
volumes:
tei-models:
```
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
| Поле | Значение |
|-------------------|---------------------------------------|
| Model | `intfloat/multilingual-e5-small` |
| Base URL | `https://embeddings.example.com/v1/` |
| Embedding API key | ваш `sk-emb-…` |
Проверка снаружи:
```bash
curl -s https://embeddings.example.com/v1/embeddings \
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
# -> dims: 384
```
### Заметки про сервер эмбеддингов
- **Размерность вектора — 384.** Если раньше этот Gitmost эмбеддился другой моделью
(например, `text-embedding-3-large` = 3072-dim), старые строки в pgvector не совпадут по
размерности — очистите существующие эмбеддинги / переиндексируйте перед переключением. Gitmost
сравнивает только вектора одной размерности, поэтому строки другой размерности не участвуют в
поиске, а не ломают его.
- **Первый старт тянет веса** (сотни МБ) с `huggingface.co` в том `tei-models`; дальше — из тома.
- **Пин версии.** Пиньте образ, а при желании и модель: добавьте в `command` `--revision <commit-sha>`
(sha берётся со страницы модели на Hugging Face).
- **Без egress (air-gapped):** засейте том `tei-models` заранее и добавьте
`environment: [HF_HUB_OFFLINE=1]`.
- **GPU:** возьмите cuda-тег того же релиза (например,
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) и запустите контейнер с `gpus: all`.
## Возможности
- Совместная работа в реальном времени
@@ -1,5 +1,4 @@
{
"A new version is available": "A new version is available",
"Account": "Account",
"Active": "Active",
"Add": "Add",
@@ -240,8 +239,6 @@
"Comment re-opened successfully": "Comment re-opened successfully",
"Comment unresolved successfully": "Comment unresolved successfully",
"Failed to resolve comment": "Failed to resolve comment",
"Failed to re-open comment": "Failed to re-open comment",
"Comment no longer exists": "Comment no longer exists",
"Resolve comment": "Resolve comment",
"Unresolve comment": "Unresolve comment",
"Resolve Comment Thread": "Resolve Comment Thread",
@@ -1421,29 +1418,5 @@
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
"Dismiss": "Dismiss",
"Suggestion dismissed": "Suggestion dismissed",
"Failed to dismiss suggestion": "Failed to dismiss suggestion",
"Save version": "Save version",
"Ctrl+S": "Ctrl+S",
"Version saved": "Version saved",
"Already saved as the latest version": "Already saved as the latest version",
"Agent version": "Agent version",
"Boundary": "Boundary",
"Autosave": "Autosave",
"Only versions": "Only versions",
"No saved versions yet.": "No saved versions yet.",
"Time worked on this article": "Time worked on this article",
"Show time worked on this page": "Show time worked on this page",
"Estimated time worked (inactivity gap {{gap}} min)": "Estimated time worked (inactivity gap {{gap}} min)",
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Estimate · timezone {{tz}} · inactivity gap {{gap}} min",
"No editing activity recorded yet.": "No editing activity recorded yet.",
"× {{count}} days without edits": "× {{count}} days without edits",
"agent: {{value}}": "agent: {{value}}",
"Work": "Work",
"Agent": "Agent",
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
"≈ {{hours}}h": "≈ {{hours}}h",
"≈ {{minutes}}m": "≈ {{minutes}}m",
"{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m",
"{{hours}}h": "{{hours}}h",
"{{minutes}}m": "{{minutes}}m"
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
}
@@ -1,5 +1,4 @@
{
"A new version is available": "Доступна новая версия",
"Account": "Аккаунт",
"Active": "Активный",
"Add": "Добавить",
@@ -240,8 +239,6 @@
"Comment re-opened successfully": "Комментарий успешно открыт повторно",
"Comment unresolved successfully": "Комментарий успешно переведён в нерешённые",
"Failed to resolve comment": "Не удалось разрешить комментарий",
"Failed to re-open comment": "Не удалось переоткрыть комментарий",
"Comment no longer exists": "Комментарий больше не существует",
"Resolve comment": "Решить комментарий",
"Unresolve comment": "Снять статус решённого с комментария",
"Resolve Comment Thread": "Решить ветку комментариев",
@@ -1436,29 +1433,5 @@
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
"Dismiss": "Не применять",
"Suggestion dismissed": "Предложение отклонено",
"Failed to dismiss suggestion": "Не удалось отклонить предложение",
"Save version": "Сохранить версию",
"Ctrl+S": "Ctrl+S",
"Version saved": "Версия сохранена",
"Already saved as the latest version": "Уже сохранено как последняя версия",
"Agent version": "Версия агента",
"Boundary": "Граница",
"Autosave": "Автосейв",
"Only versions": "Только версии",
"No saved versions yet.": "Пока нет сохранённых версий.",
"Time worked on this article": "Время работы над статьёй",
"Show time worked on this page": "Показать время работы над страницей",
"Estimated time worked (inactivity gap {{gap}} min)": "Оценка времени работы (порог паузы {{gap}} мин)",
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Оценка · таймзона {{tz}} · порог паузы {{gap}} мин",
"No editing activity recorded yet.": "Правок пока нет.",
"× {{count}} days without edits": "× {{count}} дн. без правок",
"agent: {{value}}": "агент: {{value}}",
"Work": "Работа",
"Agent": "Агент",
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
"≈ {{hours}}h": "≈ {{hours}} ч",
"≈ {{minutes}}m": "≈ {{minutes}} мин",
"{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м",
"{{hours}}h": "{{hours}} ч",
"{{minutes}}m": "{{minutes}} м"
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isChunkLoadError } from "./chunk-load-error-boundary";
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
// The detector decides whether a caught render error is a stale-deploy chunk-404
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
@@ -35,3 +35,31 @@ describe("isChunkLoadError", () => {
expect(isChunkLoadError(err)).toBe(false);
});
});
// The window gate replaces the old one-shot flag: it must permit recovery across
// several deploys in one tab (each > window apart) while still stopping an infinite
// reload loop when a lazy chunk is permanently broken (a second failure < window).
describe("shouldAutoReload", () => {
const WINDOW = 5 * 60 * 1000;
const NOW = 1_000_000_000_000;
it("allows a reload when we have never auto-reloaded", () => {
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
});
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
});
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
});
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
});
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
});
});
@@ -1,11 +1,26 @@
import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button, Center, Stack, Text } from "@mantine/core";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
} from "@/lib/reload-guard";
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
const RELOAD_AT_KEY = "chunk-reload-at";
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
// by a single reload, so anything inside the window is treated as a reload loop
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
// window. Anything inside the window is suppressed to break an infinite reload loop.
export function shouldAutoReload(
now: number,
lastReloadAt: number | null,
windowMs: number,
): boolean {
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
return now - lastReloadAt > windowMs;
}
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
@@ -24,26 +39,24 @@ export function isChunkLoadError(error: unknown): boolean {
);
}
// Exported for tests: the reactive chunk-load reload decision, so the shared
// window budget (invariant: ≤1 auto-reload per window across this path AND the
// proactive version-coherence path) can be exercised against the real guard.
export function handleError(error: unknown) {
function handleError(error: unknown) {
if (!isChunkLoadError(error)) return;
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
// the new chunk manifest. Auto-reload at most once per window via the SHARED
// window-based reload guard (see @/lib/reload-guard — the same budget the
// proactive version-coherence path consumes, so a mismatch that arrives on
// both paths reloads at most once per window across BOTH). This recovers
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
// lazy chunk (which would loop) is stopped after the first reload and falls
// through to the manual recovery UI below. If the shared budget is already
// spent this window, or the stamp write fails (storage unavailable), we return
// without reloading rather than risk a loop.
if (hasAutoReloaded()) return;
if (!markAutoReloaded()) return;
// Trace before the reload clears the console (same diagnostic breadcrumb the
// proactive version-coherence path writes, tagged with this path).
recordReloadBreadcrumb({ path: "chunk-boundary" });
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
// recovers across multiple deploys in a single tab's lifetime, yet a
// permanently-broken lazy chunk (which would loop) is stopped after the first
// reload and falls through to the manual recovery UI below.
try {
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
const now = Date.now();
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
} catch {
// sessionStorage unavailable (private mode / disabled): skip the automatic
// reload rather than risk an unguarded loop; the fallback UI still recovers.
return;
}
window.location.reload();
}
@@ -58,11 +58,8 @@ import ConversationList from "@/features/ai-chat/components/conversation-list.ts
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import {
exportAiChat,
getAiChatMessagesDelta,
stopRun,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import { mergeDeltaRowsIntoPages } from "@/features/ai-chat/utils/resume-helpers.ts";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
import {
shouldCollapseOnOutsidePointer,
@@ -272,64 +269,17 @@ export default function AiChatWindow() {
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(
activeChatId ?? undefined,
// #491: the full infinite-query no longer POLLS. It seeds the thread ONCE; the
// degraded fallback now runs a DELTA poller (below) that augments THIS cache
// idempotently, instead of refetching every page (with full parts) every 2.5s.
false,
// #344: gate on windowOpen too — no message history is fetched while the window
// is closed; it loads when the window opens with an active chat.
// DELIBERATELY DUMB: poll every 2.5s WHILE ARMED, otherwise off. NO error
// checks (TanStack resets fetchFailureCount each fetch; the poll must survive
// a server restart), NO tail checks, NO cap here — the settled/stalled/idle-cap
// semantics all live in ChatThread's FSM, which disarms via onResumeFallback.
() => (degradedPoll === true ? 2500 : false),
// #344: gate on windowOpen too — no message history is fetched (and no
// degraded poll runs) while the window is closed; it loads when the window
// opens with an active chat.
windowOpen,
);
// #491 degraded DELTA poll. While armed (degradedPoll) and the window is open on a
// chat, poll POST /ai-chat/messages/delta every 2.5s: it returns only the rows
// CHANGED since the previous cursor (+ the run fact) in ONE round-trip. We merge
// those rows into the SAME infinite-query cache the thread reads (idempotently by
// id — the delta's overlap window re-delivers rows), so the thread's reconcile
// effect follows the detached run to its terminal row from a fraction of the wire
// cost. The run-fact settle stays the thread FSM's job (row-status reconcile), so
// we do NOT double-poll /run here. Cursor resets when the chat changes / disarms.
const deltaCursorRef = useRef<string | undefined>(undefined);
useEffect(() => {
deltaCursorRef.current = undefined;
}, [activeChatId, degradedPoll]);
useEffect(() => {
if (!degradedPoll || !windowOpen || !activeChatId) return;
const chatId = activeChatId;
let cancelled = false;
const tick = async (): Promise<void> => {
try {
const res = await getAiChatMessagesDelta(chatId, deltaCursorRef.current);
if (cancelled) return;
deltaCursorRef.current = res.cursor;
if (res.rows.length > 0) {
queryClient.setQueryData(
AI_CHAT_MESSAGES_RQ_KEY(chatId),
(
old:
| {
pages: { items: IAiChatMessageRow[]; meta: unknown }[];
pageParams: unknown[];
}
| undefined,
) =>
old
? { ...old, pages: mergeDeltaRowsIntoPages(old.pages, res.rows) }
: old,
);
}
} catch {
// Transient failure (e.g. a server restart mid-run): swallow and retry on
// the next tick — the poll must survive a bounce, like the old dumb refetch.
}
};
const id = setInterval(() => void tick(), 2500);
return () => {
cancelled = true;
clearInterval(id);
};
}, [degradedPoll, windowOpen, activeChatId, queryClient]);
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
// this workspace. When the feature is off no runs are ever created, so the
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
@@ -172,18 +172,9 @@ function resetState() {
h.state.getRun.mockResolvedValue({ run: null, message: null });
}
// #491: the streaming tail carries a persisted step frontier (metadata.stepsPersisted),
// which the tail-only attach reads as `n` in `?anchor=<id>&n=<n>`. Seeded WHOLE now.
const streamingTail = () => [
row("u1", "user", undefined, "hi"),
{
id: "a1",
role: "assistant",
content: "partial",
status: "streaming",
createdAt: "2026-01-01T00:00:00Z",
metadata: { stepsPersisted: 2 },
} as IAiChatMessageRow,
row("a1", "assistant", "streaming", "partial"),
];
const settledTail = () => [
row("u1", "user", undefined, "hi"),
@@ -344,24 +335,20 @@ describe("ChatThread — send now", () => {
expect(screen.getAllByLabelText("Remove queued message")).toHaveLength(1);
});
it("Stop then a REAL network-drop finish exits to idle (honor-in-stopping), NOT a false reconnect", async () => {
it("Stop then a REAL network-drop finish exits to idle (honor-in-stopping), NOT a false reconnect", () => {
// Regression for the disconnect-first reorder: on the STOP path, even a drop-
// form finish { isError:true, isDisconnect:true } arriving in `stopping` must be
// HONORED (reducer) and exit to idle — it must NOT enter the reconnect ladder.
startLocalStreamWithRun(); // live local stream, autonomous
fireEvent.click(screen.getByLabelText("Stop")); // STOP_REQUESTED -> stopping
h.state.error = { message: "Failed to fetch" };
// #491: the disconnect re-seeds from persist (async getRun) before dispatching
// FINISH_DISCONNECT, which the reducer HONORS in `stopping` -> idle. Flush it.
await act(async () => {
act(() => {
h.state.onFinish?.({
message: { id: "a1", role: "assistant", parts: [] },
isAbort: false,
isDisconnect: true,
isError: true,
});
await Promise.resolve();
await Promise.resolve();
});
expect(screen.queryByText(/reconnecting/i)).toBeNull();
});
@@ -816,24 +803,19 @@ describe("ChatThread — resume (attach) machinery", () => {
expect(h.state.resumeStream).not.toHaveBeenCalled();
});
it("#491 tail-only: seeds the streaming tail WHOLE (no strip), keeps a user tail whole", () => {
it("strips the streaming tail from the seed, keeps a user tail whole", () => {
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
// MUTATION-VERIFY: re-introduce the seed-strip and this goes red — the streaming
// tail (steps 0..N-1) MUST be seeded so the SDK continuation appends the tail to
// the RIGHT message. Both rows (user + assistant) are seeded.
expect(h.state.seededMessages).toHaveLength(2);
expect(h.state.seededMessages).toHaveLength(1);
cleanup();
resetState();
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
expect(h.state.seededMessages).toHaveLength(1);
});
it("#491 tail-only: builds the attach URL with ?anchor=&n= from the persisted step frontier", () => {
it("builds the attach URL with expect=live&anchor only for a stripped streaming tail", () => {
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
// n=2 comes from a1's metadata.stepsPersisted (MUTATION-VERIFY: hardcode n=0 and
// this fails). No `expect=live` param anymore.
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream?anchor=a1&n=2",
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a1",
);
cleanup();
resetState();
@@ -857,41 +839,39 @@ describe("ChatThread — resume (attach) machinery", () => {
});
}
it("204 on a streaming tail: NO restore (row kept) + invalidate + onResumeFallback(true)", async () => {
it("204 on a streaming tail: restore + invalidate + onResumeFallback(true)", async () => {
const { onResumeFallback, invalidateSpy } = renderThread({
autonomousRunsEnabled: true,
initialRows: streamingTail(),
});
await attachFetch({ status: 204, ok: false });
// #491 tail-only: the anchor row was never stripped, so there is NOTHING to
// restore. MUTATION-VERIFY: re-add a restore setMessages here and it goes red.
expect(h.state.setMessages).not.toHaveBeenCalled();
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["ai-chat-messages", "c1"],
});
expect(onResumeFallback).toHaveBeenCalledWith(true);
});
it("F7 restart-survival: a 500 attach failure arms the poll WITHOUT a restore", async () => {
it("F7 restart-survival: a 500 attach failure restores the row AND arms the poll", async () => {
const { onResumeFallback, invalidateSpy } = renderThread({
autonomousRunsEnabled: true,
initialRows: streamingTail(),
});
await attachFetch({ status: 500, ok: false });
expect(h.state.setMessages).not.toHaveBeenCalled();
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["ai-chat-messages", "c1"],
});
expect(onResumeFallback).toHaveBeenCalledWith(true);
});
it("F7 restart-survival: a network throw arms the poll WITHOUT a restore", async () => {
it("F7 restart-survival: a network throw restores the row AND arms the poll", async () => {
const { onResumeFallback, invalidateSpy } = renderThread({
autonomousRunsEnabled: true,
initialRows: streamingTail(),
});
await attachFetch(new Error("network down"), true);
expect(h.state.setMessages).not.toHaveBeenCalled();
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["ai-chat-messages", "c1"],
});
@@ -951,7 +931,7 @@ describe("ChatThread — resume (attach) machinery", () => {
expect(h.state.sendMessage).not.toHaveBeenCalled();
});
it("an empty resumed message (starved replay) arms the poll WITHOUT a restore", () => {
it("an empty resumed message (starved replay) restores the row AND arms the poll", () => {
h.state.status = "ready";
const { onResumeFallback } = renderThread({
autonomousRunsEnabled: true,
@@ -967,9 +947,7 @@ describe("ChatThread — resume (attach) machinery", () => {
isError: false,
});
});
// #491 tail-only: the seeded steps 0..N-1 are still on screen (the SDK
// continuation never wiped them), so there is nothing to restore — just poll.
expect(h.state.setMessages).not.toHaveBeenCalled();
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
expect(onResumeFallback).toHaveBeenCalledWith(true); // arm
});
@@ -1017,41 +995,24 @@ describe("ChatThread — live reconnect + stalled", () => {
cleanup();
});
// #491: the authoritative PERSISTED assistant row `getRun` projects on a local
// disconnect — the re-seed source. Its metadata.stepsPersisted becomes `n`.
const persistedAnchor = (steps = 3) => ({
run: { id: "run-1", status: "running" },
message: {
id: "a2",
role: "assistant",
content: "persisted 0..N-1",
status: "streaming",
createdAt: "2026-01-01T00:00:00Z",
metadata: { stepsPersisted: steps },
},
});
// A REAL live SSE drop. ai@6.0.207 emits BOTH { isError:true, isDisconnect:true }
// for a network TypeError AND sets useChat `error`. #491: an autonomous local drop
// now RE-SEEDS from persist (async getRun) BEFORE entering the reconnect ladder, so
// this helper is async and flushes the getRun microtask before returning.
async function disconnect(message: unknown = liveMsg) {
// for a network TypeError AND sets useChat `error` — NOT the { isError:false,
// error:null } form the old tests fed. This is the form browser QA hit; with the
// buggy isError-first routing OR without the errorView render-gate these tests go
// red (a real drop surfaces the terminal error banner, masking the reconnect
// ladder). MUTATION-VERIFY of disconnect-first + the errorView phase-gate.
function disconnect(message: unknown = liveMsg) {
h.state.error = { message: "Failed to fetch" }; // the SDK sets error on the drop
await act(async () => {
act(() => {
h.state.onFinish?.({
message,
isAbort: false,
isDisconnect: true,
isError: true,
});
// Flush the getRun().then re-seed + the deferred FINISH_DISCONNECT dispatch.
await Promise.resolve();
await Promise.resolve();
});
}
function renderLive() {
// The persisted-anchor read the local disconnect performs to re-seed from persist.
h.state.getRun.mockResolvedValue(persistedAnchor());
const view = renderThread({
autonomousRunsEnabled: true,
initialRows: settledTail(),
@@ -1071,80 +1032,35 @@ describe("ChatThread — live reconnect + stalled", () => {
});
}
it("#491: a live disconnect RE-SEEDS from persist, then backs off to reconnect with ?anchor=&n=", async () => {
it("a live disconnect starts a backoff reconnect (banner + resumeStream after backoff)", () => {
renderLive();
await disconnect();
// The re-seed read the authoritative persisted row and replaced the live partial.
// MUTATION-VERIFY: skip the getRun re-seed (send `n` off the live message) and the
// n below no longer matches the PERSISTED stepsPersisted.
expect(h.state.getRun).toHaveBeenCalledWith("c1");
expect(h.state.setMessages).toHaveBeenCalled(); // re-seeded the store from persist
disconnect();
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
expect(h.state.resumeStream).not.toHaveBeenCalled();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
// n=3 is the PERSISTED row's stepsPersisted (from getRun), NOT the live store.
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream?anchor=a2&n=3",
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a2",
);
});
it("#491 regression (#137/#161 dup): getRun REJECT on a live disconnect drops the live partial + nulls the anchor", async () => {
// The re-seed source (getRun) FAILS — a flaky-network blip (SSE + getRun both
// fail, network recovers in ~1s). The OLD .catch just re-entered the ladder with
// NO re-seed and NO filter, so the reconnect could tail-apply the registry's
// frames onto the live partial that ALREADY has those steps -> duplicated text.
renderLive();
h.state.getRun.mockReset();
h.state.getRun.mockRejectedValue(new Error("network"));
await disconnect(); // live partial = liveMsg (id "a2")
expect(h.state.getRun).toHaveBeenCalledWith("c1");
// THE GUARANTEE: on the getRun failure the live partial (a2) is FILTERED from the
// store, so the reconnect can never tail-apply already-present steps onto it.
// MUTATION-VERIFY: revert the .catch fix (enterReconnect only, no filter) and no
// setMessages call removes a2 -> this reddens.
const removedLivePartial = (
h.state.setMessages as unknown as {
mock: { calls: [unknown][] };
}
).mock.calls.some(([updater]) => {
if (typeof updater !== "function") return false;
const out = (updater as (p: { id: string }[]) => { id: string }[])([
{ id: "a2" },
{ id: "u1" },
]);
return !out.some((m) => m.id === "a2");
});
expect(removedLivePartial).toBe(true);
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
// Anchor was nulled -> replay-from-start (no params) / 204 -> poll; never a stale
// ?anchor=&n= over the live partial.
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream",
);
});
it("#488 (browser QA): the reconnect banner is SHOWN, not masked by the residual useChat error", async () => {
it("#488 (browser QA): the reconnect banner is SHOWN, not masked by the residual useChat error", () => {
// The drop sets useChat `error` (real SDK), and the terminal errorView describes
// it ("Lost connection to the server"). The FSM phase-gate must let the
// `reconnecting` banner WIN over that residual error. MUTATION-VERIFY: revert the
// errorView phase-gate (show errorView whenever error is set) and the terminal
// banner masks "reconnecting…" -> red.
renderLive();
await disconnect();
disconnect();
expect(h.state.error).not.toBeNull(); // the SDK error IS set during recovery
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
// The terminal "Lost connection… reload" banner must NOT be showing.
expect(screen.queryByText(/reload and try again/i)).toBeNull();
});
it("#488 commit 2: a disconnect BEFORE the first assistant frame reconnects with NO anchor", async () => {
it("#488 commit 2: a disconnect BEFORE the first assistant frame reconnects with NO anchor", () => {
renderLive();
// No persisted assistant row for a pre-first-frame break -> no anchor.
h.state.getRun.mockResolvedValue({ run: null, message: null });
await disconnect(null); // no assistant message yet (pre-first-frame break)
disconnect(null); // no assistant message yet (pre-first-frame break)
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
expect(
screen.queryByText("Connection lost — the answer was interrupted."),
@@ -1158,7 +1074,7 @@ describe("ChatThread — live reconnect + stalled", () => {
it("a live re-attach (2xx) clears the reconnect banner", async () => {
renderLive();
await disconnect();
disconnect();
advanceToAttempt(1);
await reconnect({ status: 200, ok: true });
expect(screen.queryByText(/reconnecting/i)).toBeNull();
@@ -1166,7 +1082,7 @@ describe("ChatThread — live reconnect + stalled", () => {
it("a 204 arms the degraded poll and backs off to the next attempt", async () => {
const { onResumeFallback } = renderLive();
await disconnect();
disconnect();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
await reconnect({ status: 204, ok: false });
@@ -1178,7 +1094,7 @@ describe("ChatThread — live reconnect + stalled", () => {
it("exhausts the attempt limit into a manual Retry, which restarts the sequence", async () => {
renderLive();
await disconnect();
disconnect();
for (let n = 1; n <= 5; n++) {
advanceToAttempt(n);
expect(h.state.resumeStream).toHaveBeenCalledTimes(n);
@@ -1196,23 +1112,22 @@ describe("ChatThread — live reconnect + stalled", () => {
it("#488 commit 3: two breaks in a row produce two reconnect cycles", async () => {
renderLive();
// First break -> reconnect -> re-attach live.
await disconnect();
disconnect();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
await reconnect({ status: 200, ok: true });
expect(screen.queryByText(/reconnecting/i)).toBeNull();
// The re-attached observer (live-follow) stream drops AGAIN -> a SECOND reconnect
// cycle. #491: this too re-seeds from persist before re-attaching (never tail-
// applies over the live-follow partial).
await disconnect();
// The re-attached observer stream drops AGAIN -> a SECOND reconnect cycle
// (the old one-shot !wasResumed gate sent this to silent poll).
disconnect();
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(2);
});
it("does NOT reconnect when autonomous runs are disabled", async () => {
it("does NOT reconnect when autonomous runs are disabled", () => {
renderThread({ autonomousRunsEnabled: false, initialRows: settledTail() });
await disconnect();
disconnect();
expect(screen.queryByText(/reconnecting/i)).toBeNull();
expect(
screen.getByText("Connection lost — the answer was interrupted."),
@@ -1223,7 +1138,7 @@ describe("ChatThread — live reconnect + stalled", () => {
it("#488 commit 4a: the poll idle cap surfaces a stalled banner + Retry (not silent)", async () => {
renderLive();
await disconnect();
disconnect();
advanceToAttempt(1);
await reconnect({ status: 204, ok: false }); // arms the poll (reconnecting)
// No activity for the whole idle cap -> stalled.
@@ -1254,88 +1169,4 @@ describe("ChatThread — live reconnect + stalled", () => {
});
expect(onResumeFallback).toHaveBeenCalledWith(false); // POLL_IDLE_CAP -> idle -> disarm
});
it("#541: getRun HANGS on a live disconnect — the timeout race still enters reconnect (no silent freeze in `streaming`)", async () => {
// MUTATION-VERIFY: revert the race to a bare `getRun(cid).then/.catch` and this
// reddens — a HUNG (never-settling, NOT rejected) getRun leaves the FSM stuck in
// `streaming` with no reconnect banner and no poll (the axios client sets no
// request timeout, and the stalled-idle cap only arms AFTER reconnecting/polling).
renderLive();
h.state.getRun.mockReset();
h.state.getRun.mockReturnValue(new Promise(() => {})); // getRun HANGS forever
await disconnect(); // live partial = liveMsg (id "a2")
expect(h.state.getRun).toHaveBeenCalledWith("c1");
// BEFORE the bound fires the FSM is still in the live turn — the very freeze bug.
expect(screen.queryByText(/reconnecting/i)).toBeNull();
// The recovery-start bound fires -> the SAME fallback as the reject path.
act(() => {
vi.advanceTimersByTime(4_000);
});
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
// The live partial (a2) was DROPPED from the store (replay-from-start, no stale
// tail-apply base). Mirrors the getRun-REJECT test's inspection.
const removedLivePartial = (
h.state.setMessages as unknown as {
mock: { calls: [unknown][] };
}
).mock.calls.some(([updater]) => {
if (typeof updater !== "function") return false;
const out = (updater as (p: { id: string }[]) => { id: string }[])([
{ id: "a2" },
{ id: "u1" },
]);
return !out.some((m) => m.id === "a2");
});
expect(removedLivePartial).toBe(true);
// ...and the anchor was NULLED -> replay-from-start (no ?anchor=&n= over the partial).
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream",
);
});
it("#541: a getRun resolve AFTER the timeout already fired is IGNORED (no double reconnect, no stale re-seed)", async () => {
// The timeout wins first and enters the ladder via replay-from-start. When the
// hung getRun FINALLY answers, its late `.then` must be a full no-op: it must not
// re-seed the store from the (now stale) persisted row, must not re-set the
// anchor, and must not re-enter reconnect. The local `settled` flag makes the
// resolve/reject/timeout branches mutually exclusive.
// MUTATION-VERIFY: drop the `settled` guard (let the late `.then` run) and the
// late re-seed re-sets the anchor (URL regains ?anchor=a2&n=3) + calls setMessages.
renderLive();
let resolveGetRun!: (v: unknown) => void;
h.state.getRun.mockReset();
h.state.getRun.mockReturnValue(
new Promise((r) => {
resolveGetRun = r;
}),
);
await disconnect();
act(() => {
vi.advanceTimersByTime(4_000); // bound fires -> replay-from-start, reconnecting
});
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
// Anchor is null -> replay-from-start URL (the pre-condition the late resolve must
// not undo).
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream",
);
const setMessagesCallsBefore = h.state.setMessages.mock.calls.length;
// NOW the hung getRun finally resolves with a persisted anchor (id a2, steps 3).
await act(async () => {
resolveGetRun(persistedAnchor());
await Promise.resolve();
});
// The late resolve did NOT re-seed the store...
expect(h.state.setMessages.mock.calls.length).toBe(setMessagesCallsBefore);
// ...did NOT re-set the anchor (URL stays replay-from-start, no ?anchor=&n=)...
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream",
);
// ...and did NOT trigger a fresh reconnect attach.
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
});
});
@@ -42,7 +42,7 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
import {
isStreamingTail,
isSettledAssistantTail,
stepsPersistedOf,
seedRows,
mergeById,
} from "@/features/ai-chat/utils/resume-helpers.ts";
import { getRun } from "@/features/ai-chat/services/ai-chat-service.ts";
@@ -81,17 +81,6 @@ const STREAM_THROTTLE_MS = 50;
// THREAD now (the FSM owns polling->stalled); the window just polls while armed.
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
// #541: the bound on the persist re-seed (getRun) wait when ENTERING the reconnect
// ladder after a live SSE drop. The axios client (lib/api-client.ts) sets NO request
// timeout, and the stalled-idle cap only arms AFTER the FSM enters reconnecting/
// polling — which never happens if getRun HANGS (connection established, no response).
// Without this bound a live drop + a hung getRun sticks the FSM in `streaming` with
// no banner and no poll until the browser socket timeout (minutes) — the silent-freeze
// class #497 eliminates. This is a deliberate RECOVERY-START bound (drop the live
// partial + enter the ladder so the poll/idle-cap arms), intentionally much shorter
// than any network socket timeout — not a network read timeout.
const RECONNECT_RESEED_TIMEOUT_MS = 4_000;
/** The #487 active (non-terminal) run statuses mirrors the server's
* ACTIVE_RUN_STATUSES. A run-fact is "active" only for these. */
function isActiveRunStatus(status: string | null | undefined): boolean {
@@ -277,36 +266,25 @@ export default function ChatThread({
// is NOT one of the lifecycle flags the FSM replaced.
const mountedRef = useRef(true);
// attachStrategy DATA (behind the resumeStream effect; #491 tail-only, WITHOUT
// touching the FSM). The controller is effect-owned (aborted in cleanup, I5).
// `anchorRef` is the PERSISTED assistant row that pins the run (server invariant
// 6) and its persisted step frontier N: it feeds `?anchor=<id>&n=<stepsPersisted>`
// so the tail-only attach returns frames for steps >= N (the seed carries 0..N-1).
// It is NOT a "stripped" row — the seed keeps every row (tail-only replaces the
// old full-replay+strip). Null when there is no streaming/active tail to resume.
// attachStrategy DATA (behind the resumeStream effect; #491 swaps it to tail-only
// WITHOUT touching the FSM). The controller is effect-owned (aborted in cleanup,
// I5). `stripRef`/`strippedRowRef` are the current full-replay+strip anchor.
const attachAbortRef = useRef<AbortController | null>(null);
const anchorRef = useRef<{ id: string; stepsPersisted: number } | null>(
(() => {
if (chatId === null || !isStreamingTail(initialRows ?? [])) return null;
const rows = initialRows ?? [];
const tail = rows[rows.length - 1];
return { id: tail.id, stepsPersisted: stepsPersistedOf(tail) };
})(),
const stripRef = useRef(chatId !== null && isStreamingTail(initialRows ?? []));
const strippedRowRef = useRef<IAiChatMessageRow | null>(
stripRef.current ? (initialRows ?? [])[initialRows!.length - 1] : null,
);
// Effect-owned backoff timers (not lifecycle flags): the reconnect ladder and the
// stalled inactivity cap. Cleared by the cancelReconnect effect / the cap effect.
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const idleCapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// #541: the persist re-seed (getRun) timeout race on the disconnect->reconnect
// entry. Held in a ref so unmount (DISPOSE cleanup) clears it — no dangling timer.
const reseedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// #491 tail-only: seed EVERY persisted row unchanged (no strip). The streaming
// tail holds steps 0..N-1; the run-stream registry's tail (steps >= N) is APPENDED
// to it by the SDK continuation (readUIMessageStream({ message })), so it must be
// present in the store for the attach to continue the RIGHT message.
const initialMessages = useMemo<UIMessage[]>(
() => (initialRows ?? []).map(rowToUiMessage),
() =>
seedRows(
initialRows ?? [],
stripRef.current && autonomousRunsEnabled === true,
).map(rowToUiMessage),
[initialRows],
);
@@ -357,16 +335,21 @@ export default function ChatThread({
(eff: RunEffect, epoch: number) => {
switch (eff.type) {
case "resumeStream": {
// The attach GET. Stamp the outcome's generation (I1). #491 tail-only: the
// store already holds EXACTLY the persisted steps 0..N-1 (the mount seed IS
// persist; a reconnect was re-seeded from persist BEFORE FINISH_DISCONNECT
// scheduled it — see the onFinish disconnect handler), so there is nothing
// to filter here: the SDK continues that seeded message, appending the tail
// (steps >= N) without duplicating the pre-drop partial step.
// The attach GET. Stamp the outcome's generation (I1). A reconnect
// attempt filters the pinned live row from the store first (the mount
// seed already stripped it), so the live replay's text-start rebuilds it
// without duplicating parts (#430).
pendingAttachEpochRef.current = epoch;
// The resumed stream's onFinish is stamped with THIS attach generation
// (F1), so a superseded attempt's late finish is dropped.
turnEpochRef.current = epoch;
if (machineRef.current.phase.name === "reconnecting") {
const anchor = strippedRowRef.current;
if (anchor)
setMessagesRef.current?.((prev) =>
prev.filter((m) => m.id !== anchor.id),
);
}
void resumeStreamRef.current?.();
break;
}
@@ -481,23 +464,18 @@ export default function ChatThread({
new DefaultChatTransport<UIMessage>({
api: "/api/ai-chat/stream",
credentials: "include",
prepareReconnectToStreamRequest: () => {
// #491 tail-only attach URL. When there is an anchor (a streaming/active
// tail to resume) build `?anchor=<assistantRowId>&n=<stepsPersisted>`: the
// server returns the TAIL — a synthetic `start` frame + frames for steps
// >= n, then live — which the SDK continuation appends to the seeded row.
// The server 204s (-> restore-noop + poll) when it cannot cover the
// frontier (overflow/rotation gap) or the anchor mismatches (a newer run).
// No anchor (a user tail / pre-first-frame break) => no params.
const anchor = anchorRef.current;
return {
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
anchor
? `?anchor=${anchor.id}&n=${anchor.stepsPersisted}`
: ""
}`,
};
},
prepareReconnectToStreamRequest: () => ({
// Build the attach URL from the REAL chat id. ?expect=live&anchor=<row id>
// only when a streaming tail was stripped: expect=live opts into a
// finished-retained replay (safe only because the row is stripped and the
// replay rebuilds it), and the anchor pins the replay to OUR run — a
// mismatching (newer) run 204s into the restore+poll path instead.
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
stripRef.current
? `?expect=live&anchor=${strippedRowRef.current!.id}`
: ""
}`,
}),
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
if ((init.method ?? "GET") !== "GET") {
// Send path (POST). #488 commit 5: NO client 409 retry ladder anymore
@@ -584,9 +562,8 @@ export default function ChatThread({
// Attach GET outcome -> FSM event. The epoch guard replaces BOTH the one-shot
// 204 guard (noStreamHandledRef) and the unmount gate: a stale/superseded or
// post-DISPOSE outcome is dropped (I1). #491 tail-only: on a NONE outcome there is
// NOTHING to restore the anchor row was never stripped from the view (the seed
// keeps it) — so we only invalidate for a fresh poll + dispatch the FSM event.
// post-DISPOSE outcome is dropped (I1). For a NONE outcome the attachStrategy
// recovery (restore the stripped row + invalidate for a fresh poll) runs first.
const handleAttachOutcome = useCallback(
(ep: number, wasReconnecting: boolean, live: boolean) => {
if (ep !== epochRef.current) return; // stale generation — drop
@@ -598,6 +575,10 @@ export default function ChatThread({
);
return;
}
if (strippedRowRef.current)
setMessagesRef.current?.((prev) =>
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
);
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
});
@@ -680,155 +661,71 @@ export default function ChatThread({
// keeps executing server-side — must win; only a NON-disconnect error (a
// provider 500, `{ isError:true, isDisconnect:false }`) is terminal.
if (isDisconnect) {
if (!mountedRef.current) {
if (wasObserver) {
// A resumed/attached OBSERVER stream dropped. Recover via the degraded
// poll (restore the stripped row only when there is no visible content;
// never clobber a fuller on-screen tail, invariant 9). The FSM decides
// reconnect-vs-poll from liveFollow (a live-follow drop reconnects again,
// #488 commit 3; a mount-resume drop polls).
if (mountedRef.current) {
const hasVisible = msgHasVisible;
if (!hasVisible && strippedRowRef.current)
setMessages((prev) =>
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
);
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
});
dispatch({
type: "FINISH_DISCONNECT",
hasVisibleContent: hasVisible,
epoch: stampEpoch,
});
}
setStopNotice(null);
return;
}
// No detached run to recover (legacy, non-autonomous): a plain disconnect —
// terminal notice, no reconnect. (An observer only exists in autonomous mode,
// so this is always a local turn.)
if (autonomousRunsEnabled !== true) {
// A LOCAL live turn dropped. #488 commit 2: recover by the RUN-FACT, not by
// the presence of an assistant message — a setup-phase break (before the
// first frame) still leaves a detached run writing to pages. In autonomous
// mode a run is active for the whole turn, so seed the run-fact from the
// start-metadata runId when known, else a sentinel (the attach GET goes by
// chatId, not runId). Pin the assistant row as the strip/anchor when present.
if (autonomousRunsEnabled === true && mountedRef.current) {
const hasAnchor =
message?.role === "assistant" && typeof message.id === "string";
if (hasAnchor) {
strippedRowRef.current = {
id: message.id,
role: "assistant",
content: "",
status: "streaming",
createdAt: new Date().toISOString(),
metadata: { parts: message.parts },
};
stripRef.current = true;
} else {
strippedRowRef.current = null;
stripRef.current = false;
}
dispatch({
type: "RUN_FACT",
runFact: { runId: extractRunId(message) ?? "pending" },
});
dispatch({
type: "FINISH_DISCONNECT",
hasVisibleContent: msgHasVisible,
epoch: stampEpoch,
});
setStopNotice(null);
} else {
dispatch({
type: "FINISH_DISCONNECT",
hasVisibleContent: false,
epoch: stampEpoch,
});
setStopNotice("disconnect");
return;
}
// A mount-resume OBSERVER (one-shot resume, NOT live-follow) drop falls to
// the degraded POLL, which merges by id — it does NOT attach, so there is
// nothing to re-seed. #491 tail-only: the anchor row was never removed from
// the view (the seed keeps it; the continuation only APPENDED), so nothing to
// restore either. The FSM routes this to `polling` (ownership observer,
// !liveFollow).
if (wasObserver && !machineRef.current.ctx.liveFollow) {
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
});
dispatch({
type: "FINISH_DISCONNECT",
hasVisibleContent: msgHasVisible,
epoch: stampEpoch,
});
setStopNotice(null);
return;
}
// We will (re-)ENTER THE RECONNECT LADDER (an attach): a LOCAL live turn's
// first drop, OR a live-follow observer's SUBSEQUENT drop (#488 commit 3).
// #488 commit 2: recover by the RUN-FACT, not by the presence of an assistant
// message — a setup-phase break still leaves a detached run writing to pages.
//
// #491 tail-only (THE crux): the live store holds a PARTIAL step that is AHEAD
// of the persisted boundary; tail-applying the reconnect's step frames over it
// would DUPLICATE that partial step. So entering reconnecting is ALWAYS via a
// RE-SEED FROM PERSIST — never the live store. Fetch the authoritative
// persisted assistant row (`getRun` returns the projected `message`), replace
// the live partial by id (mergeById -> the store now holds EXACTLY steps
// 0..N-1), and set the anchor to `{ id, n = stepsPersisted }`. Only AFTER the
// re-seed is applied do we enter the ladder (FINISH_DISCONNECT schedules the
// backoff) — so the attach can never tail-apply over the live partial.
const cid = chatIdRef.current;
// The live-message runId is the run-fact source (the attach GET keys on
// chatId, so a sentinel still recovers a setup-phase break).
const runId = extractRunId(message ?? undefined) ?? "pending";
const enterReconnect = (fact: string): void => {
if (!mountedRef.current) return;
// Epoch-stamp the run-fact too (I1): the getRun rtt widens the
// onFinish->dispatch window, so a concurrent SEND_LOCAL during it must be
// able to drop this stale RUN_FACT (else it clobbers the new turn's
// runFact.runId). Consistent with the postRun RUN_FACT stamp.
dispatch({ type: "RUN_FACT", runFact: { runId: fact }, epoch: stampEpoch });
dispatch({
type: "FINISH_DISCONNECT",
hasVisibleContent: msgHasVisible,
epoch: stampEpoch,
});
};
// Restore the STRUCTURAL guarantee that the live partial is never the
// tail-apply base: drop the live partial from the store by id and null the
// anchor, so the reconnect replays from step 0 into a CLEAN store (a full
// rebuild) or, past any rotation, 204s -> degraded poll. Used on BOTH the
// no-persisted-row and getRun-FAILURE paths — after this there is no path
// where the attach tail-applies frames onto a row that already has them
// (the #137/#161 duplication class).
const dropLivePartialAndReplayFromStart = (): void => {
if (message?.role === "assistant" && typeof message.id === "string") {
const liveId = message.id;
setMessagesRef.current?.((prev) =>
prev.filter((m) => m.id !== liveId),
);
}
anchorRef.current = null;
};
if (cid) {
// #541: bound the persist re-seed wait with a timeout race. getRun goes
// through the axios client, which has NO request timeout; a HUNG getRun
// (connection open, no response) — distinct from a REJECT, which the
// `.catch` already handles — would otherwise never let us enter the ladder,
// leaving the FSM stuck in `streaming` with no banner and no poll until the
// browser socket timeout. `settled` makes the three branches (resolve /
// reject / timeout) mutually exclusive: whichever fires FIRST wins and the
// others become no-ops. So a LATE getRun resolve AFTER the timeout is fully
// ignored — it cannot (i) re-enter reconnect a second time, (ii) overwrite
// the timeout's replay-from-start with a stale re-seed, or (iii) re-arm any
// timer. On timeout we take the SAME fallback as the reject path (drop the
// live partial + enter the ladder, so the poll and the stalled-idle cap arm).
let settled = false;
const finishReseed = (apply: () => void): void => {
if (settled) return;
settled = true;
if (reseedTimerRef.current) {
clearTimeout(reseedTimerRef.current);
reseedTimerRef.current = null;
}
if (!mountedRef.current) return;
apply();
};
reseedTimerRef.current = setTimeout(() => {
finishReseed(() => {
dropLivePartialAndReplayFromStart();
enterReconnect(runId);
});
}, RECONNECT_RESEED_TIMEOUT_MS);
void getRun(cid)
.then((res) => {
finishReseed(() => {
const persisted = res.message;
if (persisted && persisted.role === "assistant") {
anchorRef.current = {
id: persisted.id,
stepsPersisted: stepsPersistedOf(persisted),
};
// Replace the live partial with the persisted row IN PLACE by id —
// the re-seed from persist. The attach's tail (steps >= N) then
// appends to a store holding EXACTLY steps 0..N-1: no duplication.
setMessages((prev) => mergeById(prev, rowToUiMessage(persisted)));
} else {
// No persisted assistant row (pre-first-frame break): drop the live
// partial + replay from start (no anchor/n) so nothing is duplicated.
dropLivePartialAndReplayFromStart();
}
enterReconnect(res.run?.id ?? runId);
});
})
.catch(() => {
finishReseed(() => {
// Persist read FAILED: we cannot re-seed from fresh persist, and a
// stale mount-time anchor over the live partial would tail-apply
// already-present steps -> duplication (a flaky-network blip:
// SSE + getRun both fail, network recovers in ~1s, the registry still
// covers from the mount frontier). Restore the removed-filter guarantee
// instead: drop the live partial + replay from start / 204 -> poll.
dropLivePartialAndReplayFromStart();
enterReconnect(runId);
});
});
} else {
dropLivePartialAndReplayFromStart();
enterReconnect(runId);
}
setStopNotice(null);
return;
}
// A NON-disconnect stream error (a provider 500 etc.) -> terminal error banner.
@@ -849,10 +746,11 @@ export default function ChatThread({
if (mountedRef.current) {
const hasVisible = msgHasVisible;
if (!hasVisible) {
// Starved replay (the tail carried no new steps). #491 tail-only: the
// seeded steps 0..N-1 are still on screen (the SDK continuation never
// wiped them — `start` does not reset parts), so there is nothing to
// restore; just poll to the real terminal.
// Starved replay: restore the stripped row + poll to the real terminal.
if (strippedRowRef.current)
setMessages((prev) =>
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
);
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
});
@@ -948,12 +846,6 @@ export default function ChatThread({
}
return () => {
mountedRef.current = false;
// #541: clear the in-flight persist re-seed timeout (not an FSM effect timer,
// so DISPOSE does not touch it) — no dangling setTimeout after unmount.
if (reseedTimerRef.current) {
clearTimeout(reseedTimerRef.current);
reseedTimerRef.current = null;
}
dispatch({ type: "DISPOSE" }); // aborts attach + timers, bumps epoch (I5)
};
// Mount-only by design; the parent remounts per chat via `key`.
@@ -971,12 +863,12 @@ export default function ChatThread({
const tail = rows[rows.length - 1];
if (!tail || tail.role !== "assistant") return;
setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
// Anchor-mismatch coherence: if a DIFFERENT run's row B has replaced our anchor
// row A as the tail, A would linger as an orphan — reconcile A by id from FRESH
// PERSISTED history (not the pinned live row) so no phantom row survives.
const anchor = anchorRef.current;
if (anchor && anchor.id !== tail.id) {
const historical = rows.find((r) => r.id === anchor.id);
// Anchor-mismatch coherence: a restored stripped row A that a DIFFERENT run's
// row B has replaced as the tail would linger as an orphan — settle A from
// fresh history so no phantom row survives.
const stripped = strippedRowRef.current;
if (stripped && stripped.id !== tail.id) {
const historical = rows.find((r) => r.id === stripped.id);
if (historical)
setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
}
@@ -27,7 +27,6 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
import MessageItem from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import { splitPlainChunks } from "./streaming-plain-text";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
@@ -115,89 +114,3 @@ describe("MessageItem markdown memoization", () => {
expect(queryByText("streamed answer")).not.toBeNull();
});
});
// PERF SMOKE (#492): the whole point of the incremental streaming render is that
// the ANSWER path costs O(number of markdown blocks), NOT O(number of throttled
// ~20Hz ticks). Pre-#492 the finalized MarkdownPart re-parsed the WHOLE growing
// answer on every delta — a synthetic ~100 KB stream measured 394 renderChatMarkdown
// calls (one per tick). With the incremental render each STABILIZED block is parsed
// exactly once (memoized in MarkdownChunk) and the live tail is cheap plain text, so
// the call count collapses to ~= the block count regardless of tick granularity.
describe("MessageItem streaming answer render is O(blocks), not O(ticks)", () => {
// ~100 KB answer. Each section is a heading + a paragraph — TWO blank-line
// delimited markdown blocks — so the safe-cut block count is ~2× the section
// count. The perf claim is about the BLOCK count (the memoization granularity),
// measured directly with splitPlainChunks below, not the section count.
const buildAnswer = () => {
const SECTIONS = 100;
const paragraphs: string[] = [];
for (let i = 0; i < SECTIONS; i++) {
paragraphs.push(`## Section ${i}\n\n` + "lorem ipsum dolor ".repeat(55));
}
const full = paragraphs.join("\n\n");
// The number of memoized markdown blocks the incremental render splits into
// (all but the live tail are parsed once each).
return { full, blocks: splitPlainChunks(full).length };
};
const streamMsg = (text: string, state: "streaming" | "done"): UIMessage =>
({
id: "m1",
role: "assistant",
parts: [{ type: "text", text, state }],
}) as UIMessage;
it("parses each block ~once over a 100KB stream (≈blocks, ≪ ticks)", () => {
renderChatMarkdownSpy.mockClear();
const { full, blocks } = buildAnswer();
const CHUNK = 128; // a realistic ~20Hz throttled delta size
const ticks = Math.ceil(full.length / CHUNK);
let msg = streamMsg(full.slice(0, CHUNK), "streaming");
const { rerender } = render(
<MantineProvider>
<MessageItem
message={msg}
signature={messageSignature(msg)}
turnStreaming
/>
</MantineProvider>,
);
for (let end = 2 * CHUNK; end < full.length; end += CHUNK) {
msg = streamMsg(full.slice(0, end), "streaming");
rerender(
<MantineProvider>
<MessageItem
message={msg}
signature={messageSignature(msg)}
turnStreaming
/>
</MantineProvider>,
);
}
// Finalize: the streaming→done flip renders the whole answer through ONE
// canonical pass (visual parity), so the finished DOM matches the pre-#492
// output. This is the single extra parse on top of the per-block ones.
const done = streamMsg(full, "done");
rerender(
<MantineProvider>
<MessageItem message={done} signature={messageSignature(done)} />
</MantineProvider>,
);
const calls = renderChatMarkdownSpy.mock.calls.length;
// Sanity: the stream really had far more ticks than blocks (else the test is
// vacuous — the point is that calls scale with blocks, not ticks).
expect(ticks).toBeGreaterThan(blocks * 3);
// O(blocks): each stabilized block parsed once + the single final whole-text
// parse. A small constant absorbs the finalize render and the live-tail block;
// the load-bearing claim is the bound below.
expect(calls).toBeLessThanOrEqual(blocks + 2);
// ≪ ticks — and, non-vacuously, the blocks WERE parsed (not skipped entirely).
expect(calls).toBeLessThan(ticks / 3);
expect(calls).toBeGreaterThan(blocks / 2);
// MUTATION-VERIFY (documented, not run here): dropping the `memo()` wrapper on
// MarkdownChunk (so every stable block re-parses each tick) drives `calls`
// toward `ticks` (~394), reddening both upper-bound assertions above.
});
});
@@ -1,112 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (the component reads `useTranslation`). Mirrors the other
// message-item specs.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import MessageItem from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
// The REAL canonical renderer (NOT the spy the memo test installs): this file
// exercises the actual markdown output so the visual-regression assertions below
// compare against genuine HTML (incl. the schema's `<li><p>` wrappers).
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import classes from "./ai-chat.module.css";
const msg = (
parts: UIMessage["parts"],
extra?: Partial<UIMessage>,
): UIMessage =>
({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage;
const renderRow = (message: UIMessage, turnStreaming = false) =>
render(
<MantineProvider>
<MessageItem
message={message}
signature={messageSignature(message)}
turnStreaming={turnStreaming}
/>
</MantineProvider>,
);
// A rich multi-block answer that exercises headings, a list (the `<li><p>` case
// the scoped CSS tightens), inline emphasis, and multiple paragraphs.
const ANSWER = [
"# Заголовок",
"",
"Первый абзац с **жирным** и `кодом`.",
"",
"- пункт один",
"- пункт два",
"",
"Второй абзац.",
].join("\n");
describe("MessageItem final render — visual parity with the canonical pipeline", () => {
it("a finalized text part renders exactly renderChatMarkdown(text)", () => {
const { container } = renderRow(
msg([{ type: "text", text: ANSWER, state: "done" }]),
);
const block = container.querySelector(`.${classes.markdown}`);
expect(block).not.toBeNull();
// Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492
// MarkdownPart produced), including `<li><p>…</p></li>` wrappers.
expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
// The list wrapper is really present (guards against a vacuous empty render).
expect(container.querySelectorAll("li p").length).toBe(2);
});
it("the streaming incremental view CONVERGES to the canonical render on finish", () => {
// Mount mid-stream (live tail) — the DOM here is the incremental view.
const { container, rerender } = render(
<MantineProvider>
<MessageItem
message={msg([{ type: "text", text: ANSWER, state: "streaming" }])}
signature={messageSignature(
msg([{ type: "text", text: ANSWER, state: "streaming" }]),
)}
turnStreaming
/>
</MantineProvider>,
);
// Finish the turn: state flips to done AND the turn is no longer streaming.
const done = msg([{ type: "text", text: ANSWER, state: "done" }]);
rerender(
<MantineProvider>
<MessageItem message={done} signature={messageSignature(done)} />
</MantineProvider>,
);
// After finish there is exactly ONE canonical markdown container whose HTML is
// the whole-text render — identical to the non-streaming path above.
const blocks = container.querySelectorAll(`.${classes.markdown}`);
expect(blocks.length).toBe(1);
expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
});
it("neutralizeInternalLinks is honored on the finalized render", () => {
const linkAnswer = "См. [страницу](/p/abc).";
const { container } = render(
<MantineProvider>
<MessageItem
message={msg([{ type: "text", text: linkAnswer, state: "done" }])}
signature={messageSignature(
msg([{ type: "text", text: linkAnswer, state: "done" }]),
)}
neutralizeInternalLinks
/>
</MantineProvider>,
);
const block = container.querySelector(`.${classes.markdown}`);
expect(block!.innerHTML).toBe(
renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }),
);
// The internal link was made inert (no href) by the neutralization flag.
const a = container.querySelector("a");
expect(a?.hasAttribute("href")).toBe(false);
});
});
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
import { StreamingMarkdownText } from "@/features/ai-chat/components/streaming-markdown-text.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
@@ -87,39 +86,17 @@ interface MessageItemProps {
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
* so a finalized text part is NOT re-parsed on every streamed delta: during a
* turn only the actively-growing tail part changes its `text`, so every earlier
* part hits the memo and skips the expensive canonical parse + DOMPurify pass.
* Props are primitives, so React.memo's default shallow compare is exactly right
* (the `text` string is compared by value).
*
* Streaming gate (#492) mirrors ReasoningBlock:
* - `streaming` (this is the live, actively-growing tail part of an in-flight
* turn): render incrementally via StreamingMarkdownText the stabilized blocks
* go through the canonical pipeline (each parsed ONCE, memoized) and only the
* live tail is cheap plain text. This makes the per-tick cost O(new blocks),
* not the pre-#492 O(ticks) whole-answer re-parse on every ~20Hz delta.
* - finalized (the common case, and the turn-end flip): render the WHOLE text
* through ONE canonical pass byte-identical to the pre-#492 output (visual
* parity). The row re-renders on the streamingdone flip because
* `messageSignature` tracks each part's `state` (and `turnStreaming` flips at
* turn end), so the incremental view always converges to this single render.
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
* primitives, so React.memo's default shallow compare is exactly right (the
* `text` string is compared by value).
*/
const MarkdownPart = memo(function MarkdownPart({
text,
neutralizeInternalLinks,
streaming,
}: {
text: string;
neutralizeInternalLinks: boolean;
streaming: boolean;
}) {
if (streaming) {
return (
<StreamingMarkdownText
text={text}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
);
}
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
if (html) {
return (
@@ -202,10 +179,47 @@ function MessageItem({
{resolveAssistantName(assistantName) ?? t("AI agent")}
</Text>
{message.parts.map((part, index) => {
// Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so
// they cannot be a `switch` case; the runtime guard handles them, and the
// switch below covers every CLOSED (literal-typed) part kind with a
// compile-time exhaustiveness check in its default.
if (part.type === "reasoning") {
// Reasoning ("thinking") -> a collapsible block with its own token
// count. Empty/whitespace reasoning with no authoritative count carries
// nothing to show, so skip it (avoids an empty 0-token block).
const text = (part as { text?: string }).text ?? "";
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
return null;
// Absent state (persisted rows) and "done" both mean finalized.
// `messageSignature` already includes each part's `state`, so the
// streaming→done flip changes the row signature and re-renders this
// row — which is what lets ReasoningBlock switch from chunked plain
// text to its one-time markdown parse (see reasoning-block.tsx).
// ALSO require the turn to be live: a part stranded at
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
// the `turnStreaming` prop doc) must still finalize and parse.
const streaming =
turnStreaming && (part as { state?: string }).state === "streaming";
return (
<ReasoningBlock
key={index}
text={text}
tokens={reasoningTokens}
streaming={streaming}
/>
);
}
if (part.type === "text") {
// Skip empty/whitespace-only text parts (a streaming message often
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
return (
<MarkdownPart
key={index}
text={part.text}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
);
}
if (isToolPart(part.type)) {
return (
<ToolCallCard
@@ -218,76 +232,7 @@ function MessageItem({
);
}
switch (part.type) {
case "reasoning": {
// Reasoning ("thinking") -> a collapsible block with its own token
// count. Empty/whitespace reasoning with no authoritative count
// carries nothing to show, so skip it (avoids an empty 0-token block).
const text = part.text ?? "";
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
return null;
// Absent state (persisted rows) and "done" both mean finalized.
// `messageSignature` already includes each part's `state`, so the
// streaming→done flip changes the row signature and re-renders this
// row — which is what lets ReasoningBlock switch from chunked plain
// text to its one-time markdown parse (see reasoning-block.tsx).
// ALSO require the turn to be live: a part stranded at
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
// the `turnStreaming` prop doc) must still finalize and parse.
const streaming = turnStreaming && part.state === "streaming";
return (
<ReasoningBlock
key={index}
text={text}
tokens={reasoningTokens}
streaming={streaming}
/>
);
}
case "text": {
// Skip empty/whitespace-only text parts (a streaming message often
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
// The live, actively-growing tail part of the in-flight turn renders
// incrementally (see MarkdownPart); a finalized part (persisted, or
// the turn-end flip) renders the whole text through one canonical
// pass. Same liveness rule as the reasoning branch above.
const streaming = turnStreaming && part.state === "streaming";
return (
<MarkdownPart
key={index}
text={part.text}
neutralizeInternalLinks={neutralizeInternalLinks}
streaming={streaming}
/>
);
}
case "source-url":
case "source-document":
case "file":
case "step-start":
// Not surfaced in the chat bubble (v1) — same as the pre-#492 default.
return null;
default: {
// Compile-time exhaustiveness over the CLOSED union members: every
// literal-typed part kind is handled above, so the only kinds that
// can reach here are the OPEN template-literal ones (`tool-*` — caught
// by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding
// a NEW closed part kind to UIMessagePart makes this assignment fail
// to compile, forcing it to be handled instead of silently ignored
// (this replaces the pre-#492 fall-through `return null` + WARNING).
const _exhaustive:
| `tool-${string}`
| "dynamic-tool"
| `data-${string}` = part.type;
void _exhaustive;
return null;
}
}
return null;
})}
{/* A persisted turn error (server stored it in metadata.error). Rendered
here so it survives a thread remount and shows in reopened history. */}
@@ -1,96 +0,0 @@
import { memo, useMemo } from "react";
import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
/**
* One STABILIZED markdown block, rendered through the canonical pipeline and
* memoized on its string prop. During streaming only the TAIL chunk grows (the
* `splitPlainChunks` append-only invariant guarantees every earlier chunk is
* byte-identical across deltas), so React skips every stable block and each one
* is parsed by `renderChatMarkdown` EXACTLY ONCE turning the pre-#492
* "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into
* O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown
* before it reaches `dangerouslySetInnerHTML`.
*
* NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary,
* so a construct that legitimately contains a blank line (e.g. a fenced code block
* with an empty line) can be split across chunks and render oddly WHILE it is still
* streaming. This is cosmetic and self-heals: the moment the part finalizes,
* MarkdownPart renders the WHOLE text through one canonical pass (visual parity
* with the pre-#492 output). The reasoning path makes the same trade (plain text
* while streaming, one markdown parse at the end).
*/
const MarkdownChunk = memo(function MarkdownChunk({
text,
neutralizeInternalLinks,
}: {
text: string;
neutralizeInternalLinks: boolean;
}) {
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
if (html) {
return (
<div
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Malformed/unsupported markdown could not render synchronously: raw text.
return (
<div className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{text}
</div>
);
});
/**
* The cheap streaming-time stand-in for the finalized answer's one-time markdown
* parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's
* chunked-memo pattern but renders the STABILIZED prefix as real markdown (each
* block parsed once, memoized) and only the LIVE tail as flat plain text so the
* user sees formatted output for everything up to the last safe cut, and the not-
* yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a
* single cheap escaped text node until it stabilizes into a new block.
*
* `splitPlainChunks` yields chunks where, under append-only growth, every chunk
* except the LAST is immutable; the last chunk is the live tail. Index keys are
* therefore stable (a given index never changes to a different chunk's content).
*/
export function StreamingMarkdownText({
text,
neutralizeInternalLinks,
}: {
text: string;
neutralizeInternalLinks: boolean;
}) {
const chunks = useMemo(() => splitPlainChunks(text), [text]);
return (
<>
{chunks.map((chunk, index) =>
index < chunks.length - 1 ? (
<MarkdownChunk
key={index}
text={chunk}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
) : (
// The live tail: flat, React-escaped plain text (no markdown parse, no
// sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing
// separator newlines are dropped at display time so the block gap comes
// from the markdown margins, not a doubled empty line (mirrors
// PlainChunk in streaming-plain-text.tsx).
<div
key={index}
className={classes.markdown}
style={{ whiteSpace: "pre-wrap" }}
>
{chunk.replace(/\n+$/, "")}
</div>
),
)}
</>
);
}
@@ -57,31 +57,6 @@ export async function stopRun(
return req.data;
}
/**
* Delta poll (#491): the chat's message rows changed since `cursor` (a DB-clock
* timestamp echoed from the previous poll) plus the current run fact, in ONE
* round-trip the degraded-poll fallback's payload, replacing the old "refetch
* ALL infinite-query pages every 2.5s with full parts" poll. Omit `cursor` on the
* first poll (returns just a fresh cursor, no rows, to start the chain). The
* overlap window guarantees occasional REPEATS, so the caller MUST merge rows
* idempotently by id (mergeById). Owner-gated server-side.
*/
export async function getAiChatMessagesDelta(
chatId: string,
cursor?: string,
): Promise<{
rows: IAiChatMessageRow[];
cursor: string;
run: { id: string; status: string } | null;
}> {
const req = await api.post<{
rows: IAiChatMessageRow[];
cursor: string;
run: { id: string; status: string } | null;
}>("/ai-chat/messages/delta", { chatId, cursor });
return req.data;
}
/**
* #488: the run-fact "is a run active on this chat?" first-class from the
* server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact
@@ -48,7 +48,6 @@ Legend: **†** = command-transition (bumps `epoch`, I1). Effects in `[…]`.
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
| `POLL_IDLE_CAP` (inactivity cap) | stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (Review #4: a Stop-armed poll with no SDK/terminal backstop gets a bounded exit — NOT `stalled`, Stop was already pressed so nothing to retry) |
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
@@ -122,7 +121,8 @@ holds. **Pending column: empty.**
| 11 | `stopPendingRef` | **FSM phase `stopping`** | the deferred stop fires from the chat-id adoption effect while `stopping` |
| 12 | `mountedRef` | **retained (React liveness)** | orthogonal to run-lifecycle; gates imperative onFinish side-effects post-unmount. Epoch (I1) handles stale COMMAND-outcomes; DISPOSE bumps it |
| 13 | `attemptResumeRef` | **FSM `ATTACH_START` + run-fact** | mount arms attach ONLY on a confirmed active run (commit 4b: streaming-tail status, or POST /run for a user tail) |
| 14–15 | `anchorRef {id, stepsPersisted}` | **data** (attachStrategy) | #491 tail-only: replaced `stripRef`/`strippedRowRef`. The PERSISTED assistant row that pins the run (server invariant 6) + its step frontier N; feeds `?anchor=<id>&n=<stepsPersisted>`. No strip — the seed keeps every row; entering reconnecting re-seeds from persist |
| 14 | `stripRef` | **data** (attachStrategy) | strip+replay detail; the `resumeStream` effect reads it |
| 15 | `strippedRowRef` | **data** (attachStrategy) | the anchor row |
| 16 | `attachAbortRef` | **effect-owned controller** | aborted by the `abortAttach` effect in cleanup (I5) |
| 17–25 | `chatIdRef`, `openPageRef`, `getEditorSelectionRef`, `roleIdRef`, `stableIdRef`, `queuedRef`, `sendMessageRef`, `statusRef`, `lastForwardedChatIdRef` | **data** (identity/send mirrors) | unchanged — not lifecycle flags |
| NEW | `pendingSupersedeRef` | **data** (send-plumbing) | the runId injected into the next `POST /stream {supersede}`; the single replacement for the 3 DELETED one-shots (#8/#9/#10) — net −2 refs |
@@ -151,12 +151,8 @@ message. Sources, in the order they update `ctx.runFact`:
3. **Attach outcomes:** `ATTACH_LIVE` (2xx) confirms active; a 204 on a non-stripped
path is an authoritative NEGATIVE fact → the runtime dispatches `RUN_FACT{null}`,
which cancels recovery (I3 fresh-negative gate).
4. **Poll (#491, implemented):** the degraded poll now hits the delta endpoint
(`POST /ai-chat/messages/delta`), which ALREADY carries the run fact
(`run: {id, status} | null`) alongside the changed rows. The client does NOT yet
consume that run field — it still drives to a terminal ROW (merged by id),
dispatched as `POLL_TERMINAL` — so the run field rides the wire for a future
client that settles straight off it.
4. **Poll (future resume-stack iteration #491):** the delta will carry the run field;
until then the poll drives to a terminal ROW, dispatched as `POLL_TERMINAL`.
Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); the
204 then cuts it. A fresh negative fact gates recovery OUT immediately.
@@ -182,9 +178,6 @@ Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); th
/run) are effect-owned and aborted in cleanup (`abortAttach` on `DISPOSE`), not
render-phase refs. A client abort of an already-sent POST does not cancel the
server action, so disarming on unmount is safe.
- **attachStrategy** is behind the `resumeStream` effect; #491 swapped it to
tail-only (`?anchor=&n=`, `anchorRef` data) WITHOUT touching the FSM. Entering
reconnecting always re-seeds from persist; on a getRun failure the live partial
is dropped + replay-from-start so it is never the tail-apply base (no #137/#161
duplication).
- **attachStrategy** (strip+replay today) is behind the `resumeStream` effect; the
resume-stack iteration (#491) swaps it to tail-only WITHOUT touching the FSM.
- **Queue** stays a data structure; flush/interrupt decisions are transitions.
@@ -181,12 +181,6 @@ export interface IAiChatMessageRow {
toolCalls?: unknown;
metadata?: {
parts?: UIMessage["parts"];
// #491 step-alignment anchor: the count of FINISHED steps whose parts are in
// THIS row, written atomically with `parts` server-side (flushAssistant). The
// resume client reads it as its persisted step frontier N — the tail-only
// attach asks the run-stream registry for the frames of step N onward (the
// seed already carries steps 0..N-1). Absent on pre-#491 rows -> read as 0.
stepsPersisted?: number;
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
// figure (sum of every step's usage for the turn); kept for back-compat and
// as the fallback for older rows that have no `contextTokens`.
@@ -4,8 +4,7 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
import {
isStreamingTail,
isSettledAssistantTail,
stepsPersistedOf,
mergeDeltaRowsIntoPages,
seedRows,
mergeById,
} from "./resume-helpers.ts";
@@ -13,18 +12,8 @@ function row(
id: string,
role: string,
status?: string,
stepsPersisted?: number,
): IAiChatMessageRow {
return {
id,
role,
content: "",
status,
createdAt: "2026-01-01T00:00:00Z",
...(stepsPersisted !== undefined
? { metadata: { stepsPersisted } }
: {}),
};
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
}
function makeMsg(id: string, text: string): UIMessage {
@@ -76,92 +65,23 @@ describe("isSettledAssistantTail", () => {
});
});
describe("stepsPersistedOf", () => {
it("reads metadata.stepsPersisted", () => {
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 3))).toBe(3);
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 0))).toBe(0);
describe("seedRows", () => {
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
it("returns the rows unchanged when not stripping", () => {
expect(seedRows(rows, false)).toBe(rows);
});
it("defaults to 0 for a pre-#491 row (absent), null/undefined, or a bad value", () => {
expect(stepsPersistedOf(row("a1", "assistant", "streaming"))).toBe(0);
expect(stepsPersistedOf(null)).toBe(0);
expect(stepsPersistedOf(undefined)).toBe(0);
expect(
stepsPersistedOf({
id: "a1",
role: "assistant",
content: "",
createdAt: "x",
metadata: { stepsPersisted: -2 },
}),
).toBe(0);
it("drops the last row when stripping", () => {
const seeded = seedRows(rows, true);
expect(seeded).toHaveLength(1);
expect(seeded[0].id).toBe("u1");
});
it("floors a non-integer count", () => {
expect(
stepsPersistedOf({
id: "a1",
role: "assistant",
content: "",
createdAt: "x",
metadata: { stepsPersisted: 2.9 },
}),
).toBe(2);
});
});
describe("mergeDeltaRowsIntoPages", () => {
const pages = () => [
{ items: [row("u1", "user"), row("a1", "assistant", "streaming", 1)], meta: {} },
];
it("returns the pages unchanged for an empty delta", () => {
const p = pages();
expect(mergeDeltaRowsIntoPages(p, [])).toBe(p);
});
it("appends a genuinely new row to the last page in chronological order", () => {
const merged = mergeDeltaRowsIntoPages(pages(), [row("a2", "assistant", "streaming", 0)]);
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
});
it("replaces a grown row in place (per-step growth), never appends a duplicate", () => {
const merged = mergeDeltaRowsIntoPages(pages(), [
row("a1", "assistant", "streaming", 2),
]);
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1"]);
// the in-place replacement carries the grown step frontier.
expect(stepsPersistedOf(merged[0].items[1])).toBe(2);
});
it("does not mutate the input pages", () => {
const input = pages();
const before = input[0].items.slice();
mergeDeltaRowsIntoPages(input, [row("a2", "assistant", "streaming", 0)]);
expect(input[0].items).toEqual(before); // untouched
});
// #491 CONTRACT: the delta overlap window re-delivers the same rows, so merging
// MUST be idempotent — applying a delta twice equals applying it once (no growth,
// no reorder). A regression re-introduces duplicate assistant bubbles per poll.
it("is idempotent: applying the same delta twice equals once", () => {
const delta = [
row("a1", "assistant", "streaming", 2), // grown existing row
row("a2", "assistant", "streaming", 0), // new row
];
const once = mergeDeltaRowsIntoPages(pages(), delta);
const twice = mergeDeltaRowsIntoPages(once, delta);
const thrice = mergeDeltaRowsIntoPages(twice, delta);
expect(once[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
expect(twice[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
expect(twice).toEqual(once);
expect(thrice).toEqual(once);
});
it("seeds a first page when the cache is empty", () => {
const merged = mergeDeltaRowsIntoPages([], [row("u1", "user")]);
expect(merged).toHaveLength(1);
expect(merged[0].items.map((i) => i.id)).toEqual(["u1"]);
it("returns an empty list when stripping a single-row list", () => {
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
0,
);
});
});
@@ -189,37 +109,4 @@ describe("mergeById", () => {
expect(mergeById(prev, null)).toBe(prev);
expect(mergeById(prev, undefined)).toBe(prev);
});
// #491 CONTRACT: the delta poll's overlap window GUARANTEES the same row is
// re-delivered across close polls, so merging must be IDEMPOTENT by id — merging
// the same row (or an equal-length list of rows) twice must not duplicate or
// reorder. This is the property the whole delta-poll design leans on; a
// regression here would re-introduce duplicate assistant bubbles on every poll.
it("is idempotent by id: re-merging the same row does not duplicate or reorder", () => {
const seed = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
const repeat = makeMsg("a1", "step 1"); // the SAME row the overlap re-delivers
const once = mergeById(seed, repeat);
const twice = mergeById(once, repeat);
const thrice = mergeById(twice, repeat);
// Length is stable (no growth), order is stable (user then assistant).
expect(once.map((m) => m.id)).toEqual(["u1", "a1"]);
expect(twice.map((m) => m.id)).toEqual(["u1", "a1"]);
expect(thrice.map((m) => m.id)).toEqual(["u1", "a1"]);
// The repeated merge converges: the row is replaced in place, never appended.
expect(twice[1]).toBe(repeat);
});
it("is idempotent across a batch of repeated + grown rows (delta re-delivery)", () => {
// A delta poll re-delivers a1 (unchanged) and a2 (grown one step). Applying the
// batch twice must equal applying it once — the poll can re-send either.
const start = [makeMsg("u1", "hi"), makeMsg("a1", "done")];
const batch = [makeMsg("a1", "done"), makeMsg("a2", "grown step 2")];
const apply = (list: typeof start) =>
batch.reduce((acc, row) => mergeById(acc, row), list);
const once = apply(start);
const twice = apply(once);
expect(once.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
expect(twice.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
expect(twice).toEqual(once);
});
});
@@ -11,10 +11,9 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
/**
* A STREAMING tail: the last persisted row is an assistant row still marked
* `status === 'streaming'`. #491 (tail-only): such a tail is seeded UNCHANGED
* it carries the persisted steps 0..N-1 and the run-stream registry's tail
* (frames for steps >= N) is APPENDED to it by the SDK's `readUIMessageStream`
* continuation. Only the presence of this tail decides WHETHER to attach.
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
* part and replaying over a seeded in-progress row would duplicate its text.
*/
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
const tail = rows[rows.length - 1];
@@ -33,61 +32,15 @@ export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
}
/**
* #491 tail-only anchor: the count of FINISHED steps whose parts are persisted in
* THIS assistant row (`metadata.stepsPersisted`), written atomically with `parts`
* server-side. The resume client reads it as its persisted step frontier N the
* tail-only attach asks the run-stream registry for the frames of step N onward
* (the seed already carries steps 0..N-1). Absent on pre-#491 rows => 0.
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
* without duplicating parts).
*/
export function stepsPersistedOf(
row: IAiChatMessageRow | null | undefined,
): number {
const n = row?.metadata?.stepsPersisted;
return typeof n === "number" && n >= 0 ? Math.floor(n) : 0;
}
/** One page of the messages infinite-query cache (`{ items, meta }`). */
export interface IMessagePage {
items: IAiChatMessageRow[];
meta: unknown;
}
/**
* #491 delta-poll merge: upsert the delta poll's `rows` into the messages
* infinite-query page structure IDEMPOTENTLY by id. The delta endpoint's overlap
* window GUARANTEES occasional REPEATS, so this MUST converge: a row already
* present is REPLACED IN PLACE (per-step growth of an in-progress row), a new row
* is APPENDED to the last page in chronological order (the server returns delta
* rows oldest-first). Applying the same delta twice equals applying it once. Never
* mutates the input pages (returns fresh page objects with cloned item arrays).
*/
export function mergeDeltaRowsIntoPages(
pages: IMessagePage[],
export function seedRows(
rows: IAiChatMessageRow[],
): IMessagePage[] {
if (rows.length === 0) return pages;
const next: IMessagePage[] = pages.map((p) => ({
...p,
items: p.items.slice(),
}));
const locate = (id: string): [number, number] | null => {
for (let pi = 0; pi < next.length; pi++) {
const ii = next[pi].items.findIndex((it) => it.id === id);
if (ii !== -1) return [pi, ii];
}
return null;
};
for (const row of rows) {
const at = locate(row.id);
if (at) {
next[at[0]].items[at[1]] = row; // replace in place — idempotent by id
} else if (next.length > 0) {
next[next.length - 1].items.push(row); // append chronologically
} else {
next.push({ items: [row], meta: undefined });
}
}
return next;
strip: boolean,
): IAiChatMessageRow[] {
return strip ? rows.slice(0, -1) : rows;
}
/**
@@ -1,83 +0,0 @@
import { describe, it, expect } from "vitest";
import { readUIMessageStream, type UIMessage } from "ai";
import pkg from "../../../../package.json";
/**
* PIN-SPEC TRIP-WIRE (#491). The tail-only attach continuation relies on THREE
* behaviors of `ai@6.0.207`, verified line-by-line in the issue. Without this
* test, an `ai` bump could silently break attach (the client would append the
* live tail to the wrong message, or duplicate a step):
*
* 1. `readUIMessageStream({ message })` CONTINUES the passed message it does
* not start a fresh one so the tail streamed after a re-seed is appended to
* the seeded assistant row (the same DB id).
* 2. A `start` frame does NOT reset the existing message's parts (so the seeded
* steps 0..N-1 survive; the synthetic `start` the registry prepends only
* carries the run-fact metadata).
* 3. Text parts do NOT cross a `finish-step` boundary a new `text-start` after
* `finish-step` is a NEW part so the reconstructed steps stay separated and
* the step frontier stays meaningful.
*
* If an `ai` upgrade changes any of these, this test fails LOUD instead of the
* resume path silently corrupting.
*/
describe("ai SDK continuation trip-wire (#491, tail-only attach)", () => {
it("is pinned to the exact ai version the continuation was verified against", () => {
// A caret/range bump is exactly what would silently break attach — require an
// exact pin. Bumping ai MUST re-verify the behavior asserted below, then this.
expect((pkg as { dependencies: Record<string, string> }).dependencies.ai).toBe(
"6.0.207",
);
});
it("continues the seeded message: start does not reset parts, the tail appends as new parts", async () => {
// A seeded assistant row with ONE finished step already reconstructed.
const seeded: UIMessage = {
id: "assistant-1",
role: "assistant",
parts: [
{ type: "step-start" },
{ type: "text", text: "STEP0", state: "done" },
],
} as UIMessage;
// The tail the registry delivers on re-attach: a synthetic start (run-fact),
// then step 1's frames, then finish. As UI-message chunks (what the SSE frames
// decode to).
const chunks = [
{ type: "start", messageMetadata: { runId: "r1", chatId: "c1" } },
{ type: "start-step" },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "STEP1" },
{ type: "text-end", id: "t1" },
{ type: "finish-step" },
{ type: "finish" },
];
const stream = new ReadableStream({
start(c) {
for (const ch of chunks) c.enqueue(ch);
c.close();
},
});
let last: UIMessage | undefined;
for await (const msg of readUIMessageStream({ message: seeded, stream })) {
last = msg;
}
expect(last).toBeDefined();
// Same message id (continuation, not a fresh message).
expect(last!.id).toBe("assistant-1");
// The seeded step-0 parts SURVIVED the `start` frame, and step 1 was appended
// as SEPARATE parts (text did not cross the finish-step boundary).
const shape = last!.parts.map((p) => `${p.type}:${(p as { text?: string }).text ?? ""}`);
expect(shape).toEqual([
"step-start:",
"text:STEP0",
"step-start:",
"text:STEP1",
]);
// The run-fact metadata from the synthetic start frame is applied.
expect(last!.metadata).toMatchObject({ runId: "r1", chatId: "c1" });
});
});
@@ -27,15 +27,11 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({
import {
buildChildrenByParent,
CommentEditorWithActions,
sortResolvedByResolvedAt,
} from "./comment-list-with-tabs";
const c = (id: string, parentCommentId: string | null = null): IComment =>
({ id, parentCommentId }) as IComment;
const resolvedAtComment = (id: string, resolvedAt: unknown): IComment =>
({ id, resolvedAt }) as unknown as IComment;
describe("buildChildrenByParent (childrenByParent grouping)", () => {
it("returns an empty map for undefined or empty input", () => {
expect(buildChildrenByParent(undefined).size).toBe(0);
@@ -75,48 +71,6 @@ describe("buildChildrenByParent (childrenByParent grouping)", () => {
});
});
describe("sortResolvedByResolvedAt (Resolved tab order, #542)", () => {
it("orders by resolvedAt DESC — newest resolve first — with ISO-STRING values", () => {
// At runtime resolvedAt is an ISO string (axios JSON / WS subscription), so
// the sort must coerce with new Date(...) before .getTime().
const older = resolvedAtComment("older", "2026-07-10T10:00:00.000Z");
const newest = resolvedAtComment("newest", "2026-07-12T10:00:00.000Z");
const middle = resolvedAtComment("middle", "2026-07-11T10:00:00.000Z");
const out = sortResolvedByResolvedAt([older, newest, middle]);
expect(out.map((x) => x.id)).toEqual(["newest", "middle", "older"]);
});
it("also handles Date instances (optimistic onMutate window)", () => {
const older = resolvedAtComment("older", new Date("2026-01-01T00:00:00Z"));
const newer = resolvedAtComment("newer", new Date("2026-06-01T00:00:00Z"));
expect(sortResolvedByResolvedAt([older, newer]).map((x) => x.id)).toEqual([
"newer",
"older",
]);
});
it("does not mutate the input array", () => {
const a = resolvedAtComment("a", "2026-01-01T00:00:00.000Z");
const b = resolvedAtComment("b", "2026-02-01T00:00:00.000Z");
const input = [a, b];
sortResolvedByResolvedAt(input);
expect(input.map((x) => x.id)).toEqual(["a", "b"]);
});
it("keeps stable order for equal resolvedAt timestamps", () => {
const ts = "2026-03-03T03:03:03.000Z";
const x = resolvedAtComment("x", ts);
const y = resolvedAtComment("y", ts);
const z = resolvedAtComment("z", ts);
expect(sortResolvedByResolvedAt([x, y, z]).map((c) => c.id)).toEqual([
"x",
"y",
"z",
]);
});
});
function renderReplyEditor() {
return render(
<MantineProvider>
@@ -53,22 +53,6 @@ export function buildChildrenByParent(
return m;
}
// Sort the Resolved tab by resolve time, newest first, on a COPY (never mutate
// the react-query cache array). `resolvedAt` is typed `Date` but at runtime it
// is an ISO STRING (from the axios-JSON onSuccess and the WS subscription) — a
// real Date only during the optimistic onMutate window — so it MUST be coerced
// with `new Date(...)` before `.getTime()`, or a raw `.getTime()` on the string
// throws / yields NaN. ES2019's stable sort preserves order for equal
// timestamps. Callers pass a list already filtered to a truthy `resolvedAt`, so
// the non-null assertion is safe.
// Exported for unit testing.
export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
return [...resolved].sort(
(a, b) =>
new Date(b.resolvedAt!).getTime() - new Date(a.resolvedAt!).getTime(),
);
}
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
@@ -107,10 +91,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
(comment: IComment) => comment.resolvedAt,
);
return {
activeComments: active,
resolvedComments: sortResolvedByResolvedAt(resolved),
};
return { activeComments: active, resolvedComments: resolved };
}, [comments]);
// Index replies by their parent once, instead of an O(n^2) filter per thread.
@@ -1,353 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import {
QueryClient,
QueryClientProvider,
InfiniteData,
} from "@tanstack/react-query";
/**
* Coverage for the resolve/reopen mutation (#542): the Undo-in-toast reopen and
* its double-click guard, the terminal 404 branch (drop from cache + clear the
* inline mark, no rollback), and the directional error copy.
*/
// A fake TipTap editor injected via the mocked pageEditorAtom, so we can assert
// the mutation clears the inline comment mark (unsetComment / setCommentResolved).
const editorMock = vi.hoisted(() => ({
current: {
isDestroyed: false,
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
} as {
isDestroyed: boolean;
commands: {
unsetComment: (id: string) => void;
setCommentResolved: (id: string, v: boolean) => void;
};
} | null,
}));
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn(), hide: vi.fn() },
}));
vi.mock("jotai", () => ({
atom: (v: unknown) => v,
useAtomValue: () => editorMock.current,
}));
vi.mock("@/features/comment/services/comment-service", () => ({
applySuggestion: vi.fn(),
dismissSuggestion: vi.fn(),
createComment: vi.fn(),
updateComment: vi.fn(),
deleteComment: vi.fn(),
resolveComment: vi.fn(),
getPageComments: vi.fn(),
}));
import { notifications } from "@mantine/notifications";
import { resolveComment } from "@/features/comment/services/comment-service";
import {
useResolveCommentMutation,
RESOLVE_UNDO_AUTOCLOSE_MS,
RQ_KEY,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
const PAGE_ID = "page-1";
function seededClient(comment: IComment) {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const seed: InfiniteData<any> = {
pageParams: [undefined],
pages: [
{ items: [comment], meta: { hasNextPage: false, nextCursor: null } },
],
};
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { queryClient, wrapper };
}
function items(queryClient: QueryClient): IComment[] {
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
| InfiniteData<any>
| undefined;
return cache?.pages.flatMap((p) => p.items) ?? [];
}
const comment = (over?: Partial<IComment>): IComment =>
({
id: "c-1",
pageId: PAGE_ID,
content: "{}",
creatorId: "u-1",
workspaceId: "ws-1",
createdAt: new Date(),
resolvedAt: null,
...over,
}) as IComment;
// Pull the inline Undo button's onClick out of the success toast's message tree.
function undoOnClickFromToast(): () => void {
const call = vi
.mocked(notifications.show)
.mock.calls.map((c) => c[0])
.find((arg: any) => arg?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
expect(call).toBeTruthy();
const message: any = (call as any).message;
// message = Group( Text, Button ); grab the Button element's onClick.
const children = message.props.children as any[];
const button = children[1];
return button.props.onClick;
}
describe("useResolveCommentMutation — Undo toast (#542)", () => {
beforeEach(() => {
vi.clearAllMocks();
editorMock.current = {
isDestroyed: false,
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
};
});
it("resolve shows an Undo toast with autoClose=10000ms; reopen shows NO Undo", async () => {
vi.mocked(resolveComment).mockImplementation(async (data) =>
comment({
resolvedAt: data.resolved ? (new Date() as any) : null,
}),
);
const { wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: true,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const resolveToast = vi
.mocked(notifications.show)
.mock.calls.map((c) => c[0])
.find((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
expect(resolveToast).toBeTruthy();
expect((resolveToast as any).id).toBe("resolve-undo-c-1");
expect((resolveToast as any).autoClose).toBe(10000);
// Now a reopen → plain toast, no autoClose/Undo, no id.
vi.clearAllMocks();
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: false,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
expect(
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
).toBe(false);
expect(calls).toContainEqual({ message: "Comment re-opened successfully" });
});
it("double/fast Undo click fires reopen EXACTLY once (guard)", async () => {
vi.mocked(resolveComment).mockImplementation(async (data) =>
comment({ resolvedAt: data.resolved ? (new Date() as any) : null }),
);
const { wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: true,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const onClick = undoOnClickFromToast();
// Fire twice synchronously (notifications.hide is not synchronous).
onClick();
onClick();
await waitFor(() => {
const reopenCalls = vi
.mocked(resolveComment)
.mock.calls.filter(([d]) => d.resolved === false);
expect(reopenCalls).toHaveLength(1);
});
// The mark was cleared once via setCommentResolved(id, false).
expect(editorMock.current!.commands.setCommentResolved).toHaveBeenCalledWith(
"c-1",
false,
);
// The toast was hidden.
expect(notifications.hide).toHaveBeenCalledWith("resolve-undo-c-1");
});
it("404 → drops the comment from cache, clears the inline mark, no rollback, no Undo", async () => {
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
// Removed from cache (NOT rolled back to a phantom).
expect(items(queryClient)).toHaveLength(0);
// Inline mark cleared via unsetComment (mandatory — no panel row left to do it).
expect(editorMock.current!.commands.unsetComment).toHaveBeenCalledWith(
"c-1",
);
// Neutral message, red, and crucially NOT the success copy and NO Undo toast.
expect(notifications.show).toHaveBeenCalledWith({
message: "Comment no longer exists",
color: "red",
});
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
expect(
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
).toBe(false);
});
it("404 does not crash when the editor is gone (read-only / panel closed)", async () => {
editorMock.current = null;
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(items(queryClient)).toHaveLength(0);
expect(notifications.show).toHaveBeenCalledWith({
message: "Comment no longer exists",
color: "red",
});
});
it("non-404 error on REOPEN shows 'Failed to re-open comment' and rolls back", async () => {
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
// Seed a RESOLVED comment (the reopen target).
const resolved = comment({ resolvedAt: new Date() as any });
const { queryClient, wrapper } = seededClient(resolved);
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: false })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(notifications.show).toHaveBeenCalledWith({
message: "Failed to re-open comment",
color: "red",
});
expect(notifications.show).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Failed to resolve comment" }),
);
// Rolled back: the comment is still present and still resolved.
expect(items(queryClient)).toHaveLength(1);
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
});
it("reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)", async () => {
// First resolve succeeds → produces the Undo toast (no mark change on resolve).
vi.mocked(resolveComment).mockResolvedValueOnce(
comment({ resolvedAt: new Date() as any }),
);
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: true,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Now the reopen fired by Undo fails with a 500.
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
const onClick = undoOnClickFromToast();
onClick();
await waitFor(() => expect(result.current.isError).toBe(true));
// Core F1 guarantee: the mark-clear now lives in the reopen onSuccess, so a
// FAILED reopen must never flip the inline mark to unresolved — otherwise the
// doc would show an active highlight the panel still treats as resolved and
// the collab mark would diverge with nothing committed on the server.
expect(
editorMock.current!.commands.setCommentResolved,
).not.toHaveBeenCalledWith("c-1", false);
// Cache rolled back: the comment stays resolved and present.
expect(items(queryClient)).toHaveLength(1);
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
});
it("reopen success with a null editorRef degrades gracefully (no throw, no-op)", async () => {
// Read-only view / panel closed: pageEditorAtom is null on the success path.
editorMock.current = null;
vi.mocked(resolveComment).mockResolvedValue(comment({ resolvedAt: null }));
const resolved = comment({ resolvedAt: new Date() as any });
const { queryClient, wrapper } = seededClient(resolved);
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: false,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// No crash from the reopen mark-clear; the plain reopen toast is still shown.
expect(notifications.show).toHaveBeenCalledWith({
message: "Comment re-opened successfully",
});
// Cache updated to reopened (resolvedAt cleared by the server payload).
expect(items(queryClient)).toHaveLength(1);
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
});
it("non-404 error on RESOLVE shows 'Failed to resolve comment' and rolls back", async () => {
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(notifications.show).toHaveBeenCalledWith({
message: "Failed to resolve comment",
color: "red",
});
// Rolled back to open (previousCache), still present.
expect(items(queryClient)).toHaveLength(1);
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
});
});
@@ -20,19 +20,12 @@ import {
ISuggestionOutcome,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { Button, Group, Text } from "@mantine/core";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import React, { useEffect, useMemo, useRef } from "react";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { useEffect, useMemo } from "react";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
// How long the resolve success toast (with its inline Undo) stays up before it
// auto-closes. Policy constant — no env override.
export const RESOLVE_UNDO_AUTOCLOSE_MS = 10000;
export function useCommentsQuery(params: ICommentParams) {
const query = useInfiniteQuery({
queryKey: RQ_KEY(params.pageId),
@@ -383,25 +376,7 @@ export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
// Keep the live editor in a ref: the toast's Undo (and the 404 branch) must
// clear the inline comment mark AFTER the originating CommentListItem has
// unmounted (resolving pulls the comment out of the Open list, so its item is
// already gone by the time the 10s toast is clicked). In read-only view
// pageEditorAtom is null and the mark converges via the server's
// COMMENT_MARK_UPDATE job instead.
const editor = useAtomValue(pageEditorAtom);
const editorRef = useRef(editor);
editorRef.current = editor;
// Self-reference the mutation so the toast's Undo can re-invoke it (reopen)
// long after the triggering component unmounted. Declared BEFORE useMutation
// and assigned AFTER; the onClick reads mutationRef.current at CALL time, not
// definition time, so there is no initialization cycle.
const mutationRef = useRef<{
mutate: (vars: IResolveComment) => void;
} | null>(null);
const mutation = useMutation({
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
@@ -426,39 +401,7 @@ export function useResolveCommentMutation() {
return { previousCache };
},
onError: (err: any, variables, context) => {
// Terminal 404: the comment was really deleted (missing comment or deleted
// page — access denial is 403, resolve is idempotent so no 400). Do NOT
// roll back (that would resurrect a phantom row in Resolved); instead drop
// it from the cache and clear its now-orphaned inline mark. Mirrors
// handleDeleteComment and the dismiss-mutation 404 branch.
if (err?.response?.status === 404) {
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
| InfiniteData<IPagination<IComment>>
| undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
removeCommentFromCache(cache, variables.commentId),
);
}
const ed = editorRef.current;
if (ed && !ed.isDestroyed) {
try {
ed.commands.unsetComment(variables.commentId);
} catch {
/* editor gone / mark already removed */
}
}
notifications.show({
message: t("Comment no longer exists"),
color: "red",
});
return;
}
// Generic failure: roll back the optimistic update and show a DIRECTIONAL
// error (resolve vs. reopen), not always "resolve".
onError: (_err, variables, context) => {
if (context?.previousCache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
@@ -466,9 +409,7 @@ export function useResolveCommentMutation() {
);
}
notifications.show({
message: variables.resolved
? t("Failed to resolve comment")
: t("Failed to re-open comment"),
message: t("Failed to resolve comment"),
color: "red",
});
},
@@ -489,72 +430,11 @@ export function useResolveCommentMutation() {
);
}
// Reopen keeps the plain toast without an Undo.
if (!variables.resolved) {
// Clear the inline mark ONLY after the server confirms the reopen, so a
// failed reopen never leaves an active highlight the panel still treats
// as resolved. Mirrors the 404 branch's editor-liveness guard/try-catch.
// The button-triggered reopen already set the mark, so this is an
// idempotent no-op there.
const ed = editorRef.current;
if (ed && !ed.isDestroyed) {
try {
ed.commands.setCommentResolved(variables.commentId, false);
} catch {
/* editor gone — server COMMENT_MARK_UPDATE converges it */
}
}
notifications.show({ message: t("Comment re-opened successfully") });
return;
}
// Resolve: attach an inline Undo (reopen) to the success toast. Built with
// React.createElement because this is a .ts module (no JSX).
const { commentId, pageId } = variables;
const notificationId = `resolve-undo-${commentId}`;
// Double-click guard: notifications.hide is NOT synchronous, so the button
// stays clickable for a frame or two — without this a fast double-click
// would fire reopen twice.
let done = false;
notifications.show({
id: notificationId,
autoClose: RESOLVE_UNDO_AUTOCLOSE_MS,
message: React.createElement(
Group,
{ justify: "space-between", wrap: "nowrap", gap: "md" },
React.createElement(
Text,
{ size: "sm" },
t("Comment resolved successfully"),
),
React.createElement(
Button,
{
variant: "subtle",
size: "compact-sm",
onClick: () => {
if (done) return;
done = true;
// Reopen via the SAME mutation (read at click time — the
// originating item is already unmounted).
mutationRef.current?.mutate({
commentId,
pageId,
resolved: false,
});
// The inline mark is cleared in the reopen mutation's onSuccess
// (bound to server confirmation), NOT here — clearing it eagerly
// would desync the doc from the panel if reopen then fails.
notifications.hide(notificationId);
},
},
t("Undo"),
),
),
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully"),
});
},
});
mutationRef.current = mutation;
return mutation;
}
@@ -3,20 +3,11 @@ import { atom } from "jotai";
// import would drag the whole @tiptap/core engine into the eager graph of every
// shell component that reads one of these atoms.
import type { Editor } from "@tiptap/core";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
export const pageEditorAtom = atom<Editor | null>(null);
// #370 — the active page's collab provider, published by the page editor so the
// header menu can emit the "save-version" stateless signal (Cmd+S / button).
// Null when the page is read-only / collab isn't connected. A typed initial
// value (rather than an explicit generic) keeps jotai's overload resolution on
// the writable PrimitiveAtom branch.
const initialCollabProvider: HocuspocusProvider | null = null;
export const collabProviderAtom = atom(initialCollabProvider);
export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null);
@@ -31,18 +31,11 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
collabProviderAtom,
currentPageEditModeAtom,
dictationAvailabilityAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { notifications } from "@mantine/notifications";
import {
VERSION_SAVED_MESSAGE_TYPE,
type VersionSavedMessage,
saveVersionPending,
} from "@/features/page-history/version-messages";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
@@ -131,7 +124,6 @@ export default function PageEditor({
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
const setCollabProvider = useSetAtom(collabProviderAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
@@ -189,24 +181,6 @@ export default function PageEditor({
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
// #370 — a version was saved somewhere; live-refresh the history panel
// on every client. Only the client that pressed Save (tracked by the
// module-level flag) shows the confirmation toast.
if (message?.type === VERSION_SAVED_MESSAGE_TYPE) {
const versionMsg = message as VersionSavedMessage;
queryClient.invalidateQueries({
queryKey: ["page-history-list"],
});
if (saveVersionPending.current) {
saveVersionPending.current = false;
notifications.show({
message: versionMsg.alreadySaved
? t("Already saved as the latest version")
: t("Version saved"),
});
}
return;
}
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
@@ -264,16 +238,12 @@ export default function PageEditor({
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
// #370 — publish the provider so the header menu can emit save-version.
setCollabProvider(remote);
setProvidersReady(true);
} else {
setCollabProvider(providersRef.current.remote);
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
setCollabProvider(null);
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
@@ -1,11 +1,4 @@
import {
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
Badge,
} from "@mantine/core";
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { formattedDate } from "@/lib/time";
@@ -14,59 +7,36 @@ import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
const MAX_VISIBLE_AVATARS = 5;
/**
* #370 map a snapshot's intentionality tier to its badge. `version: true`
* marks the intentional points (manual / agent); autosaves (boundary / idle /
* legacy null) are non-versions and get dimmed in the list.
*/
type HistoryKindMeta = { labelKey: string; color: string; version: boolean };
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
switch (kind) {
case "manual":
return { labelKey: "Saved", color: "blue", version: true };
case "agent":
return { labelKey: "Agent version", color: "violet", version: true };
case "boundary":
return { labelKey: "Boundary", color: "gray", version: false };
default: // "idle" | null | undefined (legacy autosave)
return { labelKey: "Autosave", color: "gray", version: false };
}
}
interface HistoryItemProps {
historyItem: IPageHistory;
// The previous snapshot for diff/restore is resolved by id from the FULL list
// in the parent (resolvePrevSnapshotId), so the item only needs to report its
// own id — never a list index (which would be the filtered-view index).
onSelect: (id: string) => void;
onHover?: (id: string) => void;
index: number;
onSelect: (id: string, index: number) => void;
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean;
}
const HistoryItem = memo(function HistoryItem({
historyItem,
index,
onSelect,
onHover,
onHoverEnd,
isActive,
}: HistoryItemProps) {
const setHistoryModalOpen = useSetAtom(historyAtoms);
const { t } = useTranslation();
const kindMeta = historyKindMeta(historyItem.kind);
const handleClick = useCallback(() => {
onSelect(historyItem.id);
}, [onSelect, historyItem.id]);
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id);
}, [onHover, historyItem.id]);
onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]);
const contributors = historyItem.contributors;
const hasContributors = contributors && contributors.length > 0;
@@ -79,20 +49,8 @@ const HistoryItem = memo(function HistoryItem({
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
// #370 — dim autosnapshots so intentional versions stand out.
style={{ opacity: kindMeta.version ? 1 : 0.55 }}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<Badge
size="xs"
radius="sm"
variant={kindMeta.version ? "filled" : "light"}
color={kindMeta.color}
>
{t(kindMeta.labelKey)}
</Badge>
</Group>
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
@@ -2,16 +2,14 @@ import {
usePageHistoryListQuery,
prefetchPageHistory,
} from "@/features/page-history/queries/page-history-query";
import HistoryItem, {
historyKindMeta,
} from "@/features/page-history/components/history-item";
import HistoryItem from "@/features/page-history/components/history-item";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
Button,
ScrollArea,
@@ -19,12 +17,9 @@ import {
Divider,
Loader,
Center,
Switch,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useHistoryRestore } from "@/features/page-history/hooks";
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
const PREFETCH_DELAY_MS = 150;
@@ -52,22 +47,6 @@ function HistoryList({ pageId }: Props) {
[pageHistoryData],
);
// #370 — "only versions" filter: hide autosnapshots (idle/boundary/legacy
// null), keep only intentional points (manual/agent). Filtering is over the
// already-loaded pages; the diff/restore still targets the true previous
// snapshot, so items carry their index within the FULL list.
const [onlyVersions, setOnlyVersions] = useState(false);
// Reuse historyKindMeta().version — the SAME predicate the badge (HistoryItem)
// uses to mark intentional points — so the "Only versions" filter and the badge
// can never drift apart when a future intentional kind is added.
const visibleItems = useMemo(
() =>
onlyVersions
? historyItems.filter((item) => historyKindMeta(item.kind).version)
: historyItems,
[historyItems, onlyVersions],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -81,13 +60,11 @@ function HistoryList({ pageId }: Props) {
}, []);
const handleHover = useCallback(
(historyId: string) => {
(historyId: string, index: number) => {
clearPrefetchTimeout();
prefetchTimeoutRef.current = setTimeout(() => {
prefetchPageHistory(historyId);
// The true previous snapshot in the FULL list (not the previous visible
// one under the "only versions" filter).
const prevId = resolvePrevSnapshotId(historyItems, historyId);
const prevId = historyItems[index + 1]?.id;
if (prevId) {
prefetchPageHistory(prevId);
}
@@ -101,11 +78,9 @@ function HistoryList({ pageId }: Props) {
}, [clearPrefetchTimeout]);
const handleSelect = useCallback(
(id: string) => {
(id: string, index: number) => {
setActiveHistoryId(id);
// Baseline = true previous snapshot in the FULL list, so the "only
// versions" filter never diffs/restores against the wrong item.
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
@@ -153,27 +128,12 @@ function HistoryList({ pageId }: Props) {
return (
<div>
<Group px="xs" py={6} justify="flex-end">
<Switch
size="xs"
checked={onlyVersions}
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
label={t("Only versions")}
/>
</Group>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{onlyVersions && visibleItems.length === 0 && (
<Center py="md">
<Text size="sm" c="dimmed">
{t("No saved versions yet.")}
</Text>
</Center>
)}
{visibleItems.map((historyItem) => (
{historyItems.map((historyItem, index) => (
<HistoryItem
key={historyItem.id}
historyItem={historyItem}
index={index}
onSelect={handleSelect}
onHover={handleHover}
onHoverEnd={clearPrefetchTimeout}
@@ -24,10 +24,6 @@ export interface IPageHistory {
updatedAt: string;
lastUpdatedBy: IPageHistoryUser;
contributors?: IPageHistoryUser[];
// #370 — intentionality tier: 'manual'/'agent' are versions (intentional
// points), 'idle'/'boundary' are autosnapshots; null/undefined = legacy
// autosave. Derived server-side, drives the history badge + "versions" filter.
kind?: "manual" | "agent" | "idle" | "boundary" | null;
// Provenance markers copied off the page row when the snapshot was saved.
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
// (when present) deep-links to the chat that produced the edit.
@@ -1,42 +0,0 @@
import { describe, it, expect } from "vitest";
import { resolvePrevSnapshotId } from "./resolve-prev-snapshot";
// #370 F4 — the risky client path: with the "only versions" filter active, diff
// and restore must still baseline against the TRUE previous snapshot in the FULL
// list, never the previous VISIBLE version (which would skip the autosnapshots
// between two versions). These pin that the resolution is by FULL-list order.
describe("resolvePrevSnapshotId", () => {
// Newest-first, as the history list stores it: a version, then two autosaves,
// then an older version.
const full = [
{ id: "v2", kind: "manual" },
{ id: "a2", kind: "idle" },
{ id: "a1", kind: "boundary" },
{ id: "v1", kind: "manual" },
{ id: "a0", kind: null },
];
it("returns the immediate FULL-list successor, not the previous visible version", () => {
// Selecting v2 while filtered to versions-only must baseline against a2 (the
// real chronological predecessor), NOT v1 (the previous visible version).
expect(resolvePrevSnapshotId(full, "v2")).toBe("a2");
});
it("resolves an autosnapshot's predecessor by full-list order", () => {
expect(resolvePrevSnapshotId(full, "a1")).toBe("v1");
});
it("returns '' for the oldest item (no predecessor)", () => {
expect(resolvePrevSnapshotId(full, "a0")).toBe("");
});
it("returns '' for an id not in the list", () => {
expect(resolvePrevSnapshotId(full, "missing")).toBe("");
});
it("does not depend on a filtered subset — same result whatever is visible", () => {
// The helper only ever sees the full list; a filtered view cannot change the
// baseline it computes.
expect(resolvePrevSnapshotId(full, "v1")).toBe("a0");
});
});
@@ -1,22 +0,0 @@
/**
* #370 resolve the TRUE previous snapshot for a history item.
*
* The history panel can be filtered to "only versions" (manual/agent), but diff
* and restore must always compare against the immediately-preceding snapshot in
* the FULL, unfiltered list NOT the previous VISIBLE item. Comparing against
* the previous visible version would silently skip the autosnapshots between two
* versions and diff/restore the wrong baseline.
*
* Given the full (newest-first) list and an item id, this returns the id of the
* item right after it in the full list (its chronological predecessor), or "" if
* it is the oldest / not found. Pure and list-order-preserving so it can be unit
* tested without mounting the component.
*/
export function resolvePrevSnapshotId(
fullItems: ReadonlyArray<{ id: string }>,
id: string,
): string {
const index = fullItems.findIndex((item) => item.id === id);
if (index === -1) return "";
return fullItems[index + 1]?.id ?? "";
}
@@ -1,28 +0,0 @@
/**
* #370 page-version stateless wire formats. Kept in one place so the client
* emitter (Save hotkey / button) and the client listener (page-editor) agree
* with the server (PersistenceExtension) on the message shapes.
*/
/** Client server: "save a version now". The server derives the tier
* (manual/agent) from the signed connection actor, never from this payload. */
export const SAVE_VERSION_MESSAGE_TYPE = "save-version";
/** Server → all clients: a version was saved (or promoted / already existed). */
export const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
export interface VersionSavedMessage {
type: typeof VERSION_SAVED_MESSAGE_TYPE;
historyId: string;
kind: "manual" | "agent";
/** True when the latest snapshot was already a manual version (a no-op save). */
alreadySaved: boolean;
}
/**
* Cross-component coordination flag so only the client that pressed Save shows
* the confirmation toast, while every other client silently refreshes its
* history panel on the broadcast. A module-level ref avoids stale-closure
* pitfalls in the editor's long-lived stateless handler.
*/
export const saveVersionPending = { current: false };
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import {
formatHeadline,
formatDayTotal,
formatGapMinutes,
} from "./format-work-time";
const MIN = 60 * 1000;
// Fake translator: renders the key with {{tokens}} substituted, so the tests
// assert the rounding + branch selection without depending on the i18n catalogue.
const t = (key: string, opts?: Record<string, unknown>) =>
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
describe("formatHeadline", () => {
it("prefixes ≈ and rounds to a 5-minute step", () => {
expect(formatHeadline(4 * 60 * MIN + 27 * MIN, t)).toBe("≈ 4h 25m");
expect(formatHeadline(90 * MIN, t)).toBe("≈ 1h 30m");
});
it("shows hours only / minutes only cleanly", () => {
expect(formatHeadline(120 * MIN, t)).toBe("≈ 2h");
expect(formatHeadline(35 * MIN, t)).toBe("≈ 35m");
});
it("floors a tiny non-zero estimate to 5m, never 0", () => {
expect(formatHeadline(2 * MIN, t)).toBe("≈ 5m");
});
it("empty string for zero (widget hidden)", () => {
expect(formatHeadline(0, t)).toBe("");
});
});
describe("formatDayTotal", () => {
it('renders "h m" and shows — for empty days', () => {
expect(formatDayTotal(3 * 60 * MIN + 17 * MIN, t)).toBe("3h 17m");
expect(formatDayTotal(0, t)).toBe("—");
});
});
describe("formatGapMinutes", () => {
it("converts the tGap ms threshold to whole minutes", () => {
expect(formatGapMinutes(15 * MIN)).toBe(15);
});
});
@@ -1,45 +0,0 @@
// #395 — display formatting for the work-time estimate. Pure functions that take
// a translator so ru-RU / en-US wording lives in the i18n catalogue and the
// rounding logic stays unit-testable.
type Translate = (key: string, opts?: Record<string, unknown>) => string;
const MIN = 60 * 1000;
function hm(totalMinutes: number): { hours: number; minutes: number } {
return {
hours: Math.floor(totalMinutes / 60),
minutes: totalMinutes % 60,
};
}
/**
* Headline number (§6.1): an ESTIMATE, so rounded to a coarse 5-minute step and
* prefixed with "≈". A non-zero-but-tiny estimate floors to 5m rather than
* rounding down to "0" (which would read as "no work"). Zero empty string
* (the caller hides the widget).
*/
export function formatHeadline(workMs: number, t: Translate): string {
if (workMs <= 0) return "";
let minutes = Math.round(workMs / MIN / 5) * 5;
if (minutes === 0) minutes = 5;
const { hours, minutes: m } = hm(minutes);
if (hours > 0 && m > 0) return t("≈ {{hours}}h {{minutes}}m", { hours, minutes: m });
if (hours > 0) return t("≈ {{hours}}h", { hours });
return t("≈ {{minutes}}m", { minutes: m });
}
/** Per-day sum (§6.2), rounded to the minute. Zero → "—". */
export function formatDayTotal(activeMs: number, t: Translate): string {
if (activeMs <= 0) return "—";
const minutes = Math.max(1, Math.round(activeMs / MIN));
const { hours, minutes: m } = hm(minutes);
if (hours > 0 && m > 0) return t("{{hours}}h {{minutes}}m", { hours, minutes: m });
if (hours > 0) return t("{{hours}}h", { hours });
return t("{{minutes}}m", { minutes: m });
}
/** The inactivity threshold, for the "estimate · gap = N min" caption. */
export function formatGapMinutes(tGapMs: number): number {
return Math.round(tGapMs / MIN);
}
@@ -1,25 +0,0 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { IPageWorkTime } from "./work-time.types";
import { getPageWorkTime, viewerTimezone } from "./work-time-service";
const WORK_TIME_STALE_TIME = 5 * 60 * 1000;
/**
* #395 the "time worked on this article" estimate + per-day punch-card
* buckets. The buckets are computed server-side in the viewer's timezone (so a
* midnight-crossing session lands on the right calendar day for the reader).
* `enabled` is opt-in so the (cheap but non-trivial) projection query only fires
* when the number is actually shown.
*/
export function usePageWorkTime(
pageId: string,
enabled = true,
): UseQueryResult<IPageWorkTime, Error> {
const tz = viewerTimezone();
return useQuery({
queryKey: ["page-work-time", pageId, tz],
queryFn: () => getPageWorkTime(pageId, tz),
enabled: enabled && !!pageId,
staleTime: WORK_TIME_STALE_TIME,
});
}
@@ -1,171 +0,0 @@
import { Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
import {
formatDayTotal,
formatGapMinutes,
formatHeadline,
} from "./format-work-time";
import classes from "./work-time.module.css";
const DAY_MS = 24 * 60 * 60 * 1000;
// Collapse a run of this many (or more) consecutive edit-free days into a single
// "× N days" separator (§6.2 long-range) — the row is still always one day.
const EMPTY_RUN_COLLAPSE = 8;
type Row =
| { type: "day"; day: IPerDay }
| { type: "gap"; count: number };
function collapseEmptyRuns(perDay: IPerDay[]): Row[] {
const rows: Row[] = [];
let emptyRun: IPerDay[] = [];
const flush = () => {
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
rows.push({ type: "gap", count: emptyRun.length });
} else {
for (const d of emptyRun) rows.push({ type: "day", day: d });
}
emptyRun = [];
};
for (const d of perDay) {
if (d.activeMs === 0 && d.agentMs === 0) {
emptyRun.push(d);
} else {
flush();
rows.push({ type: "day", day: d });
}
}
flush();
return rows;
}
function dayHeading(day: number): string {
return new Date(day).toLocaleDateString(undefined, {
weekday: "short",
day: "numeric",
month: "short",
});
}
function DayTrack({
day,
pSingle,
}: {
day: IPerDay;
pSingle: number;
}) {
const { t } = useTranslation();
const ticks = [6, 12, 18];
return (
<div className={classes.row}>
<span className={classes.dayLabel}>{dayHeading(day.day)}</span>
<div className={classes.track}>
{ticks.map((h) => (
<div
key={h}
className={classes.hourTick}
style={{ left: `${(h / 24) * 100}%` }}
/>
))}
{day.windows.map((w: IDayWindow, i) => {
const leftPct = ((w.start - day.day) / DAY_MS) * 100;
const widthPct = ((w.end - w.start) / DAY_MS) * 100;
const isSingle = w.end - w.start <= pSingle;
const cls = [
classes.window,
w.class === "work" ? classes.windowWork : classes.windowAgent,
isSingle ? classes.windowSingle : "",
].join(" ");
return (
<div
key={i}
className={cls}
style={{
left: `${Math.max(0, Math.min(100, leftPct))}%`,
width: `${Math.max(0, Math.min(100, widthPct))}%`,
}}
/>
);
})}
</div>
<span className={classes.daySum}>
{formatDayTotal(day.activeMs, t)}
</span>
</div>
);
}
interface Props {
data: IPageWorkTime;
}
export default function WorkTimePunchCard({ data }: Props) {
const { t } = useTranslation();
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
const gapMin = formatGapMinutes(data.config.tGap);
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
return (
<Text size="sm" c="dimmed" py="md">
{t("No editing activity recorded yet.")}
</Text>
);
}
return (
<Stack gap="xs">
<Group gap="lg">
<Text size="sm" fw={500}>
{formatHeadline(data.workMs, t)}
</Text>
{data.agentOnlyMs > 0 && (
<Text size="xs" c="dimmed">
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
</Text>
)}
</Group>
<Group gap="md">
<Text size="xs" c="dimmed">
<span
className={`${classes.legendSwatch} ${classes.windowWork}`}
style={{ marginRight: 4 }}
/>
{t("Work")}
</Text>
<Text size="xs" c="dimmed">
<span
className={`${classes.legendSwatch} ${classes.windowAgent}`}
style={{ marginRight: 4 }}
/>
{t("Agent")}
</Text>
</Group>
<div>
{rows.map((row, i) =>
row.type === "day" ? (
<DayTrack
key={row.day.dayISO}
day={row.day}
pSingle={data.config.pSingle}
/>
) : (
<div key={`gap-${i}`} className={classes.gapRow}>
{t("× {{count}} days without edits", { count: row.count })}
</div>
),
)}
</div>
<Text size="xs" c="dimmed" mt="xs">
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
tz: data.tz,
gap: gapMin,
})}
</Text>
</Stack>
);
}
@@ -1,23 +0,0 @@
import api from "@/lib/api-client";
import { IPageWorkTime } from "./work-time.types";
/** The viewer's IANA timezone (browser locale) the punch-card lays days out
* in "my evenings", per §6.3/§10. Falls back to UTC if the runtime hides it. */
export function viewerTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
}
export async function getPageWorkTime(
pageId: string,
tz: string,
): Promise<IPageWorkTime> {
const req = await api.post<IPageWorkTime>("/pages/history/time", {
pageId,
tz,
});
return req.data;
}
@@ -1,69 +0,0 @@
import { Modal, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconClockHour4 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { usePageWorkTime } from "./use-page-work-time";
import { formatGapMinutes, formatHeadline } from "./format-work-time";
import WorkTimePunchCard from "./work-time-punch-card";
interface Props {
pageId: string;
}
/**
* #395 the clickable "time worked on this article" headline (§6.1). Renders
* the `work` estimate with a "≈" sign and the inactivity threshold in a tooltip
* (it is an estimate, not a stopwatch). Clicking opens the daily punch-card
* (§6.2). Renders nothing until there is a non-zero human OR agent estimate, so a
* brand-new / never-edited page shows no widget. For an agent-only-edited page
* (workMs===0, agentOnlyMs>0) the headline shows the agent estimate (labelled
* `agent:`, matching the punch-card) so the punch-card stays reachable (#395:
* "how much a HUMAN and separately the AGENT").
*/
export default function WorkTimeStat({ pageId }: Props) {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const { data } = usePageWorkTime(pageId);
if (!data || (data.workMs <= 0 && data.agentOnlyMs <= 0)) return null;
const agentOnly = data.workMs <= 0;
const label = agentOnly
? t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })
: formatHeadline(data.workMs, t);
const gapMin = formatGapMinutes(data.config.tGap);
return (
<>
<Tooltip
label={t("Estimated time worked (inactivity gap {{gap}} min)", {
gap: gapMin,
})}
position="bottom"
>
<UnstyledButton
onClick={open}
aria-label={t("Show time worked on this page")}
>
<Text
size="xs"
c="dimmed"
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
<IconClockHour4 size={14} />
{label}
</Text>
</UnstyledButton>
</Tooltip>
<Modal
opened={opened}
onClose={close}
title={t("Time worked on this article")}
size="lg"
>
<WorkTimePunchCard data={data} />
</Modal>
</>
);
}
@@ -1,82 +0,0 @@
/* #395 24h × days punch-card. Custom CSS segments on a fixed 24-hour track
(position = offset-in-day / 24h, width = duration / 24h), no chart library. */
.row {
display: grid;
grid-template-columns: 96px 1fr 64px;
align-items: center;
gap: 12px;
padding: 3px 0;
}
.dayLabel {
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed);
white-space: nowrap;
}
.track {
position: relative;
height: 16px;
border-radius: 4px;
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
overflow: hidden;
}
/* Faint hour grid so the eye can read "morning vs evening". */
.hourTick {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
}
.window {
position: absolute;
top: 2px;
bottom: 2px;
border-radius: 3px;
min-width: 3px;
}
.windowWork {
background-color: var(--mantine-color-blue-5);
}
.windowAgent {
background-color: var(--mantine-color-grape-5);
}
/* A lone single-sample (P_single) window: minimal + dimmed, so it neither
vanishes nor fakes dense work (§6.2). */
.windowSingle {
opacity: 0.5;
}
.daySum {
font-size: var(--mantine-font-size-xs);
text-align: right;
white-space: nowrap;
}
.gapRow {
padding: 6px 0 6px 108px;
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed);
font-style: italic;
}
.legendSwatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
vertical-align: middle;
}
@@ -1,37 +0,0 @@
// #395 — client-side mirror of the server work-time payload
// (apps/server/src/core/page/work-time). Shapes returned by POST /pages/history/time.
export type WorkSessionClass = "work" | "agent_only";
export interface IDayWindow {
start: number;
end: number;
class: WorkSessionClass;
}
export interface IPerDay {
day: number;
dayISO: string;
activeMs: number;
agentMs: number;
windows: IDayWindow[];
}
export interface IWorkTimeConfig {
tGap: number;
agentTGap: number;
pIn: number;
pOut: number;
pSingle: number;
excludeGit: boolean;
burstCapMs?: number;
dedupRoundMs: number;
}
export interface IPageWorkTime {
workMs: number;
agentOnlyMs: number;
perDay: IPerDay[];
config: IWorkTimeConfig;
tz: string;
}
@@ -3,7 +3,6 @@ import {
IconArrowRight,
IconArrowsHorizontal,
IconClockHour4,
IconDeviceFloppy,
IconDots,
IconEye,
IconEyeOff,
@@ -18,7 +17,7 @@ import {
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
@@ -40,18 +39,12 @@ import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
import {
collabProviderAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import {
SAVE_VERSION_MESSAGE_TYPE,
saveVersionPending,
} from "@/features/page-history/version-messages.ts";
import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import WorkTimeStat from "@/features/page-history/work-time/work-time-stat.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import {
useFavoriteIds,
@@ -79,34 +72,9 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
});
const isDeleted = !!page?.deletedAt;
const [workspace] = useAtom(workspaceAtom);
const collabProvider = useAtomValue(collabProviderAtom);
// Community public-sharing entry point (replaces the removed EE PageShareModal)
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
// #370 — explicit "save a version" (Cmd+S / Save button). One path for the
// human; the server derives the tier from the signed actor. Readers can't save
// (the button is hidden and the collab connection is read-only server-side).
const handleSaveVersion = useCallback(() => {
if (readOnly || !collabProvider) return;
// Flag this client as the initiator so only it shows the confirmation toast;
// a safety timeout clears it if no broadcast comes back (e.g. offline).
saveVersionPending.current = true;
window.setTimeout(() => {
saveVersionPending.current = false;
}, 5000);
collabProvider.sendStateless(
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
);
}, [readOnly, collabProvider]);
// mod+S must also block the browser's "Save page" dialog. `triggerOnContent-
// Editable` + empty ignore-list so it fires while typing in the editor/title.
useHotkeys(
[["mod+S", handleSaveVersion, { preventDefault: true }]],
[],
true,
);
useHotkeys(
[
[
@@ -165,16 +133,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</ActionIcon>
</Tooltip>
<PageActionMenu readOnly={readOnly} onSaveVersion={handleSaveVersion} />
<PageActionMenu readOnly={readOnly} />
</>
);
}
interface PageActionMenuProps {
readOnly?: boolean;
onSaveVersion?: () => void;
}
function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { t } = useTranslation();
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const clipboard = useClipboard({ timeout: 500 });
@@ -266,8 +233,6 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
return (
<>
{page?.id && <WorkTimeStat pageId={page.id} />}
<Menu
shadow="xl"
position="bottom-end"
@@ -338,20 +303,6 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
</Group>
</Menu.Item>
{!readOnly && (
<Menu.Item
leftSection={<IconDeviceFloppy size={16} />}
onClick={onSaveVersion}
rightSection={
<Text size="xs" c="dimmed">
{t("Ctrl+S")}
</Text>
}
>
{t("Save version")}
</Menu.Item>
)}
<Menu.Item
leftSection={<IconHistory size={16} />}
onClick={openHistoryModal}
@@ -665,6 +665,13 @@ export function updateCacheOnMovePage(
pageData: Partial<IPage>,
) {
invalidatePageTree();
// Invalidate the moved page's breadcrumbs (#523). The tree-side child-loss
// guard removes the moved node from the local tree when its new parent is an
// unloaded branch, so `findBreadcrumbPath` misses it and the breadcrumb bar
// falls back to the server `["breadcrumbs", pageId]` query — which this move
// must invalidate, otherwise the crumbs keep showing the OLD parent until a
// refocus/navigation.
queryClient.invalidateQueries({ queryKey: ["breadcrumbs", pageId] });
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IPage } from "@/features/page/types/page.types";
// A fresh QueryClient stands in for the app singleton (importing the real
// @/main.tsx would run ReactDOM.createRoot, which has no DOM root in jsdom).
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
import { queryClient } from "@/main.tsx";
import { updateCacheOnMovePage } from "./page-query";
// #523: the tree-side child-loss guard removes the moved node from the local
// tree when its new parent is an unloaded branch, so `findBreadcrumbPath` misses
// it and the breadcrumb bar falls back to the server `["breadcrumbs", pageId]`
// query. That query MUST be invalidated by a move, or the crumbs keep showing
// the OLD parent until a refocus/navigation.
describe("updateCacheOnMovePage — breadcrumbs invalidation (#523)", () => {
beforeEach(() => {
queryClient.clear();
vi.restoreAllMocks();
});
it("invalidates the moved page's ['breadcrumbs', pageId] query", () => {
const spy = vi.spyOn(queryClient, "invalidateQueries");
updateCacheOnMovePage("s1", "moved-page", "old-parent", "new-parent", {
id: "moved-page",
} as Partial<IPage>);
const invalidatedBreadcrumbs = spy.mock.calls.some(
([arg]) =>
Array.isArray((arg as { queryKey?: unknown[] })?.queryKey) &&
(arg as { queryKey: unknown[] }).queryKey[0] === "breadcrumbs" &&
(arg as { queryKey: unknown[] }).queryKey[1] === "moved-page",
);
expect(invalidatedBreadcrumbs).toBe(true);
});
});
@@ -55,7 +55,14 @@ type Props<T extends object> = {
};
const DRAG_TYPE = 'doc-tree-item';
const AUTO_EXPAND_MS = 500;
// Hover-hold before a collapsed row auto-expands during a drag. 2s (not ~0.5s)
// so merely dragging the cursor THROUGH the tree never expands rows — only a
// deliberate hold does (#523).
const AUTO_EXPAND_MS = 2000;
// How long the "a page just moved in here" cue stays on a collapsed target after
// a make-child drop. Long enough to notice at a glance during frequent
// collapsed-drops, short enough not to linger. Code-only UX constant (#523).
const DROP_LANDED_HIGHLIGHT_MS = 1800;
function DocTreeRowInner<T extends object>(props: Props<T>) {
const {
@@ -93,7 +100,11 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
const rowRef = useRef<HTMLElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<Instruction | null>(null);
// Transient "just received a child" cue: a make-child drop no longer expands
// the (collapsed) target, so flash the row instead so the move isn't invisible.
const [landedChild, setLandedChild] = useState(false);
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const landedChildTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelAutoExpand = useCallback(() => {
if (autoExpandTimerRef.current) {
@@ -249,11 +260,24 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
? getDragLabel(sourceNode)
: 'item';
liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`);
// After a make-child drop, expand this row so the user sees the
// just-dropped child — especially important when the row had no
// children before (chevron just appeared) so the drop would
// otherwise be invisible.
if (op.kind === 'make-child') onToggle(node.id, true);
// Do NOT auto-expand the target on drop: a drop must leave the node
// collapsed. Intentional expansion is handled solely by the
// hover-hold timer (AUTO_EXPAND_MS). Feedback that the drop landed is
// given by the post-move flash + landed-cue highlight + live-region
// announce above. When the make-child target is collapsed, flash a
// distinct "child moved in here" cue on the row (it stays collapsed).
if (op.kind === 'make-child' && !isOpen) {
if (landedChildTimerRef.current) {
clearTimeout(landedChildTimerRef.current);
}
setLandedChild(true);
landedChildTimerRef.current = setTimeout(() => {
setLandedChild(false);
landedChildTimerRef.current = null;
}, DROP_LANDED_HIGHLIGHT_MS);
}
// Restore the openness of the MOVED page itself (source) — untouched
// by the above; the target is never expanded here.
if (source.data.isOpenOnDragStart) onToggle(sourceId, true);
},
}),
@@ -281,6 +305,17 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]);
// Clear the landed-child cue timer on unmount (mirrors autoExpandTimerRef).
useEffect(
() => () => {
if (landedChildTimerRef.current) {
clearTimeout(landedChildTimerRef.current);
landedChildTimerRef.current = null;
}
},
[],
);
const effectiveInst =
instruction?.type === 'instruction-blocked'
? instruction.desired
@@ -317,6 +352,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
className={styles.node}
data-dragging={isDragging || undefined}
data-selected={isSelected || undefined}
data-landed-child={landedChild || undefined}
data-receiving-drop={
receivingDrop === 'make-child'
? blocked
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import type { ReactNode } from "react";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import type { SpaceTreeNode } from "@/features/page/tree/types";
// --- Boundary mocks: only the network/query + router/i18n surfaces the hook
// touches. The tree math (treeModel, dropOpToMovePayload) runs for real so the
// child-loss guard is exercised end-to-end.
const moveMutate = vi.fn().mockResolvedValue({});
const updateCacheOnMovePageMock = vi.fn();
vi.mock("@/features/page/queries/page-query.ts", () => ({
useCreatePageMutation: () => ({ mutateAsync: vi.fn() }),
useUpdatePageMutation: () => ({ mutateAsync: vi.fn() }),
useRemovePageMutation: () => ({ mutateAsync: vi.fn() }),
useMovePageMutation: () => ({ mutateAsync: moveMutate }),
updateCacheOnMovePage: (...args: unknown[]) =>
updateCacheOnMovePageMock(...args),
}));
vi.mock("react-router-dom", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ spaceSlug: "space", pageSlug: undefined }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (s: string) => s }),
}));
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
// Import AFTER mocks so the hook binds to them.
import { useTreeMutation } from "./use-tree-mutation";
function node(
id: string,
over: Partial<SpaceTreeNode> = {},
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: false,
children: [],
...over,
};
}
function setup(before: SpaceTreeNode[]) {
const store = createStore();
store.set(treeDataAtom, before);
const wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
const { result } = renderHook(() => useTreeMutation("space-1"), { wrapper });
return { store, result };
}
describe("useTreeMutation.handleMove — child-loss guard (#523)", () => {
beforeEach(() => {
vi.clearAllMocks();
moveMutate.mockResolvedValue({});
});
it("make-child into an UNLOADED folder does NOT materialize [source] — keeps it lazy-loadable", async () => {
// F has children on the server but none are loaded here (canonical unloaded
// form: hasChildren + children:[]). X sits at root.
const before = [
node("F", { position: "a0", hasChildren: true, children: [] }),
node("X", { position: "a5" }),
];
const { store, result } = setup(before);
await act(async () => {
await result.current.handleMove("X", {
kind: "make-child",
targetId: "F",
});
});
const tree = store.get(treeDataAtom);
const f = treeModel.find(tree, "F");
// The guard leaves F unloaded (children stay []), so a later expand fetches
// the FULL server set (incl. X) instead of showing a misleading partial [X].
// MUTATION: dropping the guard (using the `move` result) would put children
// === [X] here and redden this.
expect(f?.children).toEqual([]);
expect(f?.hasChildren).toBe(true);
// X is removed from its old (root) slot; it reappears on expand/load of F.
expect(treeModel.find(tree, "X")).toBeNull();
// The server move is still persisted.
expect(moveMutate).toHaveBeenCalledTimes(1);
});
it("make-child into a LOADED folder appends source and KEEPS the existing children", async () => {
// F is loaded with one child c1; moving X in must preserve c1 (no loss) and
// append X — this path does NOT hit the guard.
const before = [
node("F", {
position: "a0",
hasChildren: true,
children: [node("c1", { position: "a1", parentPageId: "F" })],
}),
node("X", { position: "a5" }),
];
const { store, result } = setup(before);
await act(async () => {
await result.current.handleMove("X", {
kind: "make-child",
targetId: "F",
});
});
const tree = store.get(treeDataAtom);
const f = treeModel.find(tree, "F");
expect(f?.children?.map((n) => n.id)).toEqual(["c1", "X"]);
expect(f?.hasChildren).toBe(true);
// X now lives under F.
expect(treeModel.find(tree, "X")?.parentPageId).toBe("F");
});
it("make-child into a genuinely-empty leaf materializes the child (nothing to lose)", async () => {
// Leaf L has no server children (hasChildren:false). Dropping X in should
// show X immediately — the guard must NOT fire here.
const before = [
node("L", { position: "a0", hasChildren: false, children: [] }),
node("X", { position: "a5" }),
];
const { store, result } = setup(before);
await act(async () => {
await result.current.handleMove("X", {
kind: "make-child",
targetId: "L",
});
});
const tree = store.get(treeDataAtom);
const l = treeModel.find(tree, "L");
expect(l?.children?.map((n) => n.id)).toEqual(["X"]);
expect(l?.hasChildren).toBe(true);
});
});
@@ -61,11 +61,48 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
if (!source) return;
const oldParentId = source.parentPageId ?? null;
// optimistic apply with the new position from the payload
let optimistic = treeModel.update(after, sourceId, {
position: payload.position,
parentPageId: payload.parentPageId,
} as Partial<SpaceTreeNode>);
// Child-loss guard (#523, twin of #525's realtime `insertByPosition` fix).
// We no longer auto-expand the make-child target on drop, so the old
// `onToggle(target, true)` — which was ALSO the only trigger of the
// corrective lazy-load — is gone. `treeModel.move` materialized
// `target.children = [source]` (only the moved node); if the target is an
// UNLOADED branch (server has children but none are loaded here), keeping
// that partial `[source]` list would defeat the lazy-load gate and hide the
// target's OTHER server children (the #159 #1 data-loss class). So for an
// unloaded make-child target, build the optimistic tree WITHOUT
// materializing source under it: just remove source from its old parent and
// flag the target `hasChildren`. The gate stays armed and a later manual
// expand fetches the FULL set (incl. the moved page, which the awaited
// server move persists). Predicate is the gate's (`isUnloadedBranch`), NOT
// `insertByPosition`'s old `=== undefined` (canonical unloaded is `[]`).
const target =
op.kind === "make-child"
? (treeModel.find(before, op.targetId) as SpaceTreeNode | null)
: null;
const unloadedMakeChild =
op.kind === "make-child" && treeModel.isUnloadedBranch(target);
let optimistic: SpaceTreeNode[];
if (unloadedMakeChild) {
// Do NOT materialize [source] into the unloaded target.
optimistic = treeModel.remove(before, sourceId);
optimistic = treeModel.update(optimistic, op.targetId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
} else {
// optimistic apply with the new position from the payload
optimistic = treeModel.update(after, sourceId, {
position: payload.position,
parentPageId: payload.parentPageId,
} as Partial<SpaceTreeNode>);
// For make-child onto a previously-childless (loaded) target: flip
// hasChildren on so the new parent shows its chevron.
if (op.kind === "make-child") {
optimistic = treeModel.update(optimistic, op.targetId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
}
// If the old parent has no children left, mark hasChildren: false so the
// chevron disappears. Without this, the empty parent keeps rendering an
@@ -79,14 +116,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
}
}
// For make-child onto a previously-childless target: flip hasChildren on
// so the new parent shows its chevron.
if (op.kind === "make-child") {
optimistic = treeModel.update(optimistic, op.targetId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
setData(optimistic);
try {
@@ -150,6 +150,45 @@
);
}
/* "A page just moved in here" cue (#523). A make-child drop no longer expands
the collapsed target, so the moved page is momentarily invisible; pulse the
target row in a distinct teal (NOT the blue make-child highlight, NOT the
neutral post-move flash) so the landing is noticeable while the node stays
collapsed. Two short pulses fit inside DROP_LANDED_HIGHLIGHT_MS (1.8s), after
which the row clears the attribute and the animation stops. */
@keyframes landedChildPulse {
0% {
background-color: light-dark(
var(--mantine-color-teal-2),
rgba(45, 212, 191, 0.30)
);
outline-color: light-dark(
var(--mantine-color-teal-6),
var(--mantine-color-teal-5)
);
}
100% {
background-color: transparent;
outline-color: transparent;
}
}
.node[data-landed-child="true"] {
outline: 2px solid transparent;
outline-offset: -1px;
animation: landedChildPulse 0.9s ease-out 2;
}
@media (prefers-reduced-motion: reduce) {
.node[data-landed-child="true"] {
animation: none;
background-color: light-dark(
var(--mantine-color-teal-1),
rgba(45, 212, 191, 0.18)
);
}
}
.dropLine {
position: absolute;
left: var(--drop-line-indent, 0);
@@ -1,6 +1,6 @@
import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import { Group, Text, VisuallyHidden } from "@mantine/core";
import { Group, VisuallyHidden } from "@mantine/core";
import { useState, useMemo } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
@@ -84,11 +84,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
onFiltersChange={handleFiltersChange}
spaceId={spaceId}
/>
{/* #529: operator hint matches ANY word by default; "" for an exact
phrase, +term to require, -term to exclude. */}
<Text size="xs" c="dimmed" mt={4}>
{t('Tip: "exact phrase", +required, -excluded')}
</Text>
</div>
<VisuallyHidden role="status" aria-live="polite">
@@ -5,9 +5,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
export interface IPageSearch {
id: string;
// #529 A7 superset: `pageId` aliases `id`; `rank`/`highlight` are null for
// substring-only hits (the UI already falls back to the title/snippet).
pageId?: string;
title: string;
icon: string;
parentPageId: string;
@@ -15,36 +12,9 @@ export interface IPageSearch {
creatorId: string;
createdAt: Date;
updatedAt: Date;
rank: string | number | null;
highlight: string | null;
rank: string;
highlight: string;
space: Partial<ISpace>;
// New #529 fields (present from the native Postgres search driver).
snippet?: string;
score?: number;
path?: string[];
matchedFields?: string[];
matchedTerms?: string[];
}
// #529 A5 pagination envelope returned by POST /search (native driver). The web
// list helpers read `items`; these travel alongside for pagination + diagnostics.
export interface IPageSearchResponse {
items: IPageSearch[];
total: number;
hasMore: boolean;
truncatedAtCap: boolean;
offset: number;
query?: {
raw: string;
parsed: {
positive: string[];
required: string[];
excluded: string[];
reason?: string;
};
mode: "or" | "and";
match: string;
};
}
export interface SearchSuggestionParams {
@@ -67,10 +37,6 @@ export interface IPageSearchParams {
query: string;
spaceId?: string;
shareId?: string;
// #529 A9: match mode (auto default) + pagination.
match?: "auto" | "word" | "prefix" | "substring";
limit?: number;
offset?: number;
}
export interface IAttachmentSearch {
@@ -1,195 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, act, cleanup } from "@testing-library/react";
import { MemoryRouter, useNavigate } from "react-router-dom";
// Mocks for the dirty shell's side-effecting collaborators.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
vi.mock("@/lib/reload-guard", () => ({
hasAutoReloaded: vi.fn(() => false),
markAutoReloaded: vi.fn(() => true),
recordReloadBreadcrumb: vi.fn(),
takeReloadBreadcrumb: vi.fn(() => null),
}));
import { notifications } from "@mantine/notifications";
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
__resetGuardedReloadForTests,
} from "./guarded-reload";
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
typeof vi.fn
>;
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
typeof vi.fn
>;
let reload: ReturnType<typeof vi.fn>;
let visibility: DocumentVisibilityState;
// Test harness mounted inside a router: it installs the navigation hook and
// exposes `navigate` so a test can drive an in-app router navigation.
let doNavigate: (to: string) => void;
function Harness() {
useVersionReloadOnNavigation();
const navigate = useNavigate();
doNavigate = navigate;
return null;
}
function mountHarness() {
render(
<MemoryRouter initialEntries={["/start"]}>
<Harness />
</MemoryRouter>,
);
}
function navigateTo(path: string) {
act(() => {
doNavigate(path);
});
}
beforeEach(() => {
__resetGuardedReloadForTests();
vi.clearAllMocks();
mockHasAutoReloaded.mockReturnValue(false);
mockMarkAutoReloaded.mockReturnValue(true);
vi.stubGlobal("APP_VERSION", "test-A");
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { reload },
});
visibility = "visible";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => visibility,
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("triggerGuardedReload (variant C)", () => {
it("noop when versions match: no banner, no reload", () => {
triggerGuardedReload("test-A");
expect(show).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
it("noop when the server version is empty (fail-safe)", () => {
triggerGuardedReload("");
triggerGuardedReload(undefined);
expect(show).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
it("real mismatch shows the banner but does NOT reload immediately", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
expect(show.mock.calls[0][0]).toMatchObject({
id: "app-version-reload",
autoClose: false,
withCloseButton: true,
});
expect(reload).not.toHaveBeenCalled();
});
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
// A second navigation must NOT reload again (one-shot was consumed).
navigateTo("/again");
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
// Another app-version mismatch arrives (reconnect): must not re-arm.
triggerGuardedReload("test-C");
navigateTo("/again");
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload merely from the tab going to the background", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
visibility = "hidden";
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(reload).not.toHaveBeenCalled();
});
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
visibility = "hidden";
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
expect(show).toHaveBeenCalledTimes(1);
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
});
it("the banner's Update button reloads immediately", () => {
triggerGuardedReload("test-B");
const message = show.mock.calls[0][0].message as {
props: { onClick: () => void };
};
message.props.onClick();
expect(reload).toHaveBeenCalledTimes(1);
});
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
mockHasAutoReloaded.mockReturnValue(true);
mountHarness();
triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
});
it("does NOT reload when the flag write fails; falls back to the banner", () => {
mockMarkAutoReloaded.mockReturnValue(false);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
// performAutoReload falls back to showing the banner (initial + fallback).
expect(show).toHaveBeenCalled();
});
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
triggerGuardedReload("test-B");
triggerGuardedReload("test-B");
triggerGuardedReload("test-C");
expect(show).toHaveBeenCalledTimes(1);
});
});
@@ -1,188 +0,0 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import i18n from "@/i18n.ts";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
} from "@/lib/reload-guard";
import { decideVersionAction } from "@/features/user/version-coherence";
// Dirty shell around the pure `decideVersionAction`: it reads globals
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
// Mantine notification, and arms the router-navigation reload hook. Kept
// separate from the pure module so the decision stays unit-testable without a
// DOM.
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
// single banner instead of stacking a new one each time.
const BANNER_ID = "app-version-reload";
// Module-level idempotency for the current tab-load: once a mismatch has been
// handled we don't re-arm the navigation reload or re-show the banner on
// subsequent app-version emits.
let handled = false;
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
// the background (that would silently drop a half-written comment/form). Instead
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
// the user is already leaving the current page, so an in-app navigation would
// discard that unsaved component-state anyway and the reload adds no extra loss.
let pendingNavReload = false;
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
// the (already-visible) banner.
let lastServerVersion = "";
let lastClientVersion = "";
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
// where Vite's `define` did not run) — an unknown client version makes the
// pure decision no-op (fail-safe).
function readClientVersion(): string {
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
}
// Perform the actual reload — but only after the shared one-shot flag is
// persisted. If the write fails (storage unavailable) we must NOT reload
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
// to the manual banner so the user can still recover.
function performAutoReload(): void {
if (!markAutoReloaded()) {
showReloadBanner();
return;
}
// Trace right before the reload (which clears the console): a persistent
// breadcrumb + a log line so the auto-reload is observable in a field report.
recordReloadBreadcrumb({
path: "proactive",
serverVersion: lastServerVersion,
clientVersion: lastClientVersion,
});
console.warn(
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
);
window.location.reload();
}
function showReloadBanner(): void {
notifications.show({
id: BANNER_ID,
title: i18n.t("A new version is available"),
message: (
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
{i18n.t("Update")}
</Button>
),
autoClose: false,
withCloseButton: true,
});
}
/**
* Handle a server `app-version` announcement: compare it to this bundle's
* version and, on a real mismatch, show the banner and arm a guarded reload for
* the next in-app navigation (variant C).
*
* - real mismatch (window budget available) banner + arm navigation reload.
* The banner's "Update" button reloads immediately (same shared window guard).
* The tab is NOT reloaded on visibility change.
* - auto-reload already used this window / storage error banner only (no arm),
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
* safety).
* - in sync / unknown version noop (fail-safe).
*/
export function triggerGuardedReload(
rawServerVersion: string | undefined | null,
): void {
const serverVersion = (rawServerVersion ?? "").trim();
const clientVersion = readClientVersion();
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
// reloading (banner only).
const autoReloadUsed = hasAutoReloaded();
const action = decideVersionAction({
serverVersion,
clientVersion,
autoReloadUsed,
});
if (action === "noop") return;
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
// emits (reconnects) once we've already acted.
if (handled) return;
handled = true;
lastServerVersion = serverVersion;
lastClientVersion = clientVersion;
if (action === "banner") {
// Entered banner-only (permanent skew, node oscillation, or the window's
// auto-reload budget already spent). Log for diagnosability; show the banner.
console.warn(
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
"auto-reload budget already spent this window — showing manual banner",
);
showReloadBanner();
return;
}
// action === "reload" (variant C): show the banner and defer the auto-reload
// to the next in-app navigation instead of reloading now / on visibility.
showReloadBanner();
pendingNavReload = true;
}
/**
* Consume the armed one-shot navigation reload, if any. Called by
* `useVersionReloadOnNavigation` on each in-app router navigation.
*/
export function consumeNavigationReload(): void {
if (!pendingNavReload) return;
pendingNavReload = false;
performAutoReload();
}
/**
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
* NEXT in-app router navigation after a version mismatch. Skips the initial
* render so it only reacts to real navigations, not the first location.
*/
export function useVersionReloadOnNavigation(): void {
const location = useLocation();
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
consumeNavigationReload();
}, [location.key]);
}
/**
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
* load the reload cleared the console, so this makes a "tab reloaded itself"
* report diagnosable. Call once on app startup.
*/
export function surfacePreviousReloadBreadcrumb(): void {
const crumb = takeReloadBreadcrumb();
if (!crumb) return;
console.info(
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
`at=${new Date(crumb.at).toISOString()}`,
);
}
// Test-only: reset module-level latches between cases.
export function __resetGuardedReloadForTests(): void {
handled = false;
pendingNavReload = false;
lastServerVersion = "";
lastClientVersion = "";
}
@@ -1,171 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, act, cleanup } from "@testing-library/react";
import { MemoryRouter, useNavigate } from "react-router-dom";
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
// the reactive chunk-load boundary and the proactive version-coherence path both
// route through the REAL @/lib/reload-guard, so at most one automatic reload
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
// side-effecting collaborators are mocked — the reload guard is intentionally
// REAL so this exercises the actual shared sessionStorage budget.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
import { handleError } from "@/components/chunk-load-error-boundary";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
__resetGuardedReloadForTests,
} from "./guarded-reload";
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
const T0 = 1_000_000_000_000;
let reload: ReturnType<typeof vi.fn>;
let nowMock: ReturnType<typeof vi.spyOn>;
// Harness mounted inside a router: installs the navigation hook and exposes
// `navigate` so a test can drive an in-app router navigation (the point where the
// proactive path fires its armed reload).
let doNavigate: (to: string) => void;
function Harness() {
useVersionReloadOnNavigation();
doNavigate = useNavigate();
return null;
}
function mountHarness() {
render(
<MemoryRouter initialEntries={["/start"]}>
<Harness />
</MemoryRouter>,
);
}
function navigateTo(path: string) {
act(() => {
doNavigate(path);
});
}
function setNow(t: number) {
nowMock.mockReturnValue(t);
}
beforeEach(() => {
sessionStorage.clear();
__resetGuardedReloadForTests();
vi.clearAllMocks();
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
vi.stubGlobal("APP_VERSION", "test-A");
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { reload },
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
sessionStorage.clear();
});
describe("shared window-based reload budget (invariant a)", () => {
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
handleError(CHUNK_ERROR);
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
// is spent, so the version mismatch degrades to the banner and never reloads.
setNow(T0 + 60_000);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
});
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
setNow(T0 + 2 * 60_000);
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
});
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
// First auto-reload (reactive) stamps the window.
handleError(CHUNK_ERROR);
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// A second deploy arrives after the window has fully elapsed → the proactive
// path is allowed to reload again (window, not a permanent one-shot).
__resetGuardedReloadForTests();
setNow(T0 + RELOAD_WINDOW_MS + 1);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
});
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
// getItem→null makes hasAutoReloaded() report the budget as available, so
// handleError passes the first guard and reaches `if (!markAutoReloaded())
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
// returns false and that guard MUST bail — otherwise the reactive path would
// reload on every stale-chunk error with no persisted budget (an unguarded
// loop). This is the asymmetric gap: the proactive path's equivalent is
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
// fails".
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("quota exceeded");
},
removeItem: () => {},
clear: () => {},
});
try {
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
// The real guard fails toward NOT reloading when storage throws.
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("storage disabled");
},
setItem: () => {
throw new Error("storage disabled");
},
removeItem: () => {},
clear: () => {},
});
try {
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -13,12 +13,6 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { queryClient } from "@/main.tsx";
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
surfacePreviousReloadBreadcrumb,
} from "@/features/user/guarded-reload.tsx";
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
@@ -28,16 +22,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
// fetch collab token on load
const { data: collab } = useCollabToken();
// version-coherence: fire the armed one-shot reload on the next in-app
// navigation (variant C — a safe point, not on tab backgrounding).
useVersionReloadOnNavigation();
// Surface any breadcrumb left by an auto-reload in the previous page load
// (the reload cleared the console) so a field report stays diagnosable.
useEffect(() => {
surfacePreviousReloadBreadcrumb();
}, []);
useEffect(() => {
if (isLoading || isError) {
return;
@@ -63,16 +47,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
handleConnect();
});
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
// connects: the server emits `app-version` immediately in handleConnection,
// so a listener attached after connect would miss it on a fast localhost
// connect. On a version mismatch the client shows a banner and defers the
// auto-reload to the next in-app navigation (variant C — avoids reloading a
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
triggerGuardedReload(payload?.version);
});
return () => {
console.log("ws disconnected");
newSocket.disconnect();
@@ -1,64 +0,0 @@
import { describe, it, expect } from "vitest";
import { decideVersionAction } from "./version-coherence";
describe("decideVersionAction", () => {
it("noop when the server version is empty (fail-safe)", () => {
expect(
decideVersionAction({
serverVersion: "",
clientVersion: "v1",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("noop when the client version is empty (fail-safe)", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("noop when versions are equal (in sync)", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "v1",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("reload on a real mismatch the first time this session", () => {
expect(
decideVersionAction({
serverVersion: "test-B",
clientVersion: "test-A",
autoReloadUsed: false,
}),
).toBe("reload");
});
it("banner on a mismatch once the session auto-reload is spent", () => {
expect(
decideVersionAction({
serverVersion: "test-B",
clientVersion: "test-A",
autoReloadUsed: true,
}),
).toBe("banner");
});
it("equal versions stay noop even if auto-reload was already used", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "v1",
autoReloadUsed: true,
}),
).toBe("noop");
});
});
@@ -1,32 +0,0 @@
// Payload of the per-connect `app-version` socket.io event announced by the
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
// `operation`), so it never touches use-query-subscription.
export type AppVersionSocketPayload = { version: string };
/**
* Pure decision for the version-coherence guard.
*
* All inputs are injected (no globals, no side effects) so it is unit-testable
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
*
* - `autoReloadUsed` = an automatic reload has already happened within the
* current ~5-min window, so we must not auto-reload again (loop safety,
* shared window budget with the reactive chunk-load boundary).
*
* Returns:
* - "noop" do nothing (unknown version on either side, or already in sync).
* - "banner" show the manual "update available" banner only (no auto-reload).
* - "reload" real first-time mismatch: eligible for a guarded auto-reload.
*/
export function decideVersionAction(args: {
serverVersion: string;
clientVersion: string;
autoReloadUsed: boolean;
}): "reload" | "banner" | "noop" {
const { serverVersion, clientVersion, autoReloadUsed } = args;
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
if (serverVersion === clientVersion) return "noop"; // in sync
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
return "reload"; // real mismatch, window budget available
}
-145
View File
@@ -1,145 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
hasAutoReloaded,
markAutoReloaded,
shouldAutoReload,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
RELOAD_WINDOW_MS,
} from "./reload-guard";
// The shared budget is a single sessionStorage timestamp keyed here; both the
// reactive chunk-load boundary and the proactive version-coherence path read and
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
const RELOAD_AT_KEY = "chunk-reload-at";
const NOW = 1_000_000_000_000;
describe("reload-guard", () => {
beforeEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
expect(hasAutoReloaded(NOW)).toBe(false);
expect(markAutoReloaded(NOW)).toBe(true);
// Same key both paths share; stores the reload timestamp, not a flag.
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
// Inside the window → budget spent → true (fall through to manual UI).
expect(hasAutoReloaded(NOW)).toBe(true);
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
});
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
markAutoReloaded(NOW);
// Strictly older than the window → a new deploy's mismatch may reload again.
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
});
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("storage disabled");
},
setItem: () => {
throw new Error("storage disabled");
},
});
try {
expect(hasAutoReloaded()).toBe(true);
} finally {
vi.unstubAllGlobals();
}
});
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
expect(hasAutoReloaded(NOW)).toBe(false);
});
it("markAutoReloaded returns false when writing storage throws", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
});
try {
expect(markAutoReloaded()).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
it("records and then takes a breadcrumb once (cleared on read)", () => {
recordReloadBreadcrumb({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
const crumb = takeReloadBreadcrumb();
expect(crumb).toMatchObject({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
expect(typeof crumb?.at).toBe("number");
// Cleared on read → a second take returns null.
expect(takeReloadBreadcrumb()).toBeNull();
});
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
expect(takeReloadBreadcrumb()).toBeNull();
});
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
removeItem: () => {},
});
try {
expect(() =>
recordReloadBreadcrumb({ path: "chunk-boundary" }),
).not.toThrow();
} finally {
vi.unstubAllGlobals();
}
});
});
// The pure window gate replaces the old one-shot flag: it must permit recovery
// across several deploys in one tab (each > window apart) while still stopping an
// infinite reload loop when a lazy chunk is permanently broken (a second failure
// < window). Moved here from the chunk-load boundary now that it is the shared
// guard both paths route through.
describe("shouldAutoReload", () => {
const WINDOW = RELOAD_WINDOW_MS;
it("allows a reload when we have never auto-reloaded", () => {
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
});
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
});
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
});
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
});
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
});
});
-121
View File
@@ -1,121 +0,0 @@
// Shared, window-based auto-reload budget.
//
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
// they share ONE window-scoped reload budget: at most a single automatic
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
// chunk still degrades to a manual banner/UI after the first reload instead of
// looping. When sessionStorage is unavailable every mismatch degrades to the
// manual UI — no unguarded reload.
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
// (shared by both paths).
const RELOAD_AT_KEY = "chunk-reload-at";
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
// by a single reload, so anything inside the window is treated as a reload loop
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
/**
* Pure window decision, unit-tested in isolation: auto-reload only if we have
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
* than the window. Anything inside the window is suppressed to break an infinite
* reload loop.
*/
export function shouldAutoReload(
now: number,
lastReloadAt: number | null,
windowMs: number,
): boolean {
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
return now - lastReloadAt > windowMs;
}
/**
* Has an automatic reload already happened within the current window (so the
* shared budget is spent right now)? Both paths check this before reloading; a
* `true` return means fall through to the manual banner/UI instead of reloading.
*
* A storage read error (private mode / disabled) is reported as `true` so the
* caller fails toward NOT reloading an unguarded loop is worse than a stale
* tab the user can reload manually. Note a window (not a permanent flag): once
* the window elapses a later deploy's mismatch is allowed to reload again.
*/
export function hasAutoReloaded(now: number = Date.now()): boolean {
try {
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
} catch {
return true;
}
}
/**
* Stamp the shared window as consumed now record that an automatic reload is
* being performed within the current RELOAD_WINDOW_MS window.
*
* Returns whether the write succeeded. A `false` return (storage unavailable)
* means the caller MUST NOT reload otherwise the stamp would never stick and
* the reload could loop.
*/
export function markAutoReloaded(now: number = Date.now()): boolean {
try {
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
return true;
} catch {
return false;
}
}
// Diagnostic breadcrumb for an automatic reload. Written right before
// window.location.reload() (which clears the console) and read back on the next
// page load, so a "the tab reloaded itself / it's looping" field report is
// diagnosable: which path fired (proactive version-coherence vs the reactive
// chunk-load boundary) and which version pair triggered it. sessionStorage
// survives a same-tab reload, unlike the console.
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
export type ReloadBreadcrumb = {
path: "proactive" | "chunk-boundary";
serverVersion?: string;
clientVersion?: string;
at: number;
};
/**
* Persist a best-effort breadcrumb just before an automatic reload. Failures
* (storage unavailable) are swallowed this is diagnostics only and must never
* block or alter the reload decision.
*/
export function recordReloadBreadcrumb(
entry: Omit<ReloadBreadcrumb, "at">,
): void {
try {
sessionStorage.setItem(
RELOAD_BREADCRUMB_KEY,
JSON.stringify({ ...entry, at: Date.now() }),
);
} catch {
// best-effort diagnostics only
}
}
/**
* Read and clear the breadcrumb left by an auto-reload in the previous page
* load. Cleared on read so it surfaces exactly once per reload.
*/
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
try {
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
if (!raw) return null;
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
return JSON.parse(raw) as ReloadBreadcrumb;
} catch {
return null;
}
}
+2 -29
View File
@@ -1,8 +1,7 @@
import { defineConfig, loadEnv, type Plugin } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2";
import * as path from "path";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const envPath = path.resolve(process.cwd(), "..", "..");
@@ -25,32 +24,7 @@ function resolveAppVersion(cwd: string): string {
}
}
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
// the exact same build id the bundle was compiled with. The value is the SAME
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
// global are identical by construction — the single source of truth (no
// runtime-env second copy that could drift and cause a false version mismatch).
function versionJsonPlugin(version: string): Plugin {
let outDir = "dist";
return {
name: "emit-version-json",
apply: "build",
configResolved(config) {
outDir = config.build.outDir;
},
writeBundle() {
const root = path.resolve(process.cwd(), outDir);
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(
path.join(root, "version.json"),
JSON.stringify({ version }),
);
},
};
}
export default defineConfig(({ mode }) => {
const appVersion = resolveAppVersion(envPath);
const {
APP_URL,
FILE_UPLOAD_SIZE_LIMIT,
@@ -78,11 +52,10 @@ export default defineConfig(({ mode }) => {
POSTHOG_HOST,
POSTHOG_KEY,
},
APP_VERSION: JSON.stringify(appVersion),
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
},
plugins: [
react(),
versionJsonPlugin(appVersion),
// Emit .br and .gz next to every built asset so the server can serve the
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
compression({
+1 -1
View File
@@ -23,7 +23,7 @@
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build && pnpm --filter @docmost/mcp build",
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build",
"test": "jest",
"test:int": "jest --config test/jest-integration.json",
"test:watch": "jest --watch",
@@ -16,7 +16,6 @@ import { TransclusionService } from '../core/page/transclusion/transclusion.serv
import { TransclusionModule } from '../core/page/transclusion/transclusion.module';
import { StorageModule } from '../integrations/storage/storage.module';
import { EnvironmentModule } from '../integrations/environment/environment.module';
import { ApiKeyModule } from '../core/api-key/api-key.module';
@Module({
providers: [
@@ -32,7 +31,6 @@ import { ApiKeyModule } from '../core/api-key/api-key.module';
exports: [CollaborationGateway],
imports: [
TokenModule,
ApiKeyModule,
WatcherModule,
StorageModule.forRootAsync({
imports: [EnvironmentModule],
@@ -141,57 +141,7 @@ export function htmlToJson(html: string) {
}
}
/**
* Deterministic text-serializer overrides for the `format:"text"` page read
* (#502). Non-text nodes render to a STABLE placeholder instead of their
* (structure-dependent) inner text, so a machine diff of two text reads is
* driven only by the page's actual prose output stability across package
* versions IS the contract (pinned by a snapshot test). Returning a string from
* a `textSerializer` also stops `generateText` descending into the node, so a
* table renders as ONE token rather than its flattened cell text.
*
* Only nodes with no meaningful flat-text form are overridden; every other node
* (paragraph/heading/list/code/blockquote/callout/) keeps its natural text so
* a config written as markdown reads back byte-identical.
*/
const TEXT_READ_SERIALIZERS: Record<string, (props: { node: any }) => string> =
{
// Image atom: no inner text -> a fixed placeholder.
image: () => '[image]',
// Table: `[table RxC]` where R = row count, C = the first row's cell count
// (a table's columns are uniform per the schema). Computed from the PM node,
// so it is independent of cell contents.
table: ({ node }) => {
const rows = node?.childCount ?? 0;
const cols = rows > 0 ? (node.child(0)?.childCount ?? 0) : 0;
return `[table ${rows}x${cols}]`;
},
};
/**
* Serialize a ProseMirror/TipTap document to plain text.
*
* Default (no options): the long-standing search-index behavior bare
* concatenated node text with `generateText`'s default `\n\n` block separator.
* This feeds the page `textContent` tsvector and MUST NOT change.
*
* `deterministic:true` (#502 `format:"text"` page read): a flat, machine-diffable
* rendering one line per block (`\n` block separator; `hardBreak` already
* serializes to `\n`), inline marks/anchors/autoformat dropped, and non-text
* nodes replaced by the stable placeholders above (`[image]`, `[table RxC]`).
*/
export function jsonToText(
// `any` (like jsonToHtml/jsonToMarkdown) so a loosely-typed DB `page.content`
// (JsonValue) can be passed straight through, as the controller does.
tiptapJson: any,
options?: { deterministic?: boolean },
) {
if (options?.deterministic) {
return generateText(tiptapJson, tiptapExtensions, {
blockSeparator: '\n',
textSerializers: TEXT_READ_SERIALIZERS,
});
}
export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
}
+4 -30
View File
@@ -1,36 +1,10 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
// #348 — debounce window for the per-page RAG re-embed job. Repeated saves
// within this window collapse to a single delayed job (coalesced by a stable
// jobId), so active editing does not pile up expensive re-embeds (external API
// + page_embeddings rewrite, concurrency 1). The worker reads the CURRENT page
// state at run time, so the last content within the window wins.
export const EMBED_DEBOUNCE_MS = 30 * 1000;
/**
* #370 page-history intentionality tiers. Domain of `page_history.kind`.
* - 'manual' / 'agent' Tier 1 versions (intentional points)
* - 'idle' / 'boundary' Tier 0 autosnapshots (safety net)
* A legacy `null` kind is treated as an autosave.
*/
export type PageHistoryKind = 'manual' | 'agent' | 'idle' | 'boundary';
/**
* #370 trailing idle-flush windows. A page's pending idle snapshot is
* re-armed on every store and fires this long after edits go quiet, so a burst
* of edits collapses into a single autosnapshot instead of one-per-store. Human
* sessions are noisier and less risky, so they flush less often than the agent.
*/
export const IDLE_INTERVAL_USER = 60 * 60 * 1000; // 60m
export const IDLE_INTERVAL_AGENT = 15 * 60 * 1000; // 15m
/**
* #370 max-wait ceiling for the idle flush. Pure trailing debounce starves the
* safety net: hocuspocus stores at least every ~45s, so a CONTINUOUS editing
* session would re-arm the trailing timer forever and never take an idle
* snapshot until edits finally go quiet (up to IDLE_INTERVAL_USER = 60m). This
* ceiling bounds the actual wait from the FIRST edit of a burst, so an idle
* snapshot fires at least this often during a long unbroken session restoring
* a recovery point cadence closer to the old heuristic without one-per-store
* noise. Mirrors hocuspocus's own maxDebounce idea.
*/
export const IDLE_MAX_WAIT_USER = 10 * 60 * 1000; // 10m
export const IDLE_MAX_WAIT_AGENT = 5 * 60 * 1000; // 5m
@@ -52,7 +52,6 @@ describe('AuthenticationExtension.onAuthenticate', () => {
let pageRepo: { findById: jest.Mock };
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
let pagePermissionRepo: { canUserEditPage: jest.Mock };
let apiKeyService: { validate: jest.Mock };
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
// starts false; the extension flips it to true on a read-only downgrade.
@@ -80,15 +79,12 @@ describe('AuthenticationExtension.onAuthenticate', () => {
}),
};
apiKeyService = { validate: jest.fn().mockResolvedValue({ user: {}, workspace: {} }) };
ext = new AuthenticationExtension(
tokenService as any,
userRepo as any,
pageRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
apiKeyService as any,
);
// Silence the extension's logger (it warns/debugs on denial branches).
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
@@ -235,73 +231,4 @@ describe('AuthenticationExtension.onAuthenticate', () => {
// No internal ai_chats row for an MCP/service-account collab edit → null.
expect(ctx.aiChatId).toBeNull();
});
// --- #501: api-key laundering guard (fail-closed discriminator) ----------
describe('api-key laundering guard', () => {
it('api_key principal → row-checks the key on connect (valid key proceeds)', async () => {
tokenService.verifyJwt.mockResolvedValue(
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
);
const data = buildData();
await ext.onAuthenticate(data as any);
expect(apiKeyService.validate).toHaveBeenCalledTimes(1);
expect(apiKeyService.validate).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: 'key-1', type: JwtType.API_KEY }),
);
});
it('REVOKED api_key → Unauthorized on connect, BEFORE any page/user lookup', async () => {
tokenService.verifyJwt.mockResolvedValue(
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
);
// The shared validator denies a revoked key.
apiKeyService.validate.mockRejectedValue(new UnauthorizedException());
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
// No new collab connection: the key check gates before page access.
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('api_key principal missing apiKeyId → Unauthorized (malformed)', async () => {
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'api_key' }));
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
expect(apiKeyService.validate).not.toHaveBeenCalled();
});
it('session principal → NO api-key check (session-backed, incl. internal agent)', async () => {
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'session' }));
await ext.onAuthenticate(buildData() as any);
expect(apiKeyService.validate).not.toHaveBeenCalled();
});
it('claimless token WITHIN the grace window → trusted (legacy pre-rollout)', async () => {
// Default rolloutAt = now, so we are inside the grace window.
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
await expect(ext.onAuthenticate(buildData() as any)).resolves.toBeDefined();
expect(apiKeyService.validate).not.toHaveBeenCalled();
});
it('claimless token AFTER the grace window → Unauthorized (fail-closed)', async () => {
// Move the rollout reference far into the past so the grace has elapsed.
(ext as any).rolloutAt = Date.now() - 25 * 60 * 60 * 1000;
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('infra error from the api-key row-check propagates (not masked)', async () => {
tokenService.verifyJwt.mockResolvedValue(
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
);
const boom = new Error('db down');
apiKeyService.validate.mockRejectedValue(boom);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toBe(boom);
});
});
});
@@ -14,37 +14,20 @@ import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { isUserDisabled } from '../../common/helpers';
import { getPageId } from '../collaboration.util';
import {
JwtApiKeyPayload,
JwtCollabPayload,
JwtType,
} from '../../core/auth/dto/jwt-payload';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
import { ApiKeyService } from '../../core/api-key/api-key.service';
// Max lifetime of a collab token (generateCollabToken uses expiresIn '24h'). Used
// as the rollout grace window below: once this long has elapsed since this
// process started serving the #501 code, every STILL-VALID collab token was
// necessarily minted post-rollout and MUST carry the `principal` discriminator,
// so a claimless one is a bug and is rejected (fail-closed) rather than trusted.
const COLLAB_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
@Injectable()
export class AuthenticationExtension implements Extension {
private readonly logger = new Logger(AuthenticationExtension.name);
// Reference instant for the claimless-rejection grace window. Overridable so a
// unit test can drive the pre-/post-grace boundary without wall-clock waits.
protected rolloutAt = Date.now();
constructor(
private tokenService: TokenService,
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly apiKeyService: ApiKeyService,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -71,36 +54,6 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException('Invalid collab token');
}
// #501 — fail-closed api-key laundering guard. A collab token minted by an
// api-key principal carries principal='api_key' + apiKeyId; re-check the key
// on connect so a REVOKED key gets NO new collab connections (a collab token
// outlives its 24h, but a revoked key can no longer open fresh ones). An
// api-key token missing its apiKeyId is malformed → reject. A claimless token
// (no recognized principal) is trusted only DURING the rollout grace window
// (a legacy pre-rollout session token, which api keys could never mint);
// once the grace has elapsed every valid token must carry the discriminator,
// so a claimless one is a bug and is rejected (not silently trusted for 24h).
const principal = jwtPayload.principal;
if (principal === 'api_key') {
if (!jwtPayload.apiKeyId) {
throw new UnauthorizedException();
}
// Row-check via the SHARED validator: throws Unauthorized on a revoked/
// expired/disabled key; an infra error propagates (not masked). No new
// connection for a dead key.
await this.apiKeyService.validate({
sub: jwtPayload.sub,
workspaceId: jwtPayload.workspaceId,
apiKeyId: jwtPayload.apiKeyId,
type: JwtType.API_KEY,
} as JwtApiKeyPayload);
} else if (principal !== 'session') {
// Unrecognized/absent discriminator: reject once past the grace window.
if (Date.now() - this.rolloutAt >= COLLAB_TOKEN_GRACE_MS) {
throw new UnauthorizedException();
}
}
const userId = jwtPayload.sub;
const workspaceId = jwtPayload.workspaceId;
@@ -1,93 +1,84 @@
import { computeHistoryJob, resolveSource } from './persistence.extension';
import {
IDLE_INTERVAL_AGENT,
IDLE_INTERVAL_USER,
IDLE_MAX_WAIT_AGENT,
IDLE_MAX_WAIT_USER,
computeHistoryJob,
resolveSource,
} from './persistence.extension';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
// A fixed clock + fixed createdAt make pageAge deterministic.
const NOW = 1_700_000_000_000;
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
const page = { id: PAGE_ID };
// Build a minimal page whose age (NOW - createdAt) is exactly `ageMs`.
const pageAged = (ageMs: number) => ({
id: PAGE_ID,
createdAt: new Date(NOW - ageMs),
});
describe('computeHistoryJob (#370 — shared trailing idle pipeline)', () => {
it('human edit → user idle window, bare page.id job', () => {
// Humans and the agent now share ONE idle job per page (jobId = page.id).
// The agent's old delay=0 fast path is GONE — intentional agent points now
// arrive via the explicit save-version signal, not a zero-delay snapshot.
const { jobId, delay } = computeHistoryJob(page, 'user');
expect(delay).toBe(IDLE_INTERVAL_USER);
describe('computeHistoryJob', () => {
it('agent edit → delay MUST be 0 and job id is source-keyed', () => {
// INVARIANT (§15 H2 / persistence.extension): the agent delay MUST stay 0.
// The worker re-reads the page row at run time, so any non-zero delay risks
// snapshotting content a later human edit has already overwritten. This is
// the load-bearing assertion of this spec — do not relax it.
const { jobId, delay } = computeHistoryJob(pageAged(0), 'agent', NOW);
expect(delay).toBe(0);
expect(jobId).toBe(`${PAGE_ID}-agent`);
});
it('agent edit on an OLD page is still delay 0 (age never applies to agents)', () => {
// Even when the page is far older than the fast threshold, the agent path
// must short-circuit to 0 — age-based debounce is a human-only concern.
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD + 60_000),
'agent',
NOW,
);
expect(delay).toBe(0);
expect(jobId).toBe(`${PAGE_ID}-agent`);
});
it('human edit on a YOUNG page (age < threshold) → fast interval, bare job id', () => {
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD - 1),
'user',
NOW,
);
expect(delay).toBe(HISTORY_FAST_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
it('agent edit → agent idle window (shorter), still the bare page.id job', () => {
const { jobId, delay } = computeHistoryJob(page, 'agent');
expect(delay).toBe(IDLE_INTERVAL_AGENT);
// No `-agent` suffix anymore: the agent joins the common idle pipeline.
it('human edit on an OLD page (age > threshold) → standard interval', () => {
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD + 1),
'user',
NOW,
);
expect(delay).toBe(HISTORY_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
it('agent flushes sooner than a human', () => {
expect(IDLE_INTERVAL_AGENT).toBeLessThan(IDLE_INTERVAL_USER);
it('boundary: pageAge EXACTLY === threshold takes the slow branch (the `<` is strict)', () => {
// Off-by-one guard: the condition is `pageAge < HISTORY_FAST_THRESHOLD`, so
// an age of exactly the threshold is NOT "fast" — it must use HISTORY_INTERVAL.
const { delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD),
'user',
NOW,
);
expect(delay).toBe(HISTORY_INTERVAL);
});
it('treats any non-"agent" source string as human (keys strictly on === agent)', () => {
const { jobId, delay } = computeHistoryJob(page, 'user');
expect(delay).toBe(IDLE_INTERVAL_USER);
it('treats any non-"agent" source string as human', () => {
// resolveSource only ever yields 'agent' | 'user', but guard the contract:
// the agent branch keys strictly on === 'agent'.
const { jobId, delay } = computeHistoryJob(pageAged(0), 'user', NOW);
expect(delay).toBe(HISTORY_FAST_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
// #370 review round-1 WARNING: the max-wait ceiling prevents autosnapshot
// starvation during a continuous editing session (the trailing timer would
// otherwise re-arm forever and never fire).
describe('max-wait ceiling', () => {
const T0 = 1_000_000; // arbitrary fixed epoch for deterministic tests
it('once a burst is armed, delay clamps to the remaining max-wait budget', () => {
// 1 minute into the burst the USER interval (60m) far exceeds the remaining
// max-wait budget (10m - 1m = 9m), so the delay is clamped DOWN to that
// remaining budget — the full interval is NOT used once a ceiling applies.
const { delay } = computeHistoryJob(page, 'user', T0, T0 + 60_000);
expect(delay).toBe(IDLE_MAX_WAIT_USER - 60_000);
});
it('never waits longer than the max-wait budget from the burst start', () => {
// A store arriving right at the ceiling → delay 0 (fire promptly).
const { delay } = computeHistoryJob(
page,
'user',
T0,
T0 + IDLE_MAX_WAIT_USER,
);
expect(delay).toBe(0);
});
it('past the ceiling never returns a negative delay', () => {
const { delay } = computeHistoryJob(
page,
'user',
T0,
T0 + IDLE_MAX_WAIT_USER + 5 * 60_000,
);
expect(delay).toBe(0);
});
it('the agent ceiling is shorter than the user ceiling', () => {
expect(IDLE_MAX_WAIT_AGENT).toBeLessThan(IDLE_MAX_WAIT_USER);
const { delay } = computeHistoryJob(
page,
'agent',
T0,
T0 + IDLE_MAX_WAIT_AGENT,
);
expect(delay).toBe(0);
});
it('without a burstStart there is no ceiling (backward-compatible)', () => {
expect(computeHistoryJob(page, 'user').delay).toBe(IDLE_INTERVAL_USER);
expect(computeHistoryJob(page, 'agent').delay).toBe(IDLE_INTERVAL_AGENT);
});
});
});
describe('resolveSource (truth table)', () => {
@@ -40,12 +40,11 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
let pageHistoryRepo: {
saveHistory: jest.Mock;
findPageLastHistory: jest.Mock;
updateHistoryKind: jest.Mock;
};
let aiQueue: { add: jest.Mock };
let historyQueue: { add: jest.Mock; remove: jest.Mock };
let historyQueue: { add: jest.Mock };
let notificationQueue: { add: jest.Mock };
let collabHistory: { addContributors: jest.Mock; popContributors: jest.Mock };
let collabHistory: { addContributors: jest.Mock };
let transclusionService: {
syncPageTransclusions: jest.Mock;
syncPageReferences: jest.Mock;
@@ -94,22 +93,13 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
pageHistoryRepo = {
saveHistory: jest.fn().mockImplementation(async () => {
callOrder.push('saveHistory');
return { id: 'history-1' };
}),
findPageLastHistory: jest.fn().mockResolvedValue(null),
updateHistoryKind: jest.fn().mockResolvedValue(undefined),
};
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
historyQueue = {
add: jest.fn().mockResolvedValue(undefined),
// #370 — enqueuePageHistory now removes any pending idle job before re-adding.
remove: jest.fn().mockResolvedValue(undefined),
};
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
collabHistory = {
addContributors: jest.fn().mockResolvedValue(undefined),
popContributors: jest.fn().mockResolvedValue([]),
};
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
transclusionService = {
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
syncPageReferences: jest.fn().mockResolvedValue(undefined),
@@ -175,50 +165,6 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
});
// #370 review round-1 SUGGESTION: the boundary was GENERALIZED from a
// user→agent special-case to ANY lastUpdatedSource transition. These pin the
// generalized behaviour it was rebuilt for.
describe('generalized boundary — any source transition', () => {
// Same persisted page but with an explicit prior source.
const pageWithPriorSource = (prior: string | null) => ({
...persistedHumanPage('NEW CONTENT'),
lastUpdatedSource: prior,
});
it('agent→user transition fires the boundary (pins the prior agent revision)', async () => {
const document = ydocFor(doc('NEW CONTENT'));
pageRepo.findById.mockResolvedValue(pageWithPriorSource('agent'));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await ext.onStoreDocument(buildData(document, 'user') as any);
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
});
it('git→user transition fires the boundary (git-sync overwrite is a source change)', async () => {
const document = ydocFor(doc('NEW CONTENT'));
pageRepo.findById.mockResolvedValue(pageWithPriorSource('git'));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await ext.onStoreDocument(buildData(document, 'user') as any);
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
});
it('a null prior source (first-ever edit) does NOT fire the boundary', async () => {
const document = ydocFor(doc('NEW CONTENT'));
pageRepo.findById.mockResolvedValue(pageWithPriorSource(null));
await ext.onStoreDocument(buildData(document, 'agent') as any);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
});
});
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
// The Y.Doc content equals the persisted content deeply → early skip.
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
@@ -533,231 +479,4 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
});
// #370 — explicit save-version (Cmd+S / agent save tool) over the stateless
// seam. The tier is derived from the SIGNED connection actor, the store path
// is reused, and promote-not-dup avoids duplicating heavy content rows.
describe('save-version (#370)', () => {
const emitSave = (document: any, actor: 'user' | 'agent') =>
ext.onStateless({
connection: {
readOnly: false,
context: { user: { id: USER_ID, name: 'Alice' }, actor },
} as any,
documentName: `page.${PAGE_ID}`,
document: document as any,
payload: JSON.stringify({ type: 'save-version' }),
} as any);
// findById returns a page whose content already equals the live doc, so the
// store path is a no-op and we isolate the versioning decision.
const pageMatchingDoc = (document: any) => ({
...persistedHumanPage('IGNORED'),
content: TiptapTransformer.fromYdoc(document, 'default'),
});
it('human save with no prior snapshot → writes a manual version + broadcasts', async () => {
const document = ydocFor(doc('VERSION ME'));
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await emitSave(document, 'user');
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
expect(pageHistoryRepo.saveHistory.mock.calls[0][1]).toEqual(
expect.objectContaining({ kind: 'manual' }),
);
// The pending idle autosnapshot is cancelled by the explicit version.
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
const msg = JSON.parse(
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
);
expect(msg).toMatchObject({
type: 'version.saved',
kind: 'manual',
alreadySaved: false,
});
});
it('agent save derives kind=agent from the signed actor', async () => {
const document = ydocFor(doc('AGENT VERSION'));
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await emitSave(document, 'agent');
expect(pageHistoryRepo.saveHistory.mock.calls[pageHistoryRepo.saveHistory.mock.calls.length - 1][1]).toEqual(
expect.objectContaining({ kind: 'agent' }),
);
});
it('promote-not-dup: latest snapshot is an autosave with identical content → upgrades in place', async () => {
const document = ydocFor(doc('SAME'));
const page = pageMatchingDoc(document);
pageRepo.findById.mockResolvedValue(page);
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
id: 'auto-1',
content: page.content,
kind: 'idle',
});
await emitSave(document, 'user');
// No heavy new content row — the existing autosave is promoted to manual.
expect(pageHistoryRepo.updateHistoryKind).toHaveBeenCalledWith(
'auto-1',
'manual',
expect.anything(),
);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
const msg = JSON.parse(
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
);
expect(msg).toMatchObject({ historyId: 'auto-1', alreadySaved: false });
});
it('no-op when the latest snapshot is already a manual version of this content', async () => {
const document = ydocFor(doc('ALREADY SAVED'));
const page = pageMatchingDoc(document);
pageRepo.findById.mockResolvedValue(page);
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
id: 'ver-1',
content: page.content,
kind: 'manual',
});
await emitSave(document, 'user');
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
const msg = JSON.parse(
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
);
expect(msg).toMatchObject({ alreadySaved: true, kind: 'manual' });
});
it('a read-only connection cannot save a version', async () => {
const document = ydocFor(doc('READER'));
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
await ext.onStateless({
connection: {
readOnly: true,
context: { user: { id: USER_ID }, actor: 'user' },
} as any,
documentName: `page.${PAGE_ID}`,
document: document as any,
payload: JSON.stringify({ type: 'save-version' }),
} as any);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
});
// #370 F8-twin — a COMMIT abort (serialization/deadlock/conn-drop) rejects
// OUTSIDE the tx callback, AFTER the destructive popContributors (SPOP) and
// saveHistory ran but the INSERT rolled back. onStateless has no retry, so
// the outer catch MUST re-add (SADD) the popped set or attribution is lost
// irrecoverably. MUTATION: drop the outer catch → addContributors is never
// called → this reddens.
it('restores popped contributors when the commit aborts after the callback', async () => {
const document = ydocFor(doc('VERSION ME'));
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
// No matching snapshot → fresh version branch → pops contributors.
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
collabHistory.popContributors.mockResolvedValue(['u1', 'u2']);
// A db whose commit REJECTS after the callback body resolved: the SPOP and
// saveHistory already ran, then the tx aborts. onStoreDocument's flush uses
// the same db but its content matches (no-op branch) and its own retry loop
// swallows the throw, so only the versioning tx exercises the restore.
const commitFailingDb = {
transaction: () => ({
execute: async (fn: (trx: any) => Promise<any>) => {
await fn(trxStub);
throw new Error('commit aborted (serialization_failure)');
},
}),
};
const ext2 = new PersistenceExtension(
pageRepo as any,
pageHistoryRepo as any,
commitFailingDb as any,
aiQueue as any,
historyQueue as any,
notificationQueue as any,
collabHistory as any,
transclusionService as any,
);
jest.spyOn(ext2['logger'], 'debug').mockImplementation(() => undefined);
jest.spyOn(ext2['logger'], 'warn').mockImplementation(() => undefined);
jest.spyOn(ext2['logger'], 'error').mockImplementation(() => undefined);
await expect(
ext2.onStateless({
connection: {
readOnly: false,
context: { user: { id: USER_ID, name: 'Alice' }, actor: 'user' },
} as any,
documentName: `page.${PAGE_ID}`,
document: document as any,
payload: JSON.stringify({ type: 'save-version' }),
} as any),
).rejects.toThrow();
// Attribution preserved: the popped set is SADD-restored, keyed by the page
// UUID it was popped under.
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
'u1',
'u2',
]);
});
// #370 #260 — for a `page.<slugId>` document the idle job is armed under the
// page UUID (computeHistoryJob's jobId = page.id), so the supersede-remove
// must target page.id, not the raw slugId doc-name id, or it silently misses.
it('cancels the superseded idle job by the page UUID for a slugId doc', async () => {
const SLUG = 'slug-1'; // persistedHumanPage.slugId
const document = ydocFor(doc('VERSION ME'));
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await ext.onStateless({
connection: {
readOnly: false,
context: { user: { id: USER_ID, name: 'Alice' }, actor: 'user' },
} as any,
documentName: `page.${SLUG}`,
document: document as any,
payload: JSON.stringify({ type: 'save-version' }),
} as any);
// remove() keyed by the UUID (the real jobId), never the slugId.
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
});
});
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
// its sibling per-document maps) or it grows unbounded for every page that was
// edited but never manually saved. MUTATION: drop the afterUnloadDocument
// delete → the entry survives → this reddens.
describe('idleBurstStart housekeeping', () => {
it('afterUnloadDocument clears the idle-burst marker armed by a store', async () => {
const document = ydocFor(doc('EDIT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('EDIT'));
await ext.onStoreDocument(buildData(document, 'user') as any);
const map = ext['idleBurstStart'] as Map<string, number>;
// Keyed by documentName (buildData uses `page.${PAGE_ID}`).
expect(map.has(`page.${PAGE_ID}`)).toBe(true);
await ext.afterUnloadDocument({
documentName: `page.${PAGE_ID}`,
} as any);
expect(map.has(`page.${PAGE_ID}`)).toBe(false);
});
});
});
@@ -37,11 +37,9 @@ import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
import {
EMBED_DEBOUNCE_MS,
IDLE_INTERVAL_AGENT,
IDLE_INTERVAL_USER,
IDLE_MAX_WAIT_AGENT,
IDLE_MAX_WAIT_USER,
PageHistoryKind,
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
import {
@@ -58,16 +56,6 @@ import { hasTransclusionFamilyNodes } from '../../core/page/transclusion/utils/t
*/
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
/**
* #370 wire format of the clientserver "save a version" signal. Sent by the
* human (Cmd+S / Save button) and by the agent's explicit save tool over the
* SAME stateless channel. The intentionality tier ('manual' vs 'agent') is
* derived SERVER-SIDE from the signed connection actor, never from this
* payload, so a version's type is unforgeable. The document is taken from the
* connection (not the payload), so the signal cannot be aimed at another page.
*/
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
/**
* #251 how long an intentional-clear signal stays "pending" before it is
* ignored. The signal is set on the clearing keystroke but consumed by the
@@ -104,39 +92,35 @@ export function resolveSource(
}
/**
* #370 compute the BullMQ job id + delay for a page's trailing idle-flush
* autosnapshot. Pure so the timing is unit-testable.
* Compute the BullMQ job id + delay for a page-history snapshot job. Pure so
* the data-loss-sensitive timing arithmetic is unit-testable; `now` is injected
* (caller passes `Date.now()`) for determinism.
*
* Both humans and the agent now share ONE idle pipeline (the agent's old
* `delay=0` fast path is gone intentional agent points arrive via the
* explicit save-version signal instead). The job id is the bare `page.id`, so a
* page has at most one pending idle job; the caller removes-and-re-adds it on
* every store to keep it debounced to the trailing edge of an edit burst. The
* window differs by source only: the agent flushes sooner than a human.
* - Agent edits: delay 0 and a source-keyed job id `${page.id}-agent`. The
* delay MUST stay 0 the worker re-reads the page row at run time, so any
* delay risks reading content a later human edit has already overwritten
* (mis-tagged snapshot). 0 minimizes that window. The `-agent` suffix keeps
* the job from coalescing with the bare-page.id human job.
* - Human edits: age-based debounce so rapid human edits coalesce into one
* snapshot; job id is the bare `page.id`.
*
* BullMQ forbids ':' in custom job ids (Redis key separator), so '-' is used;
* page.id is a UUID, so `${page.id}-agent` cannot collide with a human job.
*/
export function computeHistoryJob(
page: Pick<Page, 'id'>,
page: Pick<Page, 'id' | 'createdAt'>,
source: string,
// Epoch ms of the FIRST edit in the current burst (when the pending idle job
// was first armed). Used to enforce the max-wait ceiling so a continuous
// editing session cannot re-arm the trailing timer forever. `now` is injectable
// for tests; both default to a live clock / no ceiling when omitted.
burstStart?: number,
now: number = Date.now(),
now: number,
): { jobId: string; delay: number } {
const isAgent = source === 'agent';
const interval = isAgent ? IDLE_INTERVAL_AGENT : IDLE_INTERVAL_USER;
const maxWait = isAgent ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
let delay = interval;
if (burstStart !== undefined) {
// Time already elapsed since the burst's first edit; the snapshot must fire
// no later than `maxWait` after that, so shrink the trailing delay to the
// remaining budget (never negative, so BullMQ fires it promptly).
const remaining = burstStart + maxWait - now;
delay = Math.max(0, Math.min(interval, remaining));
}
return { jobId: page.id, delay };
const pageAge = now - new Date(page.createdAt).getTime();
const delay = isAgent
? 0
: pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
const jobId = isAgent ? `${page.id}-agent` : page.id;
return { jobId, delay };
}
@Injectable()
@@ -148,28 +132,6 @@ export class PersistenceExtension implements Extension {
// coalescing window" per document and OR it across all edits in the window,
// so the snapshot is marked 'agent' regardless of who wrote last.
private agentTouched: Map<string, boolean> = new Map();
// #370 — epoch ms of the FIRST edit in the current idle-flush burst. Keyed by
// documentName (like its sibling per-document maps above), NOT by page.id, so
// it can be cleaned in afterUnloadDocument alongside `contributors` /
// `agentTouched` / `intentionalClear` when the doc unloads — otherwise any page
// that was edited but never manually saved (the common case) would keep its
// entry forever and the Map would grow unbounded in this long-lived process.
// Set when the pending idle job is first armed (empty entry), read to enforce
// the max-wait ceiling in computeHistoryJob, and cleared on doc unload or when
// a manual save cancels the idle job so the next burst starts a fresh window.
//
// Single-process assumption (like `contributors` / `agentTouched` above): this
// lives only in THIS collab process's memory. A restart, or a page's ownership
// moving to another node, loses the burst-start marker. Consequence: a burst
// that spans the restart looks like a fresh burst to the surviving process, so
// its max-wait ceiling is re-anchored to the first post-restart edit — a single
// continuous session straddling a restart can therefore wait up to ~2× the cap
// for its idle snapshot (once for the lost pre-restart window, once for the new
// one). Bounded and benign (it only DELAYS a safety-net autosnapshot; manual
// saves are unaffected and the next quiet period always flushes), but the
// assumption and its consequence are recorded here so no one mistakes the
// in-memory marker for a durable, cross-process guarantee.
private idleBurstStart: Map<string, number> = new Map();
// #251 — per-document "intentional clear pending" flags. Keyed by
// documentName, value = expiry timestamp (ms). Set by onStateless when the
// client reports a deliberate clear; consumed once by the next
@@ -401,19 +363,20 @@ export class PersistenceExtension implements Extension {
//this.logger.debug('Contributors error:' + err?.['message']);
}
// #370 — boundary snapshot on ANY source transition. When the store
// flips the page's provenance (user↔agent↔git), pin the OUTGOING
// state as its own history version BEFORE the incoming source
// overwrites it. `page` still holds the OLD content/provenance here,
// so saveHistory(page) captures the pre-transition state tagged with
// its own source, kind='boundary'. The incoming content is snapshotted
// later by the debounced idle job. Skip if the page is effectively
// empty or if the latest existing snapshot already equals this state
// (the shared isDeepStrictEqual gate — avoids duplicates). Generalizing
// beyond the old user→agent special-case also covers git-sync for free.
// Approach A — boundary snapshot before the agent's first edit.
// When this store is the agent's and the page's currently persisted
// state was authored by a human, pin that human state as its own
// history version BEFORE the agent overwrites it. `page` still holds
// the OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
// if the prior state is already agent-authored (boundary already
// pinned on the user->agent transition), if the page is effectively
// empty, or if the latest existing snapshot already equals this human
// state (avoid duplicates).
if (
page.lastUpdatedSource &&
page.lastUpdatedSource !== lastUpdatedSource
lastUpdatedSource === 'agent' &&
page.lastUpdatedSource !== 'agent'
) {
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
@@ -421,13 +384,15 @@ export class PersistenceExtension implements Extension {
page.id,
{ includeContent: true, trx },
);
const baselineMissing =
const humanBaselineMissing =
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content);
if (!isEmptyParagraphDoc(page.content as any) && baselineMissing) {
if (
!isEmptyParagraphDoc(page.content as any) &&
humanBaselineMissing
) {
await this.pageHistoryRepo.saveHistory(page, {
contributorIds: page.contributorIds ?? undefined,
kind: 'boundary',
trx,
});
}
@@ -557,7 +522,7 @@ export class PersistenceExtension implements Extension {
{ jobId: `embed-${page.id}`, delay: EMBED_DEBOUNCE_MS },
);
await this.enqueuePageHistory(page, documentName, lastUpdatedSource);
await this.enqueuePageHistory(page, lastUpdatedSource);
}
// #402 — report the serialized size for the store histogram's size_bucket.
@@ -589,14 +554,6 @@ export class PersistenceExtension implements Extension {
return; // unrelated / malformed stateless message
}
// #370 — explicit "save a version" (human Cmd+S / agent save tool). Edit
// rights are already enforced by the readOnly reject above (a reader can't
// create a version), exactly as intentional-clear requires.
if (message?.type === SAVE_VERSION_MESSAGE_TYPE) {
await this.handleSaveVersion(data);
return;
}
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
this.intentionalClear.set(
@@ -605,160 +562,6 @@ export class PersistenceExtension implements Extension {
);
}
/**
* #370 persist an intentional version from the live in-memory ydoc.
*
* One stateless path serves BOTH the human and the agent; the tier is derived
* SERVER-SIDE from the signed connection actor ('agent' 'agent', anything
* else 'manual'), so the version type cannot be spoofed by the client. We
* take the fresh ydoc from the collab process memory and run it through the
* EXISTING store path first (so pages.content/ydoc reflect the exact content
* being versioned a REST endpoint would race the up-to-10s-stale page row),
* then snapshot it into page_history with the intentional kind.
*
* Promote-not-dup: if the latest history row already holds this exact content
* and it is an autosave (idle/boundary/legacy-null), upgrade its kind in place
* instead of duplicating a heavy content row; if it is already 'manual', it is
* a no-op (the client shows an "already saved" toast). Otherwise a fresh
* version row is written, popping the aggregated contributors from Redis.
*/
private async handleSaveVersion(data: onStatelessPayload): Promise<void> {
const { connection, document, documentName } = data;
const context = connection?.context;
const pageId = getPageId(documentName);
// Unforgeable: 'agent' only for a signed agent connection, else 'manual'.
const kind: PageHistoryKind =
context?.actor === 'agent' ? 'agent' : 'manual';
// Flush the live ydoc through the normal store path so the page row + ydoc
// hold exactly what we are about to version (also fires the idle enqueue we
// supersede below, plus any source-transition boundary). onStoreDocument
// only needs document/documentName/context.
await this.onStoreDocument({
document,
documentName,
context,
} as onStoreDocumentPayload);
let result:
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
| undefined;
// #370 F8-twin — the contributor set popped from Redis (destructive SPOP)
// must be restored if the version row does not durably land. The inner
// try/catch below only covers a throw INSIDE the callback; but executeTx
// COMMITS after the callback, so a commit-abort (serialization/deadlock/
// connection drop — the transient class the epic retries in the processor)
// rejects OUTSIDE the callback, after saveHistory already ran and the SPOP
// already happened, while the INSERT rolls back. onStateless does NOT retry,
// so an unrestored pop is a one-shot irrecoverable attribution loss (the
// processor got exactly this fix: poppedForRestore + an outer catch). We
// track the popped set here (keyed by the page UUID it was popped by — never
// the doc-name id, which may be a slugId, #260) and restore it in the outer
// catch. addContributors is an idempotent Redis SADD, so a double-restore is
// harmless. versionedPageId is also reused below to remove the superseded
// idle job by its real jobId (page.id).
let poppedForRestore: string[] = [];
let versionedPageId: string | undefined;
try {
await executeTx(this.db, async (trx) => {
const page = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
trx,
});
if (!page) return;
versionedPageId = page.id;
// Never version an effectively-empty page (mirrors the processor's
// first-history guard); there is nothing intentional to pin.
if (isEmptyParagraphDoc(page.content as any)) return;
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
page.id,
{ includeContent: true, trx },
);
if (
lastHistory &&
isDeepStrictEqual(lastHistory.content, page.content)
) {
// Content is already snapshotted. Promote-not-dup.
if (lastHistory.kind === 'manual') {
result = {
historyId: lastHistory.id,
kind: 'manual',
alreadySaved: true,
};
return;
}
await this.pageHistoryRepo.updateHistoryKind(
lastHistory.id,
kind,
trx,
);
result = { historyId: lastHistory.id, kind, alreadySaved: false };
return;
}
// Fresh version row. Pop the contributors aggregated since the last
// snapshot (SPOP); restore them if the write fails so they aren't lost.
const contributorIds = await this.collabHistory.popContributors(
page.id,
);
poppedForRestore = contributorIds;
try {
const saved = await this.pageHistoryRepo.saveHistory(page, {
contributorIds,
kind,
trx,
});
result = { historyId: saved.id, kind, alreadySaved: false };
} catch (err) {
await this.collabHistory.addContributors(page.id, contributorIds);
poppedForRestore = [];
throw err;
}
});
} catch (err) {
// A throw here means the tx did NOT commit (callback threw, or the commit
// itself failed and rolled back). If we popped contributors and the inner
// catch did not already restore them, restore now so attribution is not
// lost — onStateless has no retry to recover it. Restore by the page UUID
// the pop was keyed under (versionedPageId is always set before the pop).
if (poppedForRestore.length && versionedPageId) {
await this.collabHistory.addContributors(
versionedPageId,
poppedForRestore,
);
}
throw err;
}
// Housekeeping: this explicit version supersedes the page's pending idle
// autosnapshot, so cancel it and end the current idle burst so the next edit
// starts a fresh max-wait window. Remove the idle job by its REAL jobId
// (page.id UUID — computeHistoryJob arms it under page.id), not the raw
// doc-name id which may be a slugId for a `page.<slugId>` doc (#260), or the
// remove silently misses. The burst marker is keyed by documentName (like its
// sibling per-document maps), and is also cleaned in afterUnloadDocument.
if (versionedPageId) {
await this.historyQueue.remove(versionedPageId).catch(() => undefined);
}
this.idleBurstStart.delete(documentName);
if (result) {
document.broadcastStateless(
JSON.stringify({
type: 'version.saved',
historyId: result.historyId,
kind: result.kind,
alreadySaved: result.alreadySaved,
}),
);
}
}
async onChange(data: onChangePayload) {
const documentName = data.documentName;
const userId = data.context?.user?.id;
@@ -783,10 +586,6 @@ export class PersistenceExtension implements Extension {
this.contributors.delete(documentName);
this.agentTouched.delete(documentName);
this.intentionalClear.delete(documentName);
// #370 — drop the idle-burst marker with the other per-document maps so it
// cannot accumulate across the process lifetime for never-manually-saved
// pages. The pending idle job (if any) is a self-expiring BullMQ delayed job.
this.idleBurstStart.delete(documentName);
}
private consumeContributors(documentName: string): string[] {
@@ -818,80 +617,19 @@ export class PersistenceExtension implements Extension {
private async enqueuePageHistory(
page: Page,
documentName: string,
lastUpdatedSource: string,
): Promise<void> {
// #370 — trailing idle debounce with a max-wait ceiling. One pending idle
// job per page (jobId = page.id); on every store we remove the pending
// delayed job and re-add it, so the snapshot lands `delay` after edits go
// quiet rather than once per store (precedent: workspace.service.ts).
// remove() on a delayed job simply deletes it (0 if absent, no throw); if the
// job is already ACTIVE and the remove is a no-op, the add still de-dups and
// the processor's isDeepStrictEqual gate collapses the duplicate content.
//
// The FIRST arm of a burst records `burstStart`; computeHistoryJob shrinks
// the delay to the remaining max-wait budget from that point, so a continuous
// session cannot re-arm the trailing timer forever and starve the snapshot.
// A burst marker older than THIS TIER's max-wait means the previous idle job
// has already fired — start a fresh window instead of firing immediately on
// the next edit. Must use the SAME source-specific max-wait computeHistoryJob
// uses (agent 5m / user 10m): a hardcoded USER ceiling would leave an agent
// burst's marker stale for 5..10m, forcing delay=0 on every store in that
// window and writing one idle row per store — exactly the per-store bloat the
// debounce exists to prevent, on the continuous-agent path.
const maxWait =
lastUpdatedSource === 'agent' ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
const now = Date.now();
// Keyed by documentName (see the map declaration) so afterUnloadDocument can
// clean it; the queue jobId stays page.id (computeHistoryJob) as required.
let burstStart = this.idleBurstStart.get(documentName);
if (burstStart === undefined || now - burstStart >= maxWait) {
burstStart = now;
this.idleBurstStart.set(documentName, burstStart);
}
// Job id + delay arithmetic lives in the pure `computeHistoryJob` (see its
// doc comment for the agent-delay-0 / age-based-debounce invariants).
const { jobId, delay } = computeHistoryJob(
page,
lastUpdatedSource,
burstStart,
now,
Date.now(),
);
// remove-then-add trailing-debounce idiom, and its ONE race. We delete the
// pending delayed job and re-add it under the same jobId so the timer resets
// to the trailing edge of the burst. The race is the small window between
// these two awaits: if the delayed job's `delay` elapses in that gap it goes
// ACTIVE, and then:
// - remove() on an active/locked job is a no-op (BullMQ won't yank a job a
// worker holds), and our `.catch(() => undefined)` swallows that too; and
// - add() with a jobId that already exists (the now-active job's id) is
// DROPPED by BullMQ — a duplicate add is a no-op.
// So this store fails to re-arm the trailing job: the just-fired snapshot
// captured content up to the moment it went active, and THIS edit is left
// without a pending trailing job. It is bounded and self-healing — the NEXT
// store re-arms a fresh delayed job (the id is free again once the active job
// completes / removeOnComplete frees it), and the processor's
// isDeepStrictEqual gate collapses any content-identical duplicate. The only
// uncovered case is when the racing store was the LAST in the session: the
// tail edits made after the job went active get NO trailing snapshot until
// the next edit re-arms one. That is an acceptable safety-net gap (a manual
// Save, a source-transition boundary, or simply the next edit all still cover
// it), which is why the reviewer accepts documenting it here rather than
// adding a post-add "did the add actually arm a job?" re-check.
//
// NOTE — do NOT "unify" this with the neighbouring embed-debounce idiom
// (aiQueue.add of PAGE_CONTENT_UPDATED above): that one uses a STABLE jobId
// and NO remove(), relying purely on BullMQ coalescing a repeated add under
// the same id, because a re-embed only needs to eventually run once on the
// latest content and re-anchoring its delay on every keystroke is undesirable.
// THIS idiom deliberately removes-then-adds precisely to PUSH the delay back
// to the trailing edge on every store (a true debounce), which coalescing
// alone cannot do. Collapsing them would silently change the history cadence.
await this.historyQueue.remove(jobId).catch(() => undefined);
await this.historyQueue.add(
QueueJob.PAGE_HISTORY,
{ pageId: page.id, kind: 'idle' } as IPageHistoryJob,
{ pageId: page.id } as IPageHistoryJob,
{ jobId, delay },
);
}
@@ -1,116 +0,0 @@
import { jsonToText } from './collaboration.util';
// #502 READ contract: `jsonToText(json, { deterministic: true })` — the flat,
// machine-diffable text rendering behind getPage `format:"text"`. Block-per-line
// (`\n` separator), inline marks/anchors dropped, hardBreak -> `\n`, and non-text
// nodes replaced by STABLE placeholders (`[image]`, `[table RxC]`). Output
// stability across package versions IS the contract, so it is pinned by a
// snapshot below. The DEFAULT (no options) path is the search-index serializer
// and MUST be unchanged — asserted separately.
const doc = (...content: any[]) => ({ type: 'doc', content });
const para = (...content: any[]) => ({ type: 'paragraph', content });
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
describe('jsonToText — default (search index) behavior is unchanged', () => {
it('uses the `\\n\\n` block separator and drops non-text nodes to empty', () => {
const d = doc(para(text('alpha')), para(text('beta')));
expect(jsonToText(d)).toBe('alpha\n\nbeta');
});
it('an image contributes no text in the default (tsvector) mode', () => {
const d = doc(para(text('cap')), { type: 'image', attrs: { src: 's' } });
// No `[image]` placeholder leaks into the search index.
expect(jsonToText(d)).not.toContain('[image]');
});
});
describe('jsonToText — deterministic:true (getPage format:"text")', () => {
it('renders one line per block with `\\n` separators, marks dropped', () => {
const d = doc(
{ type: 'heading', attrs: { level: 2 }, content: [text('Title')] },
para(
text('hello ', [{ type: 'bold' }]),
text('world', [{ type: 'italic' }]),
),
);
expect(jsonToText(d, { deterministic: true })).toBe('Title\nhello world');
});
it('a hardBreak renders as a newline', () => {
const d = doc(para(text('a'), { type: 'hardBreak' }, text('b')));
expect(jsonToText(d, { deterministic: true })).toBe('a\nb');
});
it('an image node -> the stable `[image]` placeholder', () => {
const d = doc(para(text('before')), { type: 'image', attrs: { src: 's' } });
expect(jsonToText(d, { deterministic: true })).toBe('before\n[image]');
});
it('a table -> `[table RxC]` (rows x columns) and its cell text is NOT flattened in', () => {
const cell = (t: string) => ({
type: 'tableCell',
content: [para(text(t))],
});
const row = (...cells: any[]) => ({ type: 'tableRow', content: cells });
const table = {
type: 'table',
content: [
row(cell('CELLONE'), cell('CELLTWO'), cell('CELLTHREE')),
row(cell('CELLFOUR'), cell('CELLFIVE'), cell('CELLSIX')),
],
};
const d = doc(para(text('grid:')), table);
const out = jsonToText(d, { deterministic: true });
expect(out).toBe('grid:\n[table 2x3]');
expect(out).not.toContain('CELL'); // cell text is not flattened into the read
});
it('SNAPSHOT: a mixed document renders to a stable, deterministic string', () => {
const cell = (t: string) => ({
type: 'tableCell',
content: [para(text(t))],
});
const d = doc(
{ type: 'heading', attrs: { level: 1 }, content: [text('Config')] },
para(text('key = ', [{ type: 'bold' }]), text('value')),
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('one'))] },
{ type: 'listItem', content: [para(text('two'))] },
],
},
{ type: 'image', attrs: { src: 's' } },
{
type: 'table',
content: [{ type: 'tableRow', content: [cell('x'), cell('y')] }],
},
);
expect(jsonToText(d, { deterministic: true })).toMatchInlineSnapshot(`
"Config
key = value
one
two
[image]
[table 1x2]"
`);
});
it('SCENARIO: a config written as a code block reads back byte-identical (diff empty)', () => {
const config =
'export TICKET_LIFETIME=$2592000\nservers:\n - www.internal.host';
const d = doc({
type: 'codeBlock',
attrs: { language: 'yaml' },
content: [text(config)],
});
// The code block is one block; its text (dollars, bare domain, newlines) is
// preserved verbatim, so a read-as-text of a stored config diffs empty.
expect(jsonToText(d, { deterministic: true })).toBe(config);
});
});
@@ -66,15 +66,6 @@ describe('HistoryProcessor.process', () => {
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
// #370 F3 — the processor now serializes its find+save under a page-row lock
// via executeTx. A db whose transaction().execute(fn) runs fn with a trx stub
// drives the real executeTx() helper without a database.
const db = {
transaction: () => ({
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
}),
};
// WorkerHost's constructor reads `this.worker`; passing repos positionally
// matches the constructor and avoids the Nest DI container.
proc = new HistoryProcessor(
@@ -82,7 +73,6 @@ describe('HistoryProcessor.process', () => {
pageRepo as any,
collabHistory as any,
watcherService as any,
db as any,
notificationQueue as any,
generalQueue as any,
);
@@ -136,26 +126,15 @@ describe('HistoryProcessor.process', () => {
await proc.process(buildJob());
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
// #370 F3/F9 — the snapshot decision runs under a page-row lock. Pin the lock
// structurally so a refactor that drops withLock/trx (silently reintroducing
// the TOCTOU double-insert) turns this red. The tx stub is { __trx: true }.
expect(pageRepo.findById).toHaveBeenCalledWith(
PAGE_ID,
expect.objectContaining({ withLock: true, trx: { __trx: true } }),
);
// #370 F7 — addPageWatchers MUST receive the trx, or its FK-check runs on a
// separate connection and self-deadlocks against our FOR UPDATE. Asserting
// the trx arg here is exactly what would have caught that regression.
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
['u1', 'u2'],
PAGE_ID,
SPACE_ID,
WORKSPACE_ID,
{ __trx: true },
);
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
expect.objectContaining({ id: PAGE_ID }),
{ contributorIds: ['u1', 'u2'], kind: 'idle', trx: { __trx: true } },
{ contributorIds: ['u1', 'u2'] },
);
expect(generalQueue.add).toHaveBeenCalledWith(
QueueJob.PAGE_BACKLINKS,
@@ -207,48 +186,6 @@ describe('HistoryProcessor.process', () => {
]);
});
it('COMMIT failure (throw outside the tx callback) → contributors RESTORED', async () => {
// #370 F8 — a commit-time failure throws OUTSIDE the callback, so the inner
// try/catch does not run; the outer catch must restore the popped set (else a
// BullMQ retry writes an unattributed version). Use a db whose execute() runs
// the callback THEN throws, simulating a commit abort.
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: { type: 'doc', content: [] },
});
const commitFail = {
transaction: () => ({
execute: async (fn: (trx: any) => Promise<any>) => {
await fn({ __trx: true }); // callback succeeds (saveHistory ok)
throw new Error('commit aborted'); // ...but the COMMIT fails
},
}),
};
const procCommitFail = new HistoryProcessor(
pageHistoryRepo as any,
pageRepo as any,
collabHistory as any,
watcherService as any,
commitFail as any,
notificationQueue as any,
generalQueue as any,
);
jest
.spyOn(procCommitFail['logger'], 'error')
.mockImplementation(() => undefined);
await expect(procCommitFail.process(buildJob())).rejects.toThrow(
'commit aborted',
);
// The inner catch did NOT run (save succeeded), so only the outer catch can
// restore — assert it did.
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
'u1',
'u2',
]);
// And the post-snapshot queue work must NOT have run (we rethrew).
expect(generalQueue.add).not.toHaveBeenCalled();
});
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: { type: 'doc', content: [] },
@@ -19,9 +19,6 @@ import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
import { WatcherService } from '../../core/watcher/watcher.service';
import { isEmptyParagraphDoc } from '../collaboration.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
@@ -32,7 +29,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
private readonly watcherService: WatcherService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
) {
@@ -45,9 +41,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
try {
const { pageId } = job.data;
// Read the page WITHOUT a lock first, only to bail early on the two cheap
// no-write cases (page gone / empty first snapshot) without opening a
// transaction. The authoritative check-then-write happens locked below.
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
@@ -58,109 +51,40 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
return;
}
// #370 F3 — the snapshot decision (findPageLastHistory → saveHistory) must
// be serialized against manual-save/boundary writers, which run under a
// page-row lock in onStoreDocument. Without it, this processor and a
// concurrent manual-save each read the same lastHistory (MVCC), both see
// content != lastHistory, and both insert — producing two page_history rows
// with IDENTICAL content (one 'idle', one 'manual'), defeating
// promote-not-dup and the version-vs-autosave split. Taking the same
// page-row lock makes the second writer observe the first's committed row so
// the isDeepStrictEqual gate collapses the duplicate. Only the read+write
// is transacted; the post-snapshot queue work stays outside.
let contributorIds: string[] = [];
let snapshotWritten = false;
let lastHistoryContent: unknown;
// #370 F8 — the contributor set popped from Redis (destructive SPOP) must be
// restored if the snapshot does not durably land. The inner try/catch only
// covers a throw INSIDE the callback; a COMMIT failure (connection drop,
// serialization/deadlock abort on commit — the transient class the epic
// already retries) throws OUTSIDE it, rolling the snapshot back while the
// pop is already gone. We track the popped set here and restore it in the
// outer catch so a BullMQ retry re-attributes the version. addContributors
// is an idempotent Redis SADD, so a double-restore is harmless.
let poppedForRestore: string[] = [];
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true },
);
try {
await executeTx(this.db, async (trx) => {
const lockedPage = await this.pageRepo.findById(pageId, {
includeContent: true,
withLock: true,
trx,
});
if (!lockedPage) return;
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true, trx },
);
lastHistoryContent = lastHistory?.content;
if (!lastHistory && isEmptyParagraphDoc(lockedPage.content as any)) {
this.logger.debug(
`Skipping first history for page ${pageId}: empty content`,
);
return;
}
if (
lastHistory &&
isDeepStrictEqual(lastHistory.content, lockedPage.content)
) {
return; // already snapshotted at this content — nothing to write
}
contributorIds = await this.collabHistory.popContributors(pageId);
poppedForRestore = contributorIds;
try {
// Pass `trx` so the watcher insert's FK check (FOR KEY SHARE on
// pages[pageId]) runs on the SAME connection that already holds the
// FOR UPDATE lock from findById — otherwise it takes the FK lock on a
// separate pool connection and self-deadlocks against our own tx.
await this.watcherService.addPageWatchers(
contributorIds,
pageId,
lockedPage.spaceId,
lockedPage.workspaceId,
trx,
);
// #370 — every job on this queue is a trailing idle-flush autosnapshot.
await this.pageHistoryRepo.saveHistory(lockedPage, {
contributorIds,
kind: job.data.kind ?? 'idle',
trx,
});
snapshotWritten = true;
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(pageId, contributorIds);
poppedForRestore = [];
throw err;
}
});
} catch (err) {
// A throw here means the tx did NOT commit (callback threw, or the commit
// itself failed and rolled back). If we popped contributors and the inner
// catch did not already restore them, restore now so the retry keeps
// attribution. snapshotWritten is irrelevant: it is set before commit, so
// it can be true even when the commit rolled the snapshot back.
if (poppedForRestore.length) {
await this.collabHistory.addContributors(pageId, poppedForRestore);
}
throw err;
}
// No snapshot written (page vanished / empty-first / unchanged content) →
// clear the contributor set for the skip cases and stop.
if (!snapshotWritten) {
if (!lastHistoryContent && isEmptyParagraphDoc(page.content as any)) {
await this.collabHistory.clearContributors(pageId);
}
if (!lastHistory && isEmptyParagraphDoc(page.content as any)) {
this.logger.debug(
`Skipping first history for page ${pageId}: empty content`,
);
await this.collabHistory.clearContributors(pageId);
return;
}
{
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds = await this.collabHistory.popContributors(pageId);
try {
await this.watcherService.addPageWatchers(
contributorIds,
pageId,
page.spaceId,
page.workspaceId,
);
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(pageId, contributorIds);
throw err;
}
const mentions = extractMentions(page.content);
const pageMentions = extractPageMentions(mentions);
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
@@ -178,7 +102,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
);
});
if (contributorIds.length > 0 && lastHistoryContent) {
if (contributorIds.length > 0 && lastHistory?.content) {
await this.notificationQueue
.add(QueueJob.PAGE_UPDATED, {
pageId,
@@ -1,52 +0,0 @@
import { join } from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { readClientBuildVersion } from './client-version';
describe('readClientBuildVersion', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
const writeVersionJson = (content: string) =>
fs.writeFileSync(join(dir, 'version.json'), content);
it('returns the version from a valid version.json', () => {
writeVersionJson(JSON.stringify({ version: 'test-A' }));
expect(readClientBuildVersion(dir)).toBe('test-A');
});
it('trims surrounding whitespace in the version', () => {
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
});
it('returns "" when version.json is missing', () => {
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" on malformed JSON', () => {
writeVersionJson('{ not json');
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the version field is absent', () => {
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the version field is not a string', () => {
writeVersionJson(JSON.stringify({ version: 123 }));
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the path does not exist at all', () => {
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
});
});
@@ -1,36 +0,0 @@
import { join } from 'path';
import * as fs from 'node:fs';
/**
* Resolve the absolute path to the built client bundle directory
* (`apps/client/dist`) shipped into the runtime image.
*
* The `../` depth is anchored on THIS module's compiled location
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
* MUST share this single helper rather than duplicating the depth a copy in a
* module at a different depth would silently resolve to the wrong directory.
*/
export function resolveClientDistPath(): string {
return join(__dirname, '..', '..', '..', '..', 'client/dist');
}
/**
* Read the build version the client bundle was compiled with, from
* `<clientDistPath>/version.json` (written by the Vite build the single
* source of truth shared by the baked-in `APP_VERSION` global and this file).
*
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
* version) yields `''`. The caller treats an empty version as "unknown" and
* the whole version-coherence feature stays silently inert existing deploys
* without the file keep working unchanged.
*/
export function readClientBuildVersion(clientDistPath: string): string {
try {
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
const version = (JSON.parse(raw) as { version?: unknown }).version;
return typeof version === 'string' ? version.trim() : '';
} catch {
return '';
}
}
-1
View File
@@ -3,4 +3,3 @@ export * from './nanoid.utils';
export * from './file.helper';
export * from './constants';
export * from './security-headers';
export * from './client-version';
@@ -1,122 +1,42 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
/**
* In-memory run-stream registry (#184 phase 1.5, step-aligned retention #491). A
* durable agent run tees its SSE frames here (via
* `pipeUIMessageStreamToResponse({ consumeSseStream })`) so a LATE tab one that
* reloaded, or opened after the starter dropped can attach through
* `GET /ai-chat/runs/:chatId/stream`, be handed the TAIL past the step it already
* has persisted, and then follow the live tail as a normal streamer.
* In-memory run-stream registry (#184 phase 1.5). A durable agent run tees its
* SSE frames here (via `pipeUIMessageStreamToResponse({ consumeSseStream })`)
* so a LATE tab one that reloaded, or opened after the starter dropped can
* attach through `GET /ai-chat/runs/:chatId/stream`, replay the frames buffered
* so far, and then follow the live tail as a normal streamer.
*
* This is deliberately single-process and best-effort: it holds nothing the DB
* does not (the run + assistant row are the source of truth), so a process
* restart simply drops in-flight entries and the client falls back to its
* restore + degraded-poll path. The async `attach` return type is the seam for a
* future phase-2 cross-process backend (Redis) the interface does not change.
*
* #491 step-aligned retention (the OOM fix)
* The old registry buffered up to 32MB of raw SSE frames PER active run (V8 ~2×
* in memory) and, on attach, blasted the WHOLE buffer to the socket synchronously
* with no drain a handful of marathon runs on a 1GB container OOM'd. #491 caps
* the ring at a few MB (env-tunable, default 4MB) and keeps it there by ROTATING:
*
* - Every buffered frame is STAMPED with a step number at tee (see ingestFrame).
* Convention: the stamp of a frame is the number of `finish-step` parts seen
* BEFORE it (starting at 0). The finish-step frame itself carries the current
* value, THEN the counter increments. So a frame stamped `s` is the content of
* the (s+1)-th step 0-based step index `s` and the stamp aligns EXACTLY
* with `metadata.stepsPersisted`: a client whose persisted `stepsPersisted` is
* N has steps 0..N-1 on disk (and in its seed) and needs the tail `stamp >= N`.
*
* - The ring rotates ONLY on a CONFIRMED persist of step N
* (`confirmPersistedStep`), dropping frames with `stamp < N` (those steps are
* now on disk and a fresh client seed carries them). A NON-confirmed step is
* never rotated away, so a persist FAILURE just makes the ring cover MORE
* (auto-safe). This is the anti-inversion rule: a naive "rotate in .then()"
* that rotated after an UNwritten step would drop a step nobody has silent
* hole. Rotation is gated on a real, successful persist.
*
* - If the ring still exceeds its byte cap after rotation (a single fat step, or
* a lagging persist), the OLDEST frames are evicted to stay bounded. Evicting a
* not-yet-persisted frame opens a GAP: an attach whose N falls at or below an
* evicted step answers 204 and the client degrades to restore+poll. The gap is
* NOT sticky the coverage floor is recomputed from the ring, so a later
* persist that rotates past the holey steps clears it.
*
* attach numbering / coverage (the wire convention)
* The step marker N comes ONLY FROM THE CLIENT (a query param). The server never
* reads the row to derive N a server-side N from a stale seed would open a
* silent one-step hole. N is the client's persisted `stepsPersisted` (a COUNT):
* - the tail it needs = frames with `stamp >= N`;
* - coverage is OK `coverageFloor(entry) <= N`, where coverageFloor is the
* smallest step FULLY present in the ring (its smallest retained stamp, bumped
* by one when that leading step was only partially evicted by overflow). If
* `coverageFloor > N` the ring starts AFTER the client's frontier (a hole, or
* the client's seed simply lagged behind a rotation) 204 the client
* refetches (a larger N) and re-attaches.
* The N cutoff is applied in ALL branches, INCLUDING the finished-retained replay.
*
* same-tick invariants (unchanged, still load-bearing)
* invariant 1: only the matching run may mutate/observe an entry (runId check).
* invariant 2: retention deletes ONLY its own entry (a replacement may own the key).
* invariant 3: open() over a live entry mirrors the done-path (subscribers released).
* invariant 4: the tail SLICE + subscriber registration happen in ONE synchronous
* tick inside attach() no await between them so a concurrently
* ingested frame is EITHER in the snapshot (buffered before the sync
* block, and the just-added subscriber never sees it) OR fanned out to
* the paused subscriber's `pending` (ingested after) never both and
* never neither: no loss, no duplication. NOTE (#491): the controller
* now AWAITS the drain-respecting tail write BEFORE calling start(), so
* frames ingested during that await accumulate in `pending`; this is
* bounded by the subscriber cap (an overflow degrades start() to an
* end(), a 204-equivalent). It is the SYNCHRONOUS snapshot+registration
* not a same-tick start() that makes this correct.
* invariant 5: the controller wires close-cleanup BEFORE any write.
* invariant 6: no cross-run replay the `anchor` (the client's assistant row id)
* must match this run's assistant id, or a foreign run's transcript
* would be appended to the client's message.
*/
/** How long a finished entry is retained for late attach (replay + immediate end). */
export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000;
/**
* DEFAULT per-run replay ring cap (#491, down from 32MB). SSE frames carry
* UNcompacted tool outputs + framing overhead (×1.52 vs the persisted parts), so
* a "2–3 large reads + reasoning" step routinely blows past 2MB; 4MB comfortably
* holds a step or two of TAIL, which is all a resuming client needs (steps below
* its persisted frontier come from the seed, not the ring). The ring stays bounded
* because it rotates on every confirmed persist; this cap is only the ceiling for
* the un-persisted tail between rotations. Env-tunable via
* AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (bytes); a 0/invalid value falls back to this.
* Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204, and
* the client falls back to its restore + degraded-poll path, #430).
*
* Raised from 4MB to 32MB (#430): marathon autonomous runs (11-25 min observed)
* stream far more than 4MB of SSE frames, so a live disconnect mid-run would find
* an already-overflowed buffer and could only degrade-poll instead of re-attaching
* to the live tail. 32MB comfortably covers those runs while staying bounded.
*
* Memory cost: this is the WORST-CASE retained size PER ACTIVE run (the buffer is
* freed on finish + retention, or dropped immediately on overflow). With the small
* number of concurrent autonomous runs a single workspace realistically has, 32MB
* each is an acceptable ceiling; the overflow->204->degraded-poll fallback remains
* the backstop for anything larger, so correctness never depends on this bound.
*/
export const AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
export const RUN_STREAM_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
// 2× the ring cap: a just-written full-tail burst alone can never trip the
// per-subscriber cap (see controller); only a genuinely stalled socket can. This
// derivative relationship is preserved even when the ring cap is env-overridden.
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
/**
* A finish-step boundary frame is exactly `data: {"type":"finish-step"...}\n\n`
* (verified empirically against ai@6.0.207 each UI-message-stream part is a
* single `data: {json}\n\n` event, never split across `data:` lines, and `type`
* is always the first key). A prefix match is cheaper than JSON.parse-per-frame
* and has no false positives: a literal `"type":"finish-step"` inside a text
* delta is JSON-escaped (`\"type\":...`), and the frame would start with
* `data: {"type":"text-delta"` anyway.
*/
const FINISH_STEP_FRAME_PREFIX = 'data: {"type":"finish-step"';
/** Resolve the ring cap from the environment, falling back to the default. */
function resolveMaxBufferBytes(): number {
const raw = process.env.AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
if (!raw) return AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0
? Math.floor(parsed)
: AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
}
// 2x the replay cap: a just-written full-replay burst alone can never trip the
// per-subscriber cap (see controller); only a genuinely stalled socket can.
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES;
export interface RunStreamCallbacks {
onFrame: (frame: string) => void;
@@ -124,9 +44,6 @@ export interface RunStreamCallbacks {
}
export interface RunStreamAttachment {
// The synthetic `start` frame (carrying { runId, chatId }) followed by the
// buffered TAIL filtered to `stamp >= N`. The controller writes these to the
// socket in chunks respecting drain, then calls start().
replay: string[];
finished: boolean;
start(): void; // drain pending frames (order preserved) and go live
@@ -136,19 +53,14 @@ export interface RunStreamAttachment {
interface Subscriber extends RunStreamCallbacks {
started: boolean;
pending: string[];
// Byte size of `pending`, capped at the subscriber cap. `start()` is called in
// the SAME tick as `attach()` today, so `pending` never holds more than one
// microtask of frames — but the controller writes the (potentially large) tail
// respecting drain BEFORE start(), so a stalled socket can accumulate here; the
// cap is the structural backstop (an overflow degrades start() to an end()).
// Byte size of `pending`, capped at SUBSCRIBER_MAX_BUFFERED_BYTES. `start()` is
// called in the SAME tick as `attach()` today (see attach), so `pending` never
// holds more than one microtask of frames — but the async `attach` signature is
// a phase-2 seam: an await between attach and start would let a stalled paused
// subscriber buffer the WHOLE run here. The cap is the structural backstop.
pendingBytes: number;
overflowed: boolean;
pendingEnd: boolean;
// The client's step frontier N: this subscriber only receives frames with
// `stamp >= minStamp` (the tail past what it already persisted). Live frames
// always satisfy this (their stamp is the current, highest step), so it only
// filters the rare out-of-order below-frontier frame.
minStamp: number;
}
interface Entry {
@@ -156,20 +68,8 @@ interface Entry {
// The persisted assistant row id of this run (set at bind; undefined if the
// seed failed). Used by the attach anchor check (invariant 6).
assistantMessageId?: string;
// Parallel arrays: frames[i] is the SSE string, stamps[i] its step number.
frames: string[];
stamps: number[];
bytes: number;
// The running step counter used to stamp the NEXT frame (number of finish-step
// frames seen so far).
currentStamp: number;
// The highest confirmed `stepsPersisted`: frames with stamp < persistedFloor are
// on disk (safe to drop, never re-buffered). Monotonic (confirmPersistedStep).
persistedFloor: number;
// The highest stamp EVICTED by an overflow (unsafe) drop, -1 if none. Used to
// detect a partially-evicted leading step when computing the coverage floor.
overflowThroughStamp: number;
// Sticky-for-logging only: at least one unsafe (overflow) eviction happened.
overflowed: boolean;
finished: boolean;
subscribers: Set<Subscriber>;
@@ -180,10 +80,6 @@ interface Entry {
export class AiChatStreamRegistryService implements OnModuleDestroy {
private readonly logger = new Logger(AiChatStreamRegistryService.name);
private readonly entries = new Map<string, Entry>(); // key: chatId
// Env-resolved caps (per instance) so a deployment can tune the ceiling without
// a code change. The subscriber cap keeps the documented 2× relationship.
readonly maxBufferBytes = resolveMaxBufferBytes();
readonly subscriberMaxBufferedBytes = 2 * this.maxBufferBytes;
/**
* Register a fresh entry at the START of a run (before any frame), so a tab
@@ -209,11 +105,7 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
this.entries.set(chatId, {
runId,
frames: [],
stamps: [],
bytes: 0,
currentStamp: 0,
persistedFloor: 0,
overflowThroughStamp: -1,
overflowed: false,
finished: false,
subscribers: new Set<Subscriber>(),
@@ -258,34 +150,6 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
void pump();
}
/**
* Confirm that step `stepsPersisted` (a COUNT: steps 0..stepsPersisted-1) is on
* disk for this run, and ROTATE the ring: drop the buffered frames of those
* now-persisted steps (stamp < stepsPersisted). This is the ONLY thing that
* rotates the ring, and it is called ONLY after a genuinely SUCCESSFUL per-step
* persist (see ai-chat.service updateStreaming). A failed persist never calls
* it, so the ring covers more (auto-safe). Identity-checked (invariant 1) and
* monotonic (a stale lower count is ignored).
*/
confirmPersistedStep(
chatId: string,
runId: string,
stepsPersisted: number,
): void {
const entry = this.entries.get(chatId);
if (!entry || entry.runId !== runId) return;
if (!Number.isFinite(stepsPersisted) || stepsPersisted <= entry.persistedFloor)
return;
entry.persistedFloor = stepsPersisted;
// Clean rotation: drop the persisted steps from the head. These frames are on
// disk + carried by a fresh client seed, so this NEVER opens a gap.
while (entry.frames.length > 0 && entry.stamps[0] < stepsPersisted) {
entry.bytes -= Buffer.byteLength(entry.frames[0]);
entry.frames.shift();
entry.stamps.shift();
}
}
/**
* Terminate a run's entry from the OUTER catch of the stream method (a failure
* before/while wiring the pipe, so `done` will never arrive). Identity-checked
@@ -298,77 +162,36 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
}
/**
* Attach to a run's stream from the client's step frontier `n` (its persisted
* `stepsPersisted`). Async only for the phase-2 Redis seam the body runs
* synchronously so the tail SLICE and the subscriber registration happen in ONE
* tick with no await between them (invariant 4).
* Attach to a run's stream. Async only for the phase-2 Redis seam the body
* runs synchronously so the replay snapshot and the subscriber registration
* happen in ONE tick with no await between them (invariant 4): a frame ingested
* concurrently cannot slip into the gap and be lost or duplicated.
*
* Returns null (-> the caller answers 204) when:
* - there is no entry;
* - the `anchor` does not match this run's assistant id (invariant 6);
* - the ring does not cover the client's frontier (coverageFloor > n): a hole
* from overflow, or the client's seed simply lagged behind a rotation. The
* client then refetches (a larger n) and re-attaches.
*
* Otherwise the attachment's `replay` is a synthetic `start` frame (the run-fact
* on re-attach) followed by the buffered tail filtered to `stamp >= n`. For a
* FINISHED run this is replay-only (no subscriber) and ends after the replay
* with n = N_final that tail is just the run's `finish` frame, so the client
* closes the stream. For a LIVE run a paused subscriber is registered; the
* caller writes the replay (respecting drain) then calls start() to drain the
* pending frames and go live.
* - there is no entry, or it overflowed (replay is gone);
* - expect=live with an anchor that does not match this run's assistant id
* (invariant 6: a stripped tab must never replay a FOREIGN run's transcript);
* - the run finished and the caller did not expect a live tail.
* A finished run with expect=live yields a replay-only attachment (no
* subscriber registered). Otherwise a paused subscriber is registered and the
* caller replays `replay`, then calls start() to drain and go live.
*/
async attach(
chatId: string,
expectLive: boolean,
anchor: string | undefined,
// The client's persisted step frontier. `null` = a NOT-tail-aware client (no
// `n` query param) — a legacy/parameterless tab that expects the old
// "finished -> 204 -> poll" contract; distinct from `0` (a tail-aware client
// with nothing persisted yet).
n: number | null,
cb: RunStreamCallbacks,
): Promise<RunStreamAttachment | null> {
const entry = this.entries.get(chatId);
if (!entry) return null;
if (!entry || entry.overflowed) return null;
// Invariant 6: cross-run replay is forbidden. Before bind, assistantMessageId
// is undefined and mismatches any anchor -> 204 -> client restore+poll path.
if (anchor && entry.assistantMessageId !== anchor) return null;
// #491 regression guard (#137/#161 dup): a NOT-tail-aware client (no `n`)
// resuming a FINISHED run must 204 and poll — the old `finished && !expectLive`
// gate. Without this, a missing `n` collapsing to frontier 0 would serve the
// WHOLE tail of a finished, NON-rotated run (coverageFloor 0), and a
// parameterless client that never stripped its transcript would APPEND that
// full replay onto the steps it already shows -> duplicated text. A tail-aware
// client (n present, incl. n=0) still gets the tail past its frontier.
if (entry.finished && n === null) return null;
// A finished entry with NOTHING in the ring (aborted before the first frame,
// or fully overflowed) has no tail to deliver -> 204 -> the client polls.
if (entry.finished && entry.frames.length === 0) return null;
// A LIVE run with no `n` (legacy parameterless) replays from step 0 (the old
// behavior); a tail-aware client resumes from its frontier.
const frontier = n ?? 0;
const floor = this.coverageFloor(entry);
if (floor > frontier) {
this.logger.warn(
`run-stream attach gap for run=${entry.runId}: coverageFloor=${floor} ` +
`> client frontier=${frontier} -> 204 (client refetches + re-attaches)`,
);
return null;
}
const startFrame = this.buildStartFrame(chatId, entry.runId);
const sliceTail = (): string[] => {
const out: string[] = [startFrame];
for (let i = 0; i < entry.frames.length; i++) {
if (entry.stamps[i] >= frontier) out.push(entry.frames[i]);
}
return out;
};
if (entry.finished) {
if (expectLive && anchor && entry.assistantMessageId !== anchor) return null;
if (entry.finished && !expectLive) return null;
if (entry.finished && expectLive) {
// Replay-only: the run is done, no subscriber is registered.
return {
replay: sliceTail(),
replay: entry.frames.slice(),
finished: true,
start: () => undefined,
unsubscribe: () => undefined,
@@ -383,12 +206,15 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
pendingBytes: 0,
overflowed: false,
pendingEnd: false,
minStamp: frontier,
};
// Register + snapshot in the SAME synchronous block (invariant 4). No await
// separates them, so a concurrently ingested frame cannot be lost/duplicated.
entry.subscribers.add(sub);
const replay = sliceTail();
// Snapshot in the SAME synchronous block as the registration (invariant 4).
const replay = entry.frames.slice();
// CONTRACT: the caller MUST call start() in the SAME tick as this attach()
// returns — no await between them. While a subscriber is paused, every frame
// is buffered in sub.pending; a delayed start() lets a whole run accumulate
// there. The pendingBytes cap (see ingestFrame) is the structural backstop if
// that contract is ever broken (e.g. the phase-2 Redis await seam).
return {
replay,
finished: false,
@@ -437,83 +263,24 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
this.entries.clear();
}
/** The synthetic `start` frame the tail is prefixed with the source of the
* run-fact (runId/chatId) on re-attach. A `start` frame does NOT reset the
* client's message parts (ai@6.0.207 createStreamingUIMessageState), so it is
* safe to prepend even when the sliced tail begins mid-message. */
private buildStartFrame(chatId: string, runId: string): string {
return `data: ${JSON.stringify({
type: 'start',
messageMetadata: { runId, chatId },
})}\n\n`;
}
/**
* The smallest step FULLY present in the ring: its smallest retained stamp, or
* (when the leading step was only partially evicted by an overflow) one past it.
* When the ring is empty it is the current step (only the live tail is coming).
* An attach at frontier `n` is covered coverageFloor <= n.
*/
private coverageFloor(entry: Entry): number {
// Empty ring: only the live tail is coming. The floor is the current step,
// but never below persistedFloor — a confirmed persist can rotate the ring
// empty while currentStamp still lags a beat behind on another connection, so
// max() keeps the invariant STRUCTURAL (a client with n = persistedFloor is
// always covered) rather than timing-dependent.
if (entry.frames.length === 0)
return Math.max(entry.currentStamp, entry.persistedFloor);
const min = entry.stamps[0];
return entry.overflowThroughStamp >= min ? min + 1 : min;
}
/**
* Buffer (step-stamped) + fan-out a single frame. The stamp is the number of
* finish-step frames seen BEFORE this one; a finish-step frame carries the
* current value and THEN increments the counter (so its stamp equals the 0-based
* index of the step it closes). Only frames at/above persistedFloor are buffered
* (already-persisted steps are on disk); the ring is then trimmed to the byte
* cap, an unsafe eviction opening a gap. Fan-out is always live (filtered per
* subscriber by its frontier).
*/
/** Buffer + fan-out a single frame. See invariant/overflow semantics inline. */
private ingestFrame(entry: Entry, frame: string): void {
const size = Buffer.byteLength(frame);
const stamp = entry.currentStamp;
if (frame.startsWith(FINISH_STEP_FRAME_PREFIX)) {
entry.currentStamp = stamp + 1;
}
// Buffer for replay only if this step is not already persisted+rotated away.
if (stamp >= entry.persistedFloor) {
entry.bytes += Buffer.byteLength(frame);
if (!entry.overflowed) {
entry.frames.push(frame);
entry.stamps.push(stamp);
entry.bytes += size;
// Enforce the ring cap. Evicting a not-yet-persisted frame (stamp >=
// persistedFloor) opens a GAP; a leftover persisted frame (< floor) is a
// safe drop. Keep evicting until the ring is back under the cap.
while (entry.bytes > this.maxBufferBytes && entry.frames.length > 0) {
const evStamp = entry.stamps[0];
entry.bytes -= Buffer.byteLength(entry.frames[0]);
entry.frames.shift();
entry.stamps.shift();
if (evStamp >= entry.persistedFloor) {
if (evStamp > entry.overflowThroughStamp)
entry.overflowThroughStamp = evStamp;
if (!entry.overflowed) {
entry.overflowed = true;
this.logger.warn(
`run-stream ring overflow for run=${entry.runId}: an un-persisted ` +
`step was evicted to stay under ${this.maxBufferBytes}B; a late ` +
`attach at an evicted step will 204 until a later persist confirms`,
);
}
}
if (entry.bytes > RUN_STREAM_MAX_BUFFER_BYTES) {
// The crossing frame was already counted AND (below) fanned out; only the
// replay buffer is dropped. After overflow no more frames are buffered,
// but live fan-out continues.
entry.overflowed = true;
entry.frames = [];
this.logger.warn(
`run-stream buffer overflow for run=${entry.runId}; ` +
`late attach will 204 until the run ends`,
);
}
}
// Fan out live, filtered to each subscriber's frontier (a subscriber only
// wants the tail past the step it already persisted).
for (const sub of entry.subscribers) {
if (stamp < sub.minStamp) continue;
if (sub.started) {
try {
sub.onFrame(frame);
@@ -522,12 +289,12 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
}
} else {
sub.pending.push(frame);
sub.pendingBytes += size;
if (sub.pendingBytes > this.subscriberMaxBufferedBytes) {
sub.pendingBytes += Buffer.byteLength(frame);
if (sub.pendingBytes > SUBSCRIBER_MAX_BUFFERED_BYTES) {
// The paused subscriber's buffer overflowed — only possible if start()
// was delayed (the controller's drain-respecting tail write, or the
// phase-2 await seam). Drop it rather than buffer the whole run; on
// start() it degrades to an immediate end (a 204-equivalent).
// was delayed past the same-tick contract (the phase-2 await seam).
// Drop it rather than buffer the whole run; on start() it degrades to an
// immediate end (a 204-equivalent) instead of replaying a partial.
sub.overflowed = true;
sub.pending = [];
entry.subscribers.delete(sub);
@@ -1,27 +1,19 @@
import {
AiChatStreamRegistryService,
AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES,
RUN_STREAM_MAX_BUFFER_BYTES,
RUN_STREAM_RETAIN_FINISHED_MS,
SUBSCRIBER_MAX_BUFFERED_BYTES,
RunStreamCallbacks,
} from './ai-chat-stream-registry.service';
/**
* Unit tests for the in-memory run-stream registry (#184 phase 1.5, step-aligned
* retention #491). The registry is the whole of the resumable-transport contract:
* step-stamped retention, tail-only attach at the client's frontier N, the
* confirmed-persist ring rotation (and the anti-inversion rule), the memory bound,
* the overflow gap, paused -> live hand-off, retention, the anchor check
* (invariant 6), and the mirror-the-done-path replace semantics (invariant 3).
* Unit tests for the in-memory run-stream registry (#184 phase 1.5). The registry
* is the whole of the resumable-transport contract: replay ordering, paused ->
* live hand-off, overflow, retention, the anchor check (invariant 6), and the
* mirror-the-done-path replace semantics (invariant 3). Every enumerated case in
* the issue's task 1.5 has a test here.
*/
// Real ai@6 UI-message-stream SSE frames are `data: {json}\n\n`, one part each.
const sse = (part: Record<string, unknown>): string =>
`data: ${JSON.stringify(part)}\n\n`;
const finishStep = (): string => sse({ type: 'finish-step' });
const textDelta = (id: string, delta: string): string =>
sse({ type: 'text-delta', id, delta });
const finish = (): string => sse({ type: 'finish' });
// A ReadableStream whose frames the test pushes explicitly, plus close/error.
function makePushStream(): {
stream: ReadableStream<string>;
@@ -66,9 +58,6 @@ function collector(): {
};
}
// The tail past the synthetic start frame (replay[0] is always the start frame).
const tail = (replay: string[]): string[] => replay.slice(1);
describe('AiChatStreamRegistryService', () => {
const CHAT = 'chat-1';
let registry: AiChatStreamRegistryService;
@@ -82,21 +71,7 @@ describe('AiChatStreamRegistryService', () => {
registry.onModuleDestroy();
});
it('prepends a synthetic start frame carrying { runId, chatId }', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push('a');
await flush();
const c = collector();
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
const start = JSON.parse(att.replay[0].replace(/^data: /, '').trim());
expect(start.type).toBe('start');
expect(start.messageMetadata).toEqual({ runId: 'run-1', chatId: CHAT });
});
it('replays the buffered tail (from frontier 0) in arrival order (live attach)', async () => {
it('replays frames in arrival order (live attach)', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
@@ -106,13 +81,13 @@ describe('AiChatStreamRegistryService', () => {
await flush();
const c = collector();
const att = await registry.attach(CHAT, 'assist-1', 0, c.cb);
const att = await registry.attach(CHAT, false, undefined, c.cb);
expect(att).not.toBeNull();
expect(tail(att!.replay)).toEqual(['a', 'b', 'c']);
expect(att!.replay).toEqual(['a', 'b', 'c']);
expect(att!.finished).toBe(false);
});
it('late attach gets the buffered prefix as tail plus the live tail', async () => {
it('late attach gets the full prefix as replay plus the live tail', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
@@ -121,16 +96,17 @@ describe('AiChatStreamRegistryService', () => {
await flush();
const c = collector();
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
expect(tail(att.replay)).toEqual(['a', 'b']);
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
expect(att.replay).toEqual(['a', 'b']);
att.start();
// Live tail arrives after start().
src.push('c');
src.push('d');
await flush();
expect(c.frames).toEqual(['c', 'd']);
});
it('a paused subscriber receives frames buffered during pause in order, then live', async () => {
it('a paused subscriber receives frames buffered during pause in order, then live (no loss/reorder)', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
@@ -138,45 +114,81 @@ describe('AiChatStreamRegistryService', () => {
await flush();
const c = collector();
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
expect(tail(att.replay)).toEqual(['a']);
// Attach (paused). Frames that arrive BEFORE start() must queue, not drop.
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
expect(att.replay).toEqual(['a']);
src.push('b'); // arrives while paused -> pending
src.push('c');
await flush();
expect(c.frames).toEqual([]); // nothing delivered yet (paused)
att.start();
att.start(); // drains pending in order
expect(c.frames).toEqual(['b', 'c']);
src.push('d');
src.push('d'); // now live
await flush();
expect(c.frames).toEqual(['b', 'c', 'd']);
});
it('a run that finishes while a subscriber is paused ends it on start()', async () => {
registry.open(CHAT, 'run-1');
registry.bind(CHAT, 'run-1', 'assist-1', makePushStream().stream);
const c = collector();
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
// Terminate the run while the subscriber is still paused.
registry.abortEntry(CHAT, 'run-1');
expect(c.ended()).toBe(0); // paused: not ended yet
att.start();
expect(c.ended()).toBe(1); // start() drains + ends
});
it('anchor mismatch returns null (and null before bind sets assistantMessageId)', async () => {
it('finished + expect=live returns a replay WITHOUT registering a subscriber', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push('a');
src.push('b');
src.close();
await flush();
const c = collector();
const att = (await registry.attach(CHAT, true, undefined, c.cb))!;
expect(att.finished).toBe(true);
expect(att.replay).toEqual(['a', 'b']);
// No subscriber registered: start()/unsubscribe are no-ops and the entry has
// zero subscribers.
const entry = (registry as any).entries.get(CHAT);
expect(entry.subscribers.size).toBe(0);
att.start();
expect(c.frames).toEqual([]);
});
it('finished WITHOUT expect=live returns null', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push('a');
src.close();
await flush();
const c = collector();
expect(await registry.attach(CHAT, false, undefined, c.cb)).toBeNull();
});
it('anchor mismatch with expect=live returns null (and null before bind sets assistantMessageId)', async () => {
registry.open(CHAT, 'run-1');
const c = collector();
// Before bind: assistantMessageId is undefined -> mismatches any anchor.
expect(await registry.attach(CHAT, 'assist-1', 0, c.cb)).toBeNull();
expect(
await registry.attach(CHAT, true, 'assist-1', c.cb),
).toBeNull();
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push('a');
await flush();
// Wrong anchor -> null (cross-run replay forbidden, invariant 6).
expect(await registry.attach(CHAT, 'other-id', 0, c.cb)).toBeNull();
expect(await registry.attach(CHAT, true, 'other-id', c.cb)).toBeNull();
});
it('matching anchor attaches', async () => {
it('matching anchor with expect=live attaches', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
@@ -184,39 +196,73 @@ describe('AiChatStreamRegistryService', () => {
await flush();
const c = collector();
const att = await registry.attach(CHAT, 'assist-1', 0, c.cb);
const att = await registry.attach(CHAT, true, 'assist-1', c.cb);
expect(att).not.toBeNull();
expect(tail(att!.replay)).toEqual(['a']);
expect(att!.replay).toEqual(['a']);
});
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
it('overflow: attach returns null, but the LIVE subscriber keeps receiving (incl. the crossing frame)', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
const bad = collector();
const badAtt = (await registry.attach(CHAT, 'assist-1', 0, {
onFrame: () => {
throw new Error('boom');
},
onEnd: bad.cb.onEnd,
}))!;
badAtt.start();
// A live (started) subscriber attached before the flood.
const c = collector();
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
att.start();
const good = collector();
const goodAtt = (await registry.attach(CHAT, 'assist-1', 0, good.cb))!;
goodAtt.start();
src.push('a'); // bad throws on this frame -> ejected
src.push('b'); // good still receives both
// Cap-relative so it survives a buffer-cap change (#430): a quarter-cap frame
// means 5 frames comfortably exceed the replay cap; the last one crosses.
const chunk = 'x'.repeat(Math.floor(RUN_STREAM_MAX_BUFFER_BYTES / 4));
for (let i = 0; i < 5; i++) src.push(chunk + i);
await flush();
const entry = (registry as any).entries.get(CHAT);
expect(entry.subscribers.size).toBe(1);
expect(good.frames).toEqual(['a', 'b']);
expect(entry.overflowed).toBe(true);
expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES);
// The live subscriber received ALL 5 frames, including the crossing one.
expect(c.frames).toHaveLength(5);
expect(c.frames[4]).toBe(chunk + 4);
// A NEW attach after overflow gets null (replay buffer is gone).
const c2 = collector();
expect(await registry.attach(CHAT, false, undefined, c2.cb)).toBeNull();
});
it('open() over a LIVE entry ends started subscribers once; a late done never touches the new entry (invariant 3)', async () => {
it('a paused subscriber whose pending buffer overflows is dropped and ends on start(); other subscribers keep receiving', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
// A: paused (start() deliberately delayed to simulate the phase-2 await seam).
const a = collector();
const attA = (await registry.attach(CHAT, false, undefined, a.cb))!;
// B: live (started) — its delivery must be unaffected by A's overflow.
const b = collector();
const attB = (await registry.attach(CHAT, false, undefined, b.cb))!;
attB.start();
// Cap-relative so it survives a buffer-cap change (#430): a quarter-of-the-
// per-subscriber-cap frame means 5 frames exceed A's paused-pending cap while
// B streams every frame live.
const chunk = 'x'.repeat(Math.floor(SUBSCRIBER_MAX_BUFFERED_BYTES / 4));
for (let i = 0; i < 5; i++) src.push(chunk + i);
await flush();
const entry = (registry as any).entries.get(CHAT);
// A was dropped from the subscriber set on overflow; B (started) remains.
expect(entry.subscribers.size).toBe(1);
expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered
// B received every frame live (delivery unaffected by A's overflow).
expect(b.frames).toHaveLength(5);
// A's start() (arriving late) degrades to an immediate end, not a partial replay.
attA.start();
expect(a.frames).toEqual([]);
expect(a.ended()).toBe(1);
});
it('open() over a LIVE entry ends started subscribers exactly once and a late done does not touch the new entry (invariant 3)', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
@@ -224,20 +270,23 @@ describe('AiChatStreamRegistryService', () => {
await flush();
const c = collector();
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
att.start();
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
att.start(); // started subscriber on run-1
// run-2 starts on the same chat while run-1's tee is still reading.
registry.open(CHAT, 'run-2');
expect(c.ended()).toBe(1);
expect(c.ended()).toBe(1); // exactly one onEnd from the replace
const newEntry = (registry as any).entries.get(CHAT);
expect(newEntry.runId).toBe('run-2');
expect(newEntry.finished).toBe(false);
// The old tee now completes: its late done must NOT double-end nor delete the
// new entry.
src.push('b');
src.close();
await flush();
expect(c.ended()).toBe(1);
expect(c.ended()).toBe(1); // still exactly one
const still = (registry as any).entries.get(CHAT);
expect(still).toBe(newEntry);
expect(still.runId).toBe('run-2');
@@ -250,6 +299,7 @@ describe('AiChatStreamRegistryService', () => {
src.push('a');
await flush();
const entry = (registry as any).entries.get(CHAT);
// Frames were NOT ingested (bind bailed), assistantMessageId untouched.
expect(entry.frames).toEqual([]);
expect(entry.assistantMessageId).toBeUndefined();
});
@@ -260,276 +310,32 @@ describe('AiChatStreamRegistryService', () => {
const entry = (registry as any).entries.get(CHAT);
expect(entry.finished).toBe(false);
});
});
/**
* #491 step-stamped retention: the boundary detector, tail-only slicing at the
* client's frontier N, the confirmed-persist rotation (+ anti-inversion), the
* overflow gap, the memory bound, and the finished-retained tail. All observable
* against the REAL registry driven through open/bind/ingest.
*/
describe('AiChatStreamRegistryService step-aligned retention (#491)', () => {
const CHAT = 'chat-s';
let registry: AiChatStreamRegistryService;
beforeEach(() => {
registry = new AiChatStreamRegistryService();
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
});
afterEach(() => registry.onModuleDestroy());
const entryOf = () => (registry as any).entries.get(CHAT);
it('stamps frames by finish-step count, aligned with stepsPersisted', async () => {
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
// step 0 content, its finish-step, step 1 content, its finish-step, finish.
src.push(textDelta('t0', 'a')); // stamp 0
src.push(finishStep()); // stamp 0 (the finish-step frame carries the pre value)
src.push(textDelta('t1', 'b')); // stamp 1
src.push(finishStep()); // stamp 1
src.push(finish()); // stamp 2
await flush();
const e = entryOf();
expect(e.stamps).toEqual([0, 0, 1, 1, 2]);
expect(e.currentStamp).toBe(2);
});
it('does NOT treat a text delta that merely quotes "finish-step" as a boundary', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
// A model that literally types "type":"finish-step" — JSON-escaped in the frame.
src.push(textDelta('t0', '"type":"finish-step"'));
await flush();
expect(entryOf().currentStamp).toBe(0); // no false boundary
});
const bad = collector();
const badAtt = (await registry.attach(CHAT, false, undefined, {
onFrame: () => {
throw new Error('boom');
},
onEnd: bad.cb.onEnd,
}))!;
badAtt.start();
it('tail-only: attach at N slices frames with stamp >= N', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push(textDelta('t0', 'a')); // 0
src.push(finishStep()); // 0
src.push(textDelta('t1', 'b')); // 1
src.push(finishStep()); // 1
src.push(textDelta('t2', 'c')); // 2 (in-progress)
const good = collector();
const goodAtt = (await registry.attach(CHAT, false, undefined, good.cb))!;
goodAtt.start();
src.push('a'); // bad throws on this frame -> ejected
src.push('b'); // good still receives both
await flush();
const c = collector();
// Client persisted 2 steps -> wants the tail from step 2.
const att = (await registry.attach(CHAT, 'assist-1', 2, c.cb))!;
expect(tail(att.replay)).toEqual([textDelta('t2', 'c')]);
});
it('attach in the MIDDLE of a step (N between finish-steps) slices from that step', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push(textDelta('t0', 'a')); // 0
src.push(finishStep()); // 0
src.push(textDelta('t1', 'b1')); // 1
src.push(textDelta('t1', 'b2')); // 1 (still step 1, no finish-step yet)
await flush();
const c = collector();
const att = (await registry.attach(CHAT, 'assist-1', 1, c.cb))!;
// Step 0's frames are dropped from the tail; the whole in-progress step 1 is kept.
expect(tail(att.replay)).toEqual([textDelta('t1', 'b1'), textDelta('t1', 'b2')]);
});
it('rotates the ring ONLY on a confirmed persist (drops stamp < N)', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push(textDelta('t0', 'a')); // 0
src.push(finishStep()); // 0
src.push(textDelta('t1', 'b')); // 1
await flush();
expect(entryOf().stamps).toEqual([0, 0, 1]);
// Confirm step 0 persisted (stepsPersisted = 1) -> drop stamp < 1.
registry.confirmPersistedStep(CHAT, 'run-1', 1);
expect(entryOf().stamps).toEqual([1]);
expect(entryOf().persistedFloor).toBe(1);
});
it('persist FAILED but the ring still fits -> attach SUCCEEDS and the tail includes step N', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push(textDelta('t0', 'a')); // 0
src.push(finishStep()); // 0
src.push(textDelta('t1', 'b')); // 1 (step 1's persist FAILED -> no confirm)
await flush();
// No confirmPersistedStep for step 1: the ring still holds step 1.
const c = collector();
// Client's last successful persist was step 0 -> stepsPersisted = 1.
const att = await registry.attach(CHAT, 'assist-1', 1, c.cb);
expect(att).not.toBeNull();
expect(tail(att!.replay)).toEqual([textDelta('t1', 'b')]); // includes step 1
});
it('persist failed AND the ring overflowed past N -> 204 (coverage gap)', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
// Step 0: a fat step that blows past the cap with NO persist confirmation.
const big = 'x'.repeat(Math.floor(AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES / 2));
src.push(textDelta('t0', big)); // 0
src.push(textDelta('t0', big)); // 0
src.push(textDelta('t0', big)); // 0 -> overflow evicts stamp-0 frames
await flush();
const e = entryOf();
expect(e.overflowed).toBe(true);
expect(e.bytes).toBeLessThanOrEqual(registry.maxBufferBytes);
// A client at frontier 0 falls at/below an evicted step -> gap -> null.
const c = collector();
expect(await registry.attach(CHAT, 'assist-1', 0, c.cb)).toBeNull();
});
it('stale N (client seed lagged behind a rotation) -> 204; after a refetch (larger N) -> success', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push(textDelta('t0', 'a')); // 0
src.push(finishStep()); // 0
src.push(textDelta('t1', 'b')); // 1
src.push(finishStep()); // 1
src.push(textDelta('t2', 'c')); // 2
await flush();
// Server confirmed steps 0 and 1 -> rotate away stamp < 2.
registry.confirmPersistedStep(CHAT, 'run-1', 2);
expect(entryOf().stamps).toEqual([2]);
// A client whose seed still says stepsPersisted = 1 -> below minStamp -> 204.
const stale = collector();
expect(await registry.attach(CHAT, 'assist-1', 1, stale.cb)).toBeNull();
// It refetches (now stepsPersisted = 2) and re-attaches -> success.
const fresh = collector();
const att = await registry.attach(CHAT, 'assist-1', 2, fresh.cb);
expect(att).not.toBeNull();
expect(tail(att!.replay)).toEqual([textDelta('t2', 'c')]);
});
it('overflow gap CLEARS once a later persist rotates out the holey steps', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
const big = 'x'.repeat(Math.floor(AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES / 2));
src.push(textDelta('t0', big)); // 0
src.push(textDelta('t0', big)); // 0
src.push(finishStep()); // 0 (still stamp 0)
src.push(textDelta('t1', 'small')); // 1
src.push(finishStep()); // 1
src.push(textDelta('t2', 'c')); // 2
await flush();
expect(entryOf().overflowed).toBe(true);
// Late persist confirms steps 0..1 -> rotates out the holey step-0 frames.
registry.confirmPersistedStep(CHAT, 'run-1', 2);
// A client at frontier 2 is now cleanly covered (the hole was below it).
const c = collector();
const att = await registry.attach(CHAT, 'assist-1', 2, c.cb);
expect(att).not.toBeNull();
expect(tail(att!.replay)).toEqual([textDelta('t2', 'c')]);
});
it('finished-retained + N = N_final -> empty tail plus the finish frame', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push(textDelta('t0', 'a')); // 0
src.push(finishStep()); // 0
src.push(finish()); // 1 (N_final = 1)
src.close();
await flush();
// The last step's per-step persist confirmed stepsPersisted = 1.
registry.confirmPersistedStep(CHAT, 'run-1', 1);
const c = collector();
const att = (await registry.attach(CHAT, 'assist-1', 1, c.cb))!;
expect(att.finished).toBe(true);
// Empty step tail; just the finish frame so the client's SDK closes the stream.
expect(tail(att.replay)).toEqual([finish()]);
// No subscriber registered for a finished run.
expect(entryOf().subscribers.size).toBe(0);
});
it('#491 regression (#137/#161 dup): a PARAMETERLESS attach (n=null) to a finished NON-rotated run -> 204, but n=0 still gets the tail', async () => {
// A finished, non-rotated run: frames present, coverageFloor 0. A missing `n`
// (null — a legacy/parameterless tab that never stripped its transcript) must
// 204 -> poll, NOT receive the whole tail it would append (duplicate). A
// tail-aware client (n=0 present) still resumes.
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push(textDelta('t0', 'a')); // 0
src.push(finishStep()); // 0
src.push(finish()); // 1
src.close();
await flush();
// NOT rotated (no confirmPersistedStep) -> stamps[0]=0, coverageFloor=0.
// MUTATION-VERIFY: revert the `finished && n === null -> null` gate (default n
// to 0) and the parameterless attach below serves the full tail instead of 204.
expect(await registry.attach(CHAT, 'assist-1', null, collector().cb)).toBeNull();
// A tail-aware client at frontier 0 IS served (the distinction: null != 0).
const tailAware = await registry.attach(CHAT, 'assist-1', 0, collector().cb);
expect(tailAware).not.toBeNull();
expect(tailAware!.finished).toBe(true);
});
it('confirmPersistedStep is monotonic and identity-checked', async () => {
registry.open(CHAT, 'run-1');
const src = makePushStream();
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
src.push(textDelta('t0', 'a'));
src.push(finishStep());
src.push(textDelta('t1', 'b'));
await flush();
registry.confirmPersistedStep(CHAT, 'run-1', 1);
expect(entryOf().persistedFloor).toBe(1);
// A stale lower count is ignored.
registry.confirmPersistedStep(CHAT, 'run-1', 0);
expect(entryOf().persistedFloor).toBe(1);
// A foreign runId is ignored.
registry.confirmPersistedStep(CHAT, 'WRONG', 5);
expect(entryOf().persistedFloor).toBe(1);
});
it('MEMORY BOUND: 5 parallel marathon runs each stream well past 32MB; each ring stays <= the cap', async () => {
const cap = registry.maxBufferBytes;
const chats = ['m0', 'm1', 'm2', 'm3', 'm4'];
const srcs = chats.map((chat) => {
registry.open(chat, `run-${chat}`);
const s = makePushStream();
registry.bind(chat, `run-${chat}`, `assist-${chat}`, s.stream);
return s;
});
// ~256KB frames; 160 per chat = 40MB streamed each, well past the old 32MB.
// Interleave a finish-step every 8 frames so steps advance realistically. No
// persist confirmation -> the ONLY thing keeping memory bounded is the cap.
const frame = 'y'.repeat(256 * 1024);
for (let batch = 0; batch < 20; batch++) {
for (let i = 0; i < 8; i++) {
for (const s of srcs) s.push(textDelta('t', frame));
}
for (const s of srcs) s.push(finishStep());
await flush(); // drain the pump so queues never hold a whole run
}
let total = 0;
for (const chat of chats) {
const e = (registry as any).entries.get(chat);
expect(e.bytes).toBeLessThanOrEqual(cap);
total += e.bytes;
}
// Total retained across all 5 runs is bounded by 5x the per-run cap — the old
// registry would have retained ~5x40MB = 200MB here.
expect(total).toBeLessThanOrEqual(cap * chats.length);
const entry = (registry as any).entries.get(CHAT);
expect(entry.subscribers.size).toBe(1); // bad ejected, good remains
expect(good.frames).toEqual(['a', 'b']);
});
});
@@ -555,7 +361,7 @@ describe('AiChatStreamRegistryService retention timers', () => {
it('a finished entry is removed after the retention window', () => {
registry.open(CHAT, 'run-1');
registry.abortEntry(CHAT, 'run-1');
registry.abortEntry(CHAT, 'run-1'); // finalize -> retention armed
expect((registry as any).entries.get(CHAT)).toBeDefined();
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
expect((registry as any).entries.get(CHAT)).toBeUndefined();
@@ -563,18 +369,20 @@ describe('AiChatStreamRegistryService retention timers', () => {
it('retention deletes ONLY its own entry (invariant 2)', () => {
registry.open(CHAT, 'run-1');
registry.abortEntry(CHAT, 'run-1');
registry.abortEntry(CHAT, 'run-1'); // arm retention for entry A
// Simulate the race where the key was replaced without clearing A's timer.
const sentinel = { marker: true };
(registry as any).entries.set(CHAT, sentinel);
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
// A's timer saw entries.get(CHAT) !== A, so it did NOT delete the successor.
expect((registry as any).entries.get(CHAT)).toBe(sentinel);
});
it('open() over a retained entry clears its timer and the successor survives', () => {
registry.open(CHAT, 'run-1');
registry.abortEntry(CHAT, 'run-1');
registry.abortEntry(CHAT, 'run-1'); // retained, timer armed
const clearSpy = jest.spyOn(global, 'clearTimeout');
registry.open(CHAT, 'run-2');
registry.open(CHAT, 'run-2'); // must clear run-1's retain timer
expect(clearSpy).toHaveBeenCalled();
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
const entry = (registry as any).entries.get(CHAT);
@@ -8,12 +8,10 @@ import { SUBSCRIBER_MAX_BUFFERED_BYTES } from './ai-chat-stream-registry.service
import type { User, Workspace } from '@docmost/db/types/entity.types';
/**
* Wiring spec for the #184 phase 1.5 attach endpoint (tail-only #491)
* Wiring spec for the #184 phase 1.5 attach endpoint
* (`GET /ai-chat/runs/:chatId/stream`). Owner-gated via assertOwnedChat; the
* registry is mocked so this exercises ONLY the controller's tail-write/live/204/
* cleanup wiring against a fake raw socket. The attach signature is now
* `(chatId, anchor, n, cb)` the client hands its persisted step frontier `n`
* and its assistant row id `anchor`. Constructor order is (aiChatService,
* registry is mocked so this exercises ONLY the controller's replay/live/204/
* cleanup wiring against a fake raw socket. Constructor order is (aiChatService,
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo,
* streamRegistry, environment).
*/
@@ -88,8 +86,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
attach: jest.fn(
(
_chatId: string,
_live: boolean,
_anchor: string | undefined,
_n: number,
cb: RunStreamCallbacks,
) => {
capturedCb = cb;
@@ -158,7 +156,7 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
expect(res.hijack).not.toHaveBeenCalled();
});
it('threads anchor and the numeric frontier n through to the registry', async () => {
it('threads expect=live and anchor through to the registry', async () => {
const { controller, streamRegistry } = makeController({
chat: owned,
attachment: null,
@@ -167,8 +165,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
const { req } = makeReq();
await controller.attachRunStream(
'c1',
'live',
'anchor-1',
'2',
req,
res,
user,
@@ -176,44 +174,13 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
);
expect(streamRegistry.attach).toHaveBeenCalledWith(
'c1',
true,
'anchor-1',
2, // parsed to a number
expect.anything(),
);
});
it('#491: an ABSENT/invalid n passes null (not 0) so a finished run 204s (not-tail-aware)', async () => {
// Distinguishing a MISSING `n` from `n=0` is the #137/#161 dup guard: a
// parameterless/legacy tab must be handed null (-> the registry 204s a finished
// run) rather than frontier 0 (which would serve a finished non-rotated run's
// whole tail). MUTATION-VERIFY: revert to `Number(n) || 0` and this asserts 0.
const { controller, streamRegistry } = makeController({
chat: owned,
attachment: null,
});
for (const bad of [undefined, '', 'abc']) {
streamRegistry.attach.mockClear();
const { res } = makeRawRes();
const { req } = makeReq();
await controller.attachRunStream(
'c1',
undefined,
bad,
req,
res,
user,
workspace,
);
expect(streamRegistry.attach).toHaveBeenCalledWith(
'c1',
undefined,
null,
expect.anything(),
);
}
});
it('#491: a PRESENT n=0 passes 0 (tail-aware, distinct from absent)', async () => {
it('passes expect=false when the query is absent', async () => {
const { controller, streamRegistry } = makeController({
chat: owned,
attachment: null,
@@ -223,7 +190,7 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
await controller.attachRunStream(
'c1',
undefined,
'0',
undefined,
req,
res,
user,
@@ -231,8 +198,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
);
expect(streamRegistry.attach).toHaveBeenCalledWith(
'c1',
false,
undefined,
0,
expect.anything(),
);
});
@@ -278,8 +245,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
const { req } = makeReq();
await controller.attachRunStream(
'c1',
'live',
'a1',
'1',
req,
res,
user,
@@ -1,108 +0,0 @@
import { ForbiddenException } from '@nestjs/common';
import { AiChatController } from './ai-chat.controller';
import type { User, Workspace } from '@docmost/db/types/entity.types';
/**
* Wiring spec for the #491 delta-poll endpoint (`POST /ai-chat/messages/delta`).
* Owner-gated via assertOwnedChat (same gate as the other reads), NOT flag-gated.
* The run fact rides IN the delta response (no separate /run poll). Hand-rolled
* mocks no Nest graph, no DB. Constructor order: (aiChatService,
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo).
*/
describe('AiChatController POST /ai-chat/messages/delta (#491)', () => {
const user = { id: 'u1' } as User;
const workspace = { id: 'ws1' } as Workspace;
function makeController(opts: {
chat?: unknown;
delta?: { rows: unknown[]; cursor: string };
run?: unknown;
}) {
const aiChatRunService = {
getLatestForChat: jest.fn().mockResolvedValue(opts.run),
};
const aiChatRepo = {
findById: jest.fn().mockResolvedValue(opts.chat),
};
const aiChatMessageRepo = {
findByChatUpdatedAfter: jest
.fn()
.mockResolvedValue(opts.delta ?? { rows: [], cursor: 'C1' }),
};
const controller = new AiChatController(
{} as never,
aiChatRunService as never,
aiChatRepo as never,
aiChatMessageRepo as never,
{} as never,
{} as never,
);
return { controller, aiChatRunService, aiChatRepo, aiChatMessageRepo };
}
it('owner-gates: a chat the user does not own throws, never reaching the repo', async () => {
const { controller, aiChatMessageRepo, aiChatRunService } = makeController({
chat: { id: 'c1', creatorId: 'someone-else' },
});
await expect(
controller.getMessagesDelta({ chatId: 'c1' }, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(aiChatMessageRepo.findByChatUpdatedAfter).not.toHaveBeenCalled();
expect(aiChatRunService.getLatestForChat).not.toHaveBeenCalled();
});
it('returns { rows, cursor, run:{id,status} } with the run fact inlined', async () => {
const rows = [{ id: 'm1' }];
const { controller } = makeController({
chat: { id: 'c1', creatorId: 'u1' },
delta: { rows, cursor: 'C2' },
run: { id: 'r1', status: 'running', error: 'ignored', stepCount: 3 },
});
const res = await controller.getMessagesDelta(
{ chatId: 'c1', cursor: 'C1' },
user,
workspace,
);
expect(res).toEqual({
rows,
cursor: 'C2',
// ONLY id + status — never the whole run row.
run: { id: 'r1', status: 'running' },
});
});
it('run is null when the chat has never had a run', async () => {
const { controller } = makeController({
chat: { id: 'c1', creatorId: 'u1' },
run: undefined,
});
const res = await controller.getMessagesDelta(
{ chatId: 'c1' },
user,
workspace,
);
expect(res.run).toBeNull();
});
it('passes cursor through, defaulting a missing cursor to null (first poll)', async () => {
const { controller, aiChatMessageRepo } = makeController({
chat: { id: 'c1', creatorId: 'u1' },
});
await controller.getMessagesDelta({ chatId: 'c1' }, user, workspace);
expect(aiChatMessageRepo.findByChatUpdatedAfter).toHaveBeenCalledWith(
'c1',
'ws1',
null,
);
await controller.getMessagesDelta(
{ chatId: 'c1', cursor: 'CX' },
user,
workspace,
);
expect(aiChatMessageRepo.findByChatUpdatedAfter).toHaveBeenLastCalledWith(
'c1',
'ws1',
'CX',
);
});
});
@@ -35,7 +35,6 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
@@ -44,8 +43,6 @@ import {
AiChatRunHooks,
AiChatService,
AiChatStreamBody,
rowHasInlineParts,
hydrateAssistantParts,
} from './ai-chat.service';
import { AiChatRunService } from './ai-chat-run.service';
import { AiTranscriptionService } from './ai-transcription.service';
@@ -54,7 +51,6 @@ import {
ChatIdDto,
ExportChatDto,
GeneratePageTitleDto,
GetChatDeltaDto,
GetChatMessagesDto,
GetRunDto,
RenameChatDto,
@@ -67,47 +63,6 @@ import {
SUBSCRIBER_MAX_BUFFERED_BYTES,
} from './ai-chat-stream-registry.service';
import { startSseHeartbeat } from './sse-resilience';
/**
* Write the attach TAIL to the hijacked socket in chunks that RESPECT drain
* (#491): each `write()` that returns false (the kernel buffer is full) is awaited
* on the next 'drain' before continuing. The old code wrote the whole buffer
* synchronously, which with the pre-#491 32MB ring spiked memory (half the
* OOM). Bails immediately if the socket ended/errored mid-write. Frames that the
* paused registry subscriber buffers while this awaits are delivered by start().
*/
async function writeTailRespectingDrain(
raw: {
write(chunk: string): boolean;
writableEnded?: boolean;
destroyed?: boolean;
once(event: string, cb: () => void): unknown;
removeListener?(event: string, cb: () => void): unknown;
},
frames: string[],
): Promise<void> {
for (const frame of frames) {
if (raw.writableEnded || raw.destroyed) return;
const ok = raw.write(frame);
if (!ok) {
// Kernel buffer full — wait for drain (or an early close/error) before the
// next chunk, so a slow reader never forces the whole tail into memory.
// Remove ALL three listeners once any fires, so a many-chunk tail with
// repeated backpressure never leaks (MaxListenersExceededWarning).
await new Promise<void>((resolve) => {
const finish = (): void => {
raw.removeListener?.('drain', finish);
raw.removeListener?.('close', finish);
raw.removeListener?.('error', finish);
resolve();
};
raw.once('drain', finish);
raw.once('close', finish);
raw.once('error', finish);
});
}
}
}
import { EnvironmentService } from '../../integrations/environment/environment.service';
/**
@@ -132,39 +87,8 @@ export class AiChatController {
// production. Only touched on the resumable-stream (flag-on) path.
private readonly streamRegistry?: AiChatStreamRegistryService,
private readonly environment?: EnvironmentService,
// #492: reconstruct a #492 mid-run record's parts from the steps table before
// returning rows to the client / export. OPTIONAL so positional controller
// specs compile unchanged; when absent, hydration is skipped (old-era rows
// already carry inline parts, so nothing to reconstruct).
private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
) {}
/**
* Reconstruct parts for any assistant rows that don't carry them INLINE a
* #492 mid-run record whose per-step parts live in `ai_chat_run_steps` (the
* append-persist backend). Every FINISHED row (old-era + #492) and every old-era
* streaming snapshot already has inline `metadata.parts`, so the common path
* fetches NOTHING and returns the rows untouched; only an actively-streaming
* new-style row triggers the batch step fetch. Consumers (seed/poll/export) read
* `metadata.parts` off the returned rows exactly as before the era switch is
* invisible to them (reconstructRunParts contract).
*/
private async withReconstructedParts(
rows: AiChatMessage[],
workspaceId: string,
): Promise<AiChatMessage[]> {
if (!this.aiChatRunStepRepo) return rows;
const needy = rows.filter(
(r) => r.role === 'assistant' && !rowHasInlineParts(r),
);
if (needy.length === 0) return rows;
const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
needy.map((r) => r.id),
workspaceId,
);
return hydrateAssistantParts(rows, stepsByMessage);
}
/** List the requesting user's chats in this workspace (paginated). */
@HttpCode(HttpStatus.OK)
@Post('chats')
@@ -218,60 +142,11 @@ export class AiChatController {
@AuthWorkspace() workspace: Workspace,
) {
await this.assertOwnedChat(dto.chatId, user, workspace);
const page = await this.aiChatMessageRepo.findByChat(
return this.aiChatMessageRepo.findByChat(
dto.chatId,
workspace.id,
pagination,
);
// #492: reconstruct parts for any active new-style row so the client seed sees
// `metadata.parts` unchanged (a no-op for the finished rows that fill a page).
return {
...page,
items: await this.withReconstructedParts(page.items, workspace.id),
};
}
/**
* Delta poll (#491) the degraded-poll fallback's payload. Returns the chat's
* message rows changed since `cursor` (a DB-clock timestamp from the previous
* poll), a FRESH cursor, AND the current run fact `{ id, status } | null`. This
* replaces the old degraded poll that refetched ALL infinite-query pages (full
* parts) every 2.5s: the client seeds once and thereafter merges only the
* deltas by id (the overlap window guarantees repeats the merge is idempotent,
* see mergeById). The run fact rides IN the delta (a separate /run poll would
* double the poll QPS), so the client FSM gets the run's status on the same tick.
* Owner-gated via assertOwnedChat (same gate as the other read endpoints).
*/
@HttpCode(HttpStatus.OK)
@Post('messages/delta')
async getMessagesDelta(
@Body() dto: GetChatDeltaDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<{
rows: AiChatMessage[];
cursor: string;
run: { id: string; status: string } | null;
}> {
await this.assertOwnedChat(dto.chatId, user, workspace);
const { rows, cursor } =
await this.aiChatMessageRepo.findByChatUpdatedAfter(
dto.chatId,
workspace.id,
dto.cursor ?? null,
);
const run = await this.aiChatRunService.getLatestForChat(
dto.chatId,
workspace.id,
);
return {
// #492: the delta of an actively-streaming new-style row carries its parts
// reconstructed from the steps table, so the degraded poll shows persisted
// progress exactly as the pre-#492 full-row snapshot did.
rows: await this.withReconstructedParts(rows, workspace.id),
cursor,
run: run ? { id: run.id, status: run.status } : null,
};
}
/**
@@ -290,10 +165,8 @@ export class AiChatController {
@AuthWorkspace() workspace: Workspace,
): Promise<{ markdown: string }> {
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
const rows = await this.withReconstructedParts(
await this.aiChatMessageRepo.findAllByChat(dto.chatId, workspace.id),
// #492: an interrupted-but-still-active turn exports its persisted steps
// (reconstructed from the steps table) just like the pre-#492 full row did.
const rows = await this.aiChatMessageRepo.findAllByChat(
dto.chatId,
workspace.id,
);
const markdown = buildChatMarkdown({
@@ -333,13 +206,7 @@ export class AiChatController {
workspace.id,
)
: undefined;
// #492: reconnect to an IN-FLIGHT run reconstructs the projection row's parts
// from the steps table (the row itself carries only the step marker mid-run);
// a finished run's row already has inline parts, so this is a no-op.
const [hydrated] = message
? await this.withReconstructedParts([message], workspace.id)
: [undefined];
return { run, message: hydrated ?? null };
return { run, message: message ?? null };
}
/**
@@ -382,25 +249,19 @@ export class AiChatController {
}
/**
* Attach to a chat's live run stream from the client's step frontier (#184 phase
* 1.5, tail-only #491). A late/reloaded tab hands the server the step count it
* has PERSISTED (`n` = the seeded row's `metadata.stepsPersisted`) and its
* assistant row id (`anchor`); the registry answers with the TAIL past step `n`
* (a synthetic `start` frame + the buffered frames stamped >= n) and then the
* live tail. Owner-gated via assertOwnedChat (same gate as getRun). When there
* is nothing to resume no entry, a ring that does not cover the client's
* frontier (overflow gap, or the client's seed lagged a rotation), or an anchor
* that pins a DIFFERENT run (invariant 6) the endpoint answers 204, the ONLY
* "nothing to resume" signal the AI SDK's reconnect accepts (it maps 204 to a
* silent no-op); the client then refetches (a larger n) and re-attaches. With
* AI_CHAT_RESUMABLE_STREAM off the registry is never populated, so attach always
* 204s.
* Attach to a chat's live run stream (#184 phase 1.5). A late/reloaded tab
* replays the frames buffered so far and then follows the live tail as a normal
* streamer. Owner-gated via assertOwnedChat (same gate as getRun). When there is
* nothing to resume no entry, a finished run without expect=live, an
* overflowed buffer, or an anchor that pins a DIFFERENT run the endpoint
* answers 204, the ONLY "nothing to resume" signal the AI SDK's reconnect
* accepts (it maps 204 to a silent no-op). With AI_CHAT_RESUMABLE_STREAM off the
* registry is never populated, so attach always 204s.
*
* The step marker `n` comes ONLY from the client the server never reads the
* row to derive it, because a server-side n from a stale seed would open a
* silent one-step hole. The tail is written to the socket in CHUNKS respecting
* drain (writeTailRespectingDrain): the old code synchronously blasted the whole
* buffer, which with the old 32MB cap was half the OOM.
* `expect=live` opts into replaying a finished-but-retained run (safe only when
* the client stripped the streaming tail); `anchor` is the client's assistant
* row id, which must match this run's (invariant 6) or a foreign run's
* transcript would be replayed into the store.
*/
@SkipTransform()
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
@@ -408,49 +269,39 @@ export class AiChatController {
@Get('runs/:chatId/stream')
async attachRunStream(
@Param('chatId', new ParseUUIDPipe()) chatId: string,
@Query('expect') expect: string | undefined,
@Query('anchor') anchor: string | undefined,
@Query('n') n: string | undefined,
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<void> {
await this.assertOwnedChat(chatId, user, workspace); // same gate as getRun
// The client's persisted step frontier. #491: distinguish a MISSING/invalid `n`
// (null — a NOT-tail-aware, legacy/parameterless tab expecting the old
// "finished -> 204 -> poll" contract) from `n=0` (a tail-aware client with
// nothing persisted yet). Passing 0 for a missing `n` would serve a finished,
// non-rotated run's WHOLE tail and a parameterless client would append it onto
// the steps it already shows -> #137/#161 duplicate. null makes the registry
// 204 such a finished run (see attach); a tail-aware n=0 still resumes.
const frontier: number | null =
n === undefined || n === '' || !Number.isFinite(Number(n))
? null
: Math.max(0, Number(n));
// The per-subscriber backpressure cap tracks the (env-tunable) ring cap.
const subscriberCap =
this.streamRegistry?.subscriberMaxBufferedBytes ??
SUBSCRIBER_MAX_BUFFERED_BYTES;
let stopHeartbeat: () => void = () => undefined;
const attachment = await this.streamRegistry?.attach(chatId, anchor, frontier, {
onFrame: (frame) => {
// Backpressure guard: 2x the ring cap, so the initial tail burst alone
// can never trip it; only a genuinely stalled socket can.
try {
if (res.raw.writableLength > subscriberCap) {
res.raw.destroy(); // 'close' fires -> unsubscribe below
return;
const attachment = await this.streamRegistry?.attach(
chatId,
expect === 'live',
anchor,
{
onFrame: (frame) => {
// Backpressure guard: 2x the replay cap, so the initial replay burst
// alone can never trip it; only a genuinely stalled socket can.
try {
if (res.raw.writableLength > SUBSCRIBER_MAX_BUFFERED_BYTES) {
res.raw.destroy(); // 'close' fires -> unsubscribe below
return;
}
if (!res.raw.writableEnded) res.raw.write(frame);
} catch {
res.raw.destroy();
}
if (!res.raw.writableEnded) res.raw.write(frame);
} catch {
res.raw.destroy();
}
},
onEnd: () => {
stopHeartbeat();
if (!res.raw.writableEnded) res.raw.end();
},
},
onEnd: () => {
stopHeartbeat();
if (!res.raw.writableEnded) res.raw.end();
},
});
);
if (!attachment) {
res.status(204).send(); // the ONLY "nothing to resume" signal the SDK accepts
return;
@@ -479,16 +330,13 @@ export class AiChatController {
// deliberately NO Connection/Keep-Alive (hop-by-hop; Safari/HTTP2)
});
res.raw.flushHeaders?.();
// Write the tail in chunks respecting drain (not a synchronous blast, which
// was half the OOM). Frames the paused subscriber buffers meanwhile are
// drained by start() below; its cap is the backstop for a stalled socket.
await writeTailRespectingDrain(res.raw, attachment.replay);
for (const frame of attachment.replay) res.raw.write(frame);
if (attachment.finished) {
if (!res.raw.writableEnded) res.raw.end();
res.raw.end();
return;
}
stopHeartbeat = startSseHeartbeat(res.raw, 15_000);
attachment.start(); // drain pending accumulated during the tail write, go live
attachment.start(); // drain pending accumulated during replay, go live
} catch {
attachment.unsubscribe();
stopHeartbeat();
+21 -252
View File
@@ -22,7 +22,6 @@ import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
import { describeProviderError } from '../../integrations/ai/ai-error.util';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
@@ -190,11 +189,10 @@ export function stepBudgetWarning(stepNumber: number): string {
//
// `system` is the in-scope system prompt; we CONCATENATE so the original
// persona/context is preserved — a bare `system` override would REPLACE the
// whole system prompt for the step. `activatedTools` is a closure Set grown by
// loadTools and owned by the streaming loop; the caller seeds it from and
// persists it to the chat's metadata across turns (#490), but this function only
// READS the Set it is handed, so it stays a pure function of its arguments (not
// module-global).
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
// in (not module-global, not persisted) so this stays a pure function of its
// arguments.
//
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
@@ -519,12 +517,6 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
// constructions compile unchanged; Nest always injects the real singleton, so
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
private readonly aiChatRunService?: AiChatRunService,
// #492 append-persist: per-step INSERT into the lightweight steps table (the
// O(Σ steps) replacement for the O(n²) full-row `metadata.parts` rewrite).
// OPTIONAL so existing positional constructions (int-specs) compile unchanged;
// Nest injects the real singleton. When ABSENT the per-step path falls back to
// the pre-#492 full-row flush (no regression, only no WAL win).
private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
) {}
// #487: periodic reconcile timer (single-process phase 1). Started in
@@ -1121,34 +1113,8 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
chatId,
workspace.id,
);
// #492: HYDRATE needy assistant rows from the steps table BEFORE the replay
// map. A #492 mid-run assistant row carries only a step marker
// (metadata.parts:[]); its real per-step parts live in `ai_chat_run_steps`.
// The graceful terminal callbacks (onFinish/onError/onAbort -> flushAssistant)
// assemble the full inline parts, so a normally-ended turn already has them.
// But a HARD crash mid-run (SIGKILL/OOM) fires NO terminal callback, so the
// row stays parts:[]; without this, rowToUiMessage falls back to an empty
// text part and the partial tool-calls/results/text — durable in the steps
// table — would DROP OUT of the model's replay context (regressing #183
// step-granular durability for the model consumer). Mirrors the controller's
// withReconstructedParts EXACTLY (same needy predicate + hydration helper).
// Guarded on the optional repo: absent (positional test builds) degrades to
// the current behavior rather than crashing.
let replayHistory = oldHistory;
if (this.aiChatRunStepRepo) {
const needy = oldHistory.filter(
(r) => r.role === 'assistant' && !rowHasInlineParts(r),
);
if (needy.length > 0) {
const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
needy.map((r) => r.id),
workspace.id,
);
replayHistory = hydrateAssistantParts(oldHistory, stepsByMessage);
}
}
const uiMessages: Array<Omit<UIMessage, 'id'> & { id: string }> = [
...replayHistory.map(rowToUiMessage),
...oldHistory.map(rowToUiMessage),
{
id: 'pending-user',
role: 'user',
@@ -1187,9 +1153,7 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
// hint — confirm it against the persisted history (the preceding assistant
// turn must really be aborted/streaming) so a spoofed flag cannot inject the
// interrupt note onto an ordinary turn. The partial output the model needs is
// already in `messages`: a #492 mid-run row's per-step parts live only in the
// `ai_chat_run_steps` table and were hydrated into the replay history above,
// so the aborted assistant turn replays WITH its partial parts intact.
// already in `messages` (the aborted assistant row replays via findRecent).
// Append the new user turn (shape-only) so index -2 is the prior assistant.
const interrupted = isInterruptResume(
[...oldHistory, { role: 'user', status: null, metadata: null }],
@@ -1446,11 +1410,10 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
const baseTools = { ...external.tools, ...docmostTools };
// Deferred tool loading state (#332), scoped to THIS streaming loop:
// - `activatedTools` is a fresh closure Set per streamText call (not
// module-global), SEEDED from the chat's persisted metadata.activatedTools
// (#490, just below) so activation carries across turns. loadTools.execute
// adds to it; prepareAgentStep reads it to widen `activeTools` on the NEXT
// step; turn end persists it back.
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
// per streamText call, NOT module-global and NOT persisted, so a new turn
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
// widen `activeTools` on the NEXT step.
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
// external tool is loadable by its namespaced name. loadTools rejects any
@@ -1580,79 +1543,30 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
// Per-step (non-terminal) update: persist the finished steps the moment a
// step ends. Tolerant — a failed update is logged and swallowed so it never
// throws into the stream. Keeps status 'streaming'.
//
// #491: it now SIGNALS its outcome — the persisted `stepsPersisted` count on
// a CONFIRMED write, or null when it was skipped/failed. The caller rotates
// the run-stream registry ring ONLY on a non-null return (a confirmed
// persist), so a failed persist never rotates away a step nobody has (the
// classic inversion bug); a failure just makes the ring cover more.
const updateStreaming = async (): Promise<number | null> => {
if (!assistantId) return null;
const updateStreaming = async (): Promise<void> => {
if (!assistantId) return;
// Cheap short-circuit once the turn is finalized (see `finalized` below).
// The AUTHORITATIVE guard is `onlyIfStreaming` on the UPDATE: a late
// fire-and-forget step update could still be in flight on another pool
// connection when finalize runs, so the SQL `WHERE status='streaming'`
// (not this flag) is what prevents it clobbering the terminal row.
if (finalized) return null;
// The count derives from capturedSteps.length at THIS instant, so the
// returned value is EXACTLY the persisted `stepsPersisted` the ring rotates
// on (whether we take the append-persist path or the legacy fallback).
const stepsPersisted = capturedSteps.length;
if (finalized) return;
try {
if (this.aiChatRunStepRepo) {
// #492 APPEND-PERSIST: write only THIS finished step's parts to the
// steps table (O(step) WAL), then bump the row's CHEAP step marker —
// NO growing `metadata.parts` blob (that O(n²) full-row rewrite is
// exactly what this removes). The full `metadata.parts` is assembled
// once at finalize; a mid-run resume seed is reconstructed from the
// step rows (reconstructRunParts). The INSERT is idempotent
// (ON CONFLICT DO NOTHING), so a re-fired step never doubles the parts.
const index = stepsPersisted - 1;
if (index >= 0) {
const stepParts = assistantParts(
[capturedSteps[index]],
'',
partsCache,
);
await this.aiChatRunStepRepo.insertStep(
assistantId,
workspace.id,
index,
stepParts,
);
}
// Marker UPDATE: advance stepsPersisted + keep the toolTrace era marker
// (bumps updatedAt so the delta poll observes the step, and carries the
// frontier a resuming client attaches from). Scoped onlyIfStreaming so a
// late marker never clobbers the terminal finalize.
await this.aiChatMessageRepo.update(
assistantId,
workspace.id,
{ metadata: stepMarkerMetadata(stepsPersisted) },
{ onlyIfStreaming: true },
);
} else {
// Legacy fallback (no steps table wired — positional test builds): the
// pre-#492 full-row flush, so parts still land inline on the row.
const flushed = flushAssistant(capturedSteps, '', 'streaming', {
await this.aiChatMessageRepo.update(
assistantId,
workspace.id,
flushAssistant(capturedSteps, '', 'streaming', {
pageChanged,
partsCache,
});
await this.aiChatMessageRepo.update(
assistantId,
workspace.id,
flushed,
{ onlyIfStreaming: true },
);
}
return stepsPersisted;
}),
{ onlyIfStreaming: true },
);
} catch (err) {
this.logger.warn(
`Failed to update streaming assistant row: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
return null;
}
};
@@ -1828,24 +1742,7 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
// this point still recovers the step. Not awaited here (never block the
// stream), but SERIALIZED via stepUpdateChain so the writes commit in
// step order; updateStreaming is error-tolerant (logs + swallows).
// #491: on a CONFIRMED persist, rotate the run-stream registry ring to
// drop the now-on-disk steps (stamp < stepsPersisted). Gated on the
// resumable flag (same as open/bind) and identity-checked in the
// registry; a null return (skipped/failed) rotates NOTHING (auto-safe).
stepUpdateChain = stepUpdateChain.then(async () => {
const persisted = await updateStreaming();
if (
persisted != null &&
runId &&
this.environment?.isAiChatResumableStreamEnabled?.()
) {
this.streamRegistry?.confirmPersistedStep(
chatId,
runId,
persisted,
);
}
});
stepUpdateChain = stepUpdateChain.then(() => updateStreaming());
// #184: persist the run's progress (finished-step count). Fire-and-
// forget; the hook swallows its own errors.
if (runId) runHooks?.onStep?.(runId, capturedSteps.length);
@@ -2824,122 +2721,6 @@ export function rowToUiMessage(row: AiChatMessage): Omit<UIMessage, 'id'> & {
return { id: row.id, role, parts: parts as UIMessage['parts'] };
}
/**
* Cheap step-marker metadata for the #492 per-step UPDATE. Advances
* `stepsPersisted` (the resume attach frontier) and keeps the `toolTraceVersion`
* era marker, WITHOUT the growing `parts` blob (those live in the steps table
* now; the full `metadata.parts` is assembled once at finalize by flushAssistant).
* `parts: []` is kept for shape stability it reads as an empty inline-parts row,
* which is exactly the discriminator that routes reconstruction to the steps table.
*/
export function stepMarkerMetadata(
stepsPersisted: number,
): Record<string, unknown> {
return { parts: [], toolTraceVersion: 2, stepsPersisted };
}
/**
* Whether an assistant row already carries its full UI parts INLINE on the row
* (`metadata.parts`). TRUE for every FINISHED row old-era rows AND #492 rows,
* whose full parts are assembled once at finalize and for old-era streaming
* snapshots (the pre-#492 per-step full-row flush). FALSE for a #492 MID-RUN
* record, whose per-step parts live in the `ai_chat_run_steps` table. This is the
* era discriminator the reconstruct seam branches on no schema flag needed.
*/
export function rowHasInlineParts(row: { metadata?: unknown }): boolean {
const meta = (row.metadata ?? {}) as { parts?: unknown };
return Array.isArray(meta.parts) && meta.parts.length > 0;
}
/**
* Concatenate persisted per-step parts (in `stepIndex` order) into the turn's UI
* parts (#492). Reproduces EXACTLY what flushAssistant assistantParts would have
* written to `metadata.parts` for those finished steps, since each step row stored
* `assistantParts([step])` at persist time.
*/
export function assembleStepParts(
stepRows: ReadonlyArray<{ stepIndex: number; parts: unknown }>,
): UIMessage['parts'] {
const parts: Array<Record<string, unknown>> = [];
for (const step of [...stepRows].sort((a, b) => a.stepIndex - b.stepIndex)) {
if (Array.isArray(step.parts)) {
parts.push(...(step.parts as Array<Record<string, unknown>>));
}
}
return parts as UIMessage['parts'];
}
/**
* reconstructRunParts (#492) the single backend-switch seam. Given an assistant
* ROW and its persisted step rows, return the turn's UI `parts` + the persisted
* step count, reading from the ROW when it already carries inline parts (old-era
* records AND every finished record) and from the STEPS TABLE otherwise (a #492
* mid-run record). The higher-level consumers (attach seed, delta poll, export)
* route their rowparts through this / {@link hydrateAssistantParts}, so old and
* new records reconstruct identically WITHOUT the consumers branching on the era.
*/
export function reconstructRunParts(
row: { metadata?: unknown; content?: string | null },
stepRows: ReadonlyArray<{ stepIndex: number; parts: unknown }>,
): { parts: UIMessage['parts']; stepsPersisted: number } {
if (rowHasInlineParts(row)) {
const meta = row.metadata as {
parts: UIMessage['parts'];
stepsPersisted?: number;
};
return {
parts: meta.parts,
stepsPersisted:
typeof meta.stepsPersisted === 'number'
? meta.stepsPersisted
: stepRows.length,
};
}
if (stepRows.length > 0) {
return {
parts: assembleStepParts(stepRows),
stepsPersisted: stepRows.length,
};
}
// No inline parts and no step rows: an old-era seed / empty streaming row. Fall
// back to a single text part from `content` (mirrors rowToUiMessage).
return {
parts: textPart(row.content ?? '') as UIMessage['parts'],
stepsPersisted: 0,
};
}
/**
* Fill each assistant row's `metadata.parts` from its step rows when the row does
* not already carry them inline (a #492 mid-run record), so a consumer that reads
* `metadata.parts` off the RAW row (the client seed/poll, the Markdown export)
* sees the reconstructed parts with NO change to itself. Rows that already have
* inline parts (old-era + finished) and non-assistant rows pass through untouched.
* Pure: returns new row objects, never mutates the inputs.
*/
export function hydrateAssistantParts<
T extends { id: string; role?: string; metadata?: unknown },
>(
rows: ReadonlyArray<T>,
stepsByMessage: Map<
string,
ReadonlyArray<{ stepIndex: number; parts: unknown }>
>,
): T[] {
return rows.map((row) => {
if (row.role !== 'assistant' || rowHasInlineParts(row)) return row;
const steps = stepsByMessage.get(row.id);
if (!steps || steps.length === 0) return row;
return {
...row,
metadata: {
...((row.metadata ?? {}) as Record<string, unknown>),
parts: assembleStepParts(steps),
},
};
});
}
/**
* The persisted-row patch shape produced by {@link flushAssistant}. It is the
* SAME shape the assistant repo insert/update consume (content + toolCalls +
@@ -3110,18 +2891,6 @@ export function flushAssistant(
// `parts`). Old rows have no marker and the legacy { output } shape; a
// dual-shape query branches on this. Old rows are deliberately NOT migrated.
toolTraceVersion: 2,
// #491 STEP MARKER: the number of FINISHED steps whose parts are in THIS row,
// written by the SAME flush that builds `parts` (atomically — they are both
// derived from `finished`, so the marker can NEVER disagree with the persisted
// parts). This is the step-alignment anchor the resume stack builds on:
// - the registry rotates its retention ring only on a CONFIRMED persist of
// step N (commit 3);
// - attach slices the tail at "step > N" from the client's persisted seed.
// It is NOT `run.stepCount`: recordStep is fire-and-forget and NOT atomic with
// the parts write, so stepCount could race ahead of the persisted parts
// (seed↔marker drift). The in-progress trailing text (an error/abort partial,
// or a mid-stream flush) is NOT a finished step and is excluded from the count.
stepsPersisted: finished.length,
};
// finishReason: prefer an explicit one; else derive a sensible value from the
// terminal status (so onError/onAbort records keep their historical reason).
@@ -1,65 +0,0 @@
import { flushAssistant } from './ai-chat.service';
/**
* #491 STEP MARKER `metadata.stepsPersisted` is written by the SAME flush that
* builds `metadata.parts`, so the marker can never disagree with the persisted
* parts (the step-alignment anchor the resume stack builds on). These are
* PROPERTY tests: they assert the marker tracks the number of FINISHED steps for
* every flush shape.
*/
// A finished step carrying one line of text and one tool call/result.
function step(i: number) {
return {
text: `step ${i}`,
toolCalls: [
{ toolCallId: `c${i}`, toolName: 'getPage', input: { id: `p${i}` } },
],
toolResults: [
{ toolCallId: `c${i}`, toolName: 'getPage', output: { title: `T${i}` } },
],
};
}
describe('flushAssistant step marker (#491)', () => {
it('seed (no steps) → stepsPersisted 0', () => {
const f = flushAssistant([], '', 'streaming');
expect(f.metadata.stepsPersisted).toBe(0);
});
it('PROPERTY: stepsPersisted equals the number of FINISHED steps, for any N', () => {
for (let n = 0; n <= 6; n++) {
const steps = Array.from({ length: n }, (_, i) => step(i));
const f = flushAssistant(steps, '', 'streaming');
expect(f.metadata.stepsPersisted).toBe(n);
// ...and the parts actually contain those N steps' text (marker agrees with
// the persisted parts — the atomicity the whole design relies on).
const parts = f.metadata.parts as Array<Record<string, unknown>>;
const textParts = parts.filter((p) => p.type === 'text');
expect(textParts).toHaveLength(n);
}
});
it('an in-progress trailing partial does NOT increment the marker', () => {
// 2 finished steps + a partial (not-yet-finished) trailing text: the marker
// counts only the CONFIRMED step boundaries, not the partial.
const f = flushAssistant([step(0), step(1)], 'partial third step', 'error', {
error: 'boom',
});
expect(f.metadata.stepsPersisted).toBe(2);
// The partial text IS persisted in parts (so the user sees it), but it is not a
// counted step.
const parts = f.metadata.parts as Array<Record<string, unknown>>;
expect(parts[parts.length - 1]).toEqual({
type: 'text',
text: 'partial third step',
});
});
it('terminal completed flush counts all finished steps', () => {
const f = flushAssistant([step(0), step(1), step(2)], '', 'completed', {
finishReason: 'stop',
});
expect(f.metadata.stepsPersisted).toBe(3);
});
});
@@ -1,10 +1,4 @@
import {
IsISO8601,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
/** Identify a chat by id (workspace-scoped on the server). */
export class ChatIdDto {
@@ -43,24 +37,6 @@ export class GetChatMessagesDto {
cursor?: string;
}
/**
* Delta poll (#491): pull the chat's rows changed since `cursor` (a DB-clock
* timestamp from the previous poll) plus the current run fact the degraded-poll
* fallback's payload, replacing the full infinite-query refetch. Omit `cursor` on
* the first poll (returns just a fresh cursor to start the chain).
*/
export class GetChatDeltaDto {
@IsString()
chatId: string;
// ISO-8601 timestamp echoed from the previous poll's response. Validated as
// ISO-8601 (not a bare string): a malformed cursor would otherwise reach the
// `::timestamptz` cast in findByChatUpdatedAfter and 500 instead of a clean 400.
@IsOptional()
@IsISO8601()
cursor?: string;
}
/** Resolve the chat bound to a document (the page's most-recent owned chat). */
export class BoundChatDto {
@IsString()
@@ -1,193 +0,0 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { ApiKeyController } from './api-key.controller';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
/**
* Authorization contract for the /api-keys management surface.
*
* - A token cannot manage tokens (an api_key PRINCIPAL is 403 on every method)
* GitHub-PAT semantics closing post-revocation laundering.
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
* their own.
* - kill-switch OFF -> the surface 404s (looks like the feature does not exist).
*/
function makeController(over: any = {}) {
const apiKeyService = {
create: jest.fn().mockResolvedValue({
token: 'tok',
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
}),
revoke: jest.fn().mockResolvedValue(undefined),
...(over.apiKeyService ?? {}),
};
const apiKeyRepo = {
findAllInWorkspace: jest.fn().mockResolvedValue([]),
findByCreator: jest.fn().mockResolvedValue([]),
findById: jest.fn(),
...(over.apiKeyRepo ?? {}),
};
const workspaceAbility = {
createForUser: jest.fn().mockReturnValue({
can: (_a: any, _s: any) => over.canManage ?? false,
}),
...(over.workspaceAbility ?? {}),
};
const environmentService = {
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true),
...(over.environmentService ?? {}),
};
const auditService = { log: jest.fn() };
const controller = new ApiKeyController(
apiKeyService as any,
apiKeyRepo as any,
workspaceAbility as any,
environmentService as any,
auditService as any,
);
return {
controller,
apiKeyService,
apiKeyRepo,
workspaceAbility,
environmentService,
auditService,
};
}
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
const workspace = { id: 'ws-1' } as any;
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
const reqApiKey = () =>
({ raw: { authType: 'api_key', apiKeyId: 'k-self' }, ip: '1.2.3.4', socket: {} }) as any;
describe('ApiKeyController — a token cannot manage tokens', () => {
it('403 on create for an api_key principal', async () => {
const { controller, apiKeyService } = makeController();
await expect(
controller.create({ name: 'x' } as any, user, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
expect(apiKeyService.create).not.toHaveBeenCalled();
});
it('403 on list for an api_key principal', async () => {
const { controller } = makeController();
await expect(
controller.list(user, workspace, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('403 on revoke for an api_key principal', async () => {
const { controller } = makeController();
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('ApiKeyController — kill-switch OFF → 404', () => {
it('create/list/revoke all 404 when disabled', async () => {
const { controller } = makeController({ enabled: false });
await expect(
controller.create({ name: 'x' } as any, user, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
controller.list(user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('ApiKeyController — list scoping', () => {
it('admin (Manage on API) lists ALL workspace keys', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: true });
await controller.list(user, workspace, reqAccess());
expect(apiKeyRepo.findAllInWorkspace).toHaveBeenCalledWith('ws-1');
expect(apiKeyRepo.findByCreator).not.toHaveBeenCalled();
});
it('member lists ONLY their own keys', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: false });
await controller.list(user, workspace, reqAccess());
expect(apiKeyRepo.findByCreator).toHaveBeenCalledWith('u-1', 'ws-1');
expect(apiKeyRepo.findAllInWorkspace).not.toHaveBeenCalled();
});
});
describe('ApiKeyController — revoke scoping', () => {
it("member cannot revoke another user's key (403)", async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: false,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'someone-else',
workspaceId: 'ws-1',
});
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(ForbiddenException);
expect(apiKeyService.revoke).not.toHaveBeenCalled();
});
it('member CAN revoke their own key', async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: false,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'u-1',
workspaceId: 'ws-1',
});
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
});
it("admin CAN revoke another user's key", async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: true,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'someone-else',
workspaceId: 'ws-1',
});
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
});
it('404 when the key does not exist', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: true });
apiKeyRepo.findById.mockResolvedValue(undefined);
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('ApiKeyController — create emits audit + returns token once', () => {
it('logs API_KEY_CREATED and returns the token + computed expiry', async () => {
const { controller, auditService } = makeController();
const res = await controller.create(
{ name: 'ci' } as any,
user,
reqAccess(),
);
expect(res.token).toBe('tok');
expect(res.apiKey).toMatchObject({ id: 'k1', name: 'n' });
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({ event: 'api_key.created' }),
);
});
});
// Sanity: the CASL subject used is the workspace API subject.
it('uses WorkspaceCaslSubject.API with the Manage action', () => {
expect(WorkspaceCaslSubject.API).toBe('api_key');
expect(WorkspaceCaslAction.Manage).toBeDefined();
});
@@ -1,201 +0,0 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
Logger,
NotFoundException,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FastifyRequest } from 'fastify';
import { ApiKeyService } from './api-key.service';
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names';
@UseGuards(JwtAuthGuard)
@Controller('api-keys')
export class ApiKeyController {
private readonly logger = new Logger(ApiKeyController.name);
constructor(
private readonly apiKeyService: ApiKeyService,
private readonly apiKeyRepo: ApiKeyRepo,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
// Kill-switch OFF: the issuance surface disappears entirely (404), not a 403 —
// it must look like the feature does not exist.
private assertEnabled(): void {
if (!this.environmentService.isApiKeysEnabled()) {
throw new NotFoundException();
}
}
// A token cannot manage tokens (GitHub-PAT semantics): an api-key principal is
// refused on the management surface, so a leaked key cannot mint a replacement
// or revoke the keys that would lock it out (closes post-revocation laundering).
private rejectApiKeyPrincipal(req: FastifyRequest): void {
if ((req.raw as { authType?: string }).authType === 'api_key') {
throw new ForbiddenException('API keys cannot manage API keys');
}
}
private clientIp(req: FastifyRequest): string {
return req.ip ?? req.socket?.remoteAddress ?? 'unknown';
}
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
@Throttle({ [AUTH_THROTTLER]: { limit: 10, ttl: 60_000 } })
@Post('create')
async create(
@Body() dto: CreateApiKeyDto,
@AuthUser() user: User,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
// undefined -> service default (1 year); null -> unlimited; string -> Date.
const expiresAt =
dto.expiresAt === undefined
? undefined
: dto.expiresAt === null
? null
: new Date(dto.expiresAt);
const { token, key } = await this.apiKeyService.create(
user,
dto.name,
expiresAt,
);
this.auditService.log({
event: AuditEvent.API_KEY_CREATED,
resourceType: AuditResource.API_KEY,
resourceId: key.id,
});
// The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this
// build). No token material — the JWT is only ever returned in the response.
this.logger.log(
`API key created: id=${key.id} name=${JSON.stringify(
key.name,
)} actor=${user.id} expiresAt=${
key.expiresAt ? new Date(key.expiresAt).toISOString() : 'never'
} ip=${this.clientIp(req)}`,
);
// Return the token ONCE (never retrievable again) and the computed expiry so
// the caller/UI can surface "expires <date>" (the year-default time-bomb
// early-warning).
return {
token,
apiKey: {
id: key.id,
name: key.name,
expiresAt: key.expiresAt,
createdAt: key.createdAt,
},
};
}
@HttpCode(HttpStatus.OK)
@Post('list')
async list(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
const ability = this.workspaceAbility.createForUser(user, workspace);
// Admin (CASL Manage on API): every key in the workspace, attributed to its
// creator (closes "a leaked key of a departed employee is invisible"). A
// member sees only their own.
const keys = ability.can(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.API,
)
? await this.apiKeyRepo.findAllInWorkspace(workspace.id)
: await this.apiKeyRepo.findByCreator(user.id, workspace.id);
return keys.map((k) => ({
id: k.id,
name: k.name,
expiresAt: k.expiresAt,
lastUsedAt: k.lastUsedAt,
createdAt: k.createdAt,
creator: k.creator,
}));
}
@HttpCode(HttpStatus.OK)
@Post('revoke')
async revoke(
@Body() dto: RevokeApiKeyDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
const key = await this.apiKeyRepo.findById(dto.id, workspace.id);
// Uniform 404 for a missing/already-revoked key regardless of who asks — no
// existence oracle for keys the caller may not own.
if (!key) {
throw new NotFoundException();
}
const ability = this.workspaceAbility.createForUser(user, workspace);
const canManageAll = ability.can(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.API,
);
// Admin may revoke any key in the workspace; a member only their own.
if (!canManageAll && key.creatorId !== user.id) {
throw new ForbiddenException();
}
await this.apiKeyService.revoke(key.id, workspace.id);
this.auditService.log({
event: AuditEvent.API_KEY_DELETED,
resourceType: AuditResource.API_KEY,
resourceId: key.id,
});
this.logger.log(
`API key revoked: id=${key.id} name=${JSON.stringify(
key.name,
)} actor=${user.id} ip=${this.clientIp(req)}`,
);
return { success: true };
}
}
@@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
import { ApiKeyController } from './api-key.controller';
import { TokenModule } from '../auth/token.module';
// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator
// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router).
// DatabaseModule (global) provides ApiKeyRepo/UserRepo/WorkspaceRepo; CaslModule
// (global) provides WorkspaceAbilityFactory; TokenModule provides TokenService
// (the no-exp api-key signer). ApiKeyService is exported so AuthModule (for
// jwt.strategy) and McpModule (for the /mcp router) can inject it directly,
// replacing the absent EE `ee/api-key` dynamic require.
@Module({
imports: [TokenModule],
controllers: [ApiKeyController],
providers: [ApiKeyService],
exports: [ApiKeyService],
})
export class ApiKeyModule {}
@@ -1,299 +0,0 @@
import { UnauthorizedException } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
import { JwtType } from '../auth/dto/jwt-payload';
/**
* Security contract for ApiKeyService.validate the single validator shared by
* jwt.strategy (REST) and the /mcp Bearer router.
*
* Invariants under test:
* - Anti-enumeration: every DEFINITE deny (missing/revoked/expired row, disabled
* user, workspace mismatch, kill-switch off) throws the SAME bare
* UnauthorizedException an agent cannot distinguish expired from revoked.
* - deny-on-decision / 5xx-on-infra: an UNEXPECTED (infra) error PROPAGATES as
* itself ( 5xx), never masked as a 401.
* - Expiry is read from the ROW, never a JWT exp claim.
* - No validate cache (each call re-reads the row immediate revocation).
*/
const APP_SECRET = 'secret';
function makeDeps(over: Partial<Record<string, any>> = {}) {
const apiKeyRepo = {
findById: jest.fn(),
insert: jest.fn(),
softDelete: jest.fn(),
touchLastUsed: jest.fn().mockResolvedValue(undefined),
...(over.apiKeyRepo ?? {}),
};
const userRepo = {
findById: jest.fn(),
...(over.userRepo ?? {}),
};
const workspaceRepo = {
findById: jest.fn().mockResolvedValue({ id: 'ws-1' }),
...(over.workspaceRepo ?? {}),
};
const tokenService = {
generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'),
...(over.tokenService ?? {}),
};
const environmentService = {
isApiKeysEnabled: jest.fn().mockReturnValue(true),
getApiKeysEnabledRaw: jest.fn().mockReturnValue(undefined),
getAppSecret: jest.fn().mockReturnValue(APP_SECRET),
...(over.environmentService ?? {}),
};
const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)(
apiKeyRepo,
userRepo,
workspaceRepo,
tokenService,
environmentService,
);
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService };
}
const payload = (over: Record<string, any> = {}) => ({
sub: 'u-1',
workspaceId: 'ws-1',
apiKeyId: 'key-1',
type: JwtType.API_KEY,
...over,
});
const activeRow = (over: Record<string, any> = {}) => ({
id: 'key-1',
name: 'k',
creatorId: 'u-1',
workspaceId: 'ws-1',
expiresAt: null,
lastUsedAt: null,
deletedAt: null,
...over,
});
const activeUser = (over: Record<string, any> = {}) => ({
id: 'u-1',
workspaceId: 'ws-1',
deactivatedAt: null,
deletedAt: null,
isAgent: false,
...over,
});
describe('ApiKeyService.validate', () => {
it('returns { user, workspace } for a valid active key', async () => {
const { service, apiKeyRepo, userRepo } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow());
userRepo.findById.mockResolvedValue(activeUser());
const res = await service.validate(payload() as any);
expect(res.user).toMatchObject({ id: 'u-1' });
expect(res.workspace).toMatchObject({ id: 'ws-1' });
// Loads isAgent so downstream provenance does not silently degrade.
expect(userRepo.findById).toHaveBeenCalledWith('u-1', 'ws-1', {
includeIsAgent: true,
});
});
// --- Anti-enumeration: every deny is the SAME bare 401 -------------------
const denyCases: Array<[string, (d: ReturnType<typeof makeDeps>) => void]> = [
[
'missing/orphaned row',
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
],
[
'revoked (soft-deleted → findById returns undefined)',
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
],
[
'expired (expires_at in the past)',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(
activeRow({ expiresAt: new Date(Date.now() - 1000) }),
);
d.userRepo.findById.mockResolvedValue(activeUser());
},
],
[
'disabled user',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
d.userRepo.findById.mockResolvedValue(
activeUser({ deactivatedAt: new Date() }),
);
},
],
[
'user not found',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
d.userRepo.findById.mockResolvedValue(undefined);
},
],
[
'creator/sub mismatch',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(activeRow({ creatorId: 'other' }));
d.userRepo.findById.mockResolvedValue(activeUser());
},
],
[
'workspace row missing',
(d) => {
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
d.userRepo.findById.mockResolvedValue(activeUser());
d.workspaceRepo.findById.mockResolvedValue(undefined);
},
],
[
'kill-switch OFF',
(d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false),
],
[
'malformed payload (missing apiKeyId)',
() => undefined,
],
];
it.each(denyCases)(
'throws a bare UnauthorizedException with NO message for: %s',
async (label, arrange) => {
const deps = makeDeps();
arrange(deps);
const p =
label === 'malformed payload (missing apiKeyId)'
? (payload({ apiKeyId: undefined }) as any)
: (payload() as any);
const err = await deps.service.validate(p).catch((e) => e);
expect(err).toBeInstanceOf(UnauthorizedException);
// Anti-enumeration: identical, class-less message for every failure mode.
expect(err.message).toBe('Unauthorized');
},
);
it('kill-switch OFF denies BEFORE any DB read', async () => {
const { service, apiKeyRepo, environmentService } = makeDeps();
environmentService.isApiKeysEnabled.mockReturnValue(false);
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(apiKeyRepo.findById).not.toHaveBeenCalled();
});
// --- Infra error propagates (5xx), NOT masked as 401 ---------------------
it('PROPAGATES an infra (DB) error instead of masking it as 401', async () => {
const { service, apiKeyRepo } = makeDeps();
const boom = new Error('connection terminated');
apiKeyRepo.findById.mockRejectedValue(boom);
await expect(service.validate(payload() as any)).rejects.toBe(boom);
});
// --- No cache: revocation is immediate on the next call ------------------
it('re-reads the row on every call (no validate cache → immediate revocation)', async () => {
const { service, apiKeyRepo, userRepo } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow());
userRepo.findById.mockResolvedValue(activeUser());
await service.validate(payload() as any);
// Now the key is revoked (row invisible) — the very next call denies.
apiKeyRepo.findById.mockResolvedValue(undefined);
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(apiKeyRepo.findById).toHaveBeenCalledTimes(2);
});
// --- last_used_at is throttled + fire-and-forget -------------------------
it('touches last_used_at when stale and skips when fresh', async () => {
const { service, apiKeyRepo, userRepo } = makeDeps();
userRepo.findById.mockResolvedValue(activeUser());
// Stale (never used) -> touch.
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
await service.validate(payload() as any);
expect(apiKeyRepo.touchLastUsed).toHaveBeenCalledTimes(1);
// Fresh (used 1 minute ago) -> skip.
apiKeyRepo.touchLastUsed.mockClear();
apiKeyRepo.findById.mockResolvedValue(
activeRow({ lastUsedAt: new Date(Date.now() - 60_000) }),
);
await service.validate(payload() as any);
expect(apiKeyRepo.touchLastUsed).not.toHaveBeenCalled();
});
it('a failing last_used_at touch never fails the request', async () => {
const { service, apiKeyRepo, userRepo } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
userRepo.findById.mockResolvedValue(activeUser());
apiKeyRepo.touchLastUsed.mockRejectedValue(new Error('write failed'));
await expect(service.validate(payload() as any)).resolves.toMatchObject({
user: { id: 'u-1' },
});
});
});
describe('ApiKeyService.create (mint-then-insert, no exp)', () => {
it('mints the JWT BEFORE inserting the row and returns the token once', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
const order: string[] = [];
tokenService.generateApiToken.mockImplementation(async () => {
order.push('mint');
return 'tok';
});
apiKeyRepo.insert.mockImplementation(async (row: any) => {
order.push('insert');
return { ...row, createdAt: new Date() };
});
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
const res = await service.create(user, 'my key');
expect(order).toEqual(['mint', 'insert']);
expect(res.token).toBe('tok');
// The id is generated before minting and reused for the row.
const mintArg = tokenService.generateApiToken.mock.calls[0][0];
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
expect(mintArg.apiKeyId).toBe(insertArg.id);
});
it('applies the 1-year default when expiresAt is undefined', async () => {
const { service, apiKeyRepo } = makeDeps();
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
await service.create(user, 'k');
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
const days =
(insertArg.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000);
expect(days).toBeGreaterThan(364);
expect(days).toBeLessThan(367);
});
it('honors an explicit null (unlimited)', async () => {
const { service, apiKeyRepo } = makeDeps();
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
await service.create(user, 'k', null);
expect(apiKeyRepo.insert.mock.calls[0][0].expiresAt).toBeNull();
});
it('does NOT insert a row when minting fails (mint-then-insert → inert)', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
tokenService.generateApiToken.mockRejectedValue(new Error('mint failed'));
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
await expect(service.create(user, 'k')).rejects.toThrow('mint failed');
expect(apiKeyRepo.insert).not.toHaveBeenCalled();
});
});
@@ -1,191 +0,0 @@
import {
Injectable,
Logger,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { v7 as uuid7 } from 'uuid';
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { TokenService } from '../auth/services/token.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { JwtApiKeyPayload } from '../auth/dto/jwt-payload';
import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types';
import { isUserDisabled } from '../../common/helpers';
// Default lifetime for a new key when the caller does not specify one: 1 year.
// The owner runs a homelab where agents live for years; forcing rotation is
// operational pain, so an explicit `null` (unlimited) is also allowed.
const DEFAULT_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
// last_used_at is a best-effort forensics stamp, not an access record; we only
// refresh it when it is older than this to avoid a write on every request. The
// 1h resolution is a deliberate constant (forensics granularity, not accounting).
const LAST_USED_THROTTLE_MS = 60 * 60 * 1000;
/**
* Core API-key lifecycle service. Owns minting (create), the single validator
* shared by BOTH the REST jwt.strategy path and the /mcp Bearer path (validate),
* and revocation (revoke). The `api_keys` ROW is the sole source of truth for a
* key's lifetime and revocation never the JWT, which carries no `exp` claim.
*/
@Injectable()
export class ApiKeyService implements OnModuleInit {
private readonly logger = new Logger(ApiKeyService.name);
constructor(
private readonly apiKeyRepo: ApiKeyRepo,
private readonly userRepo: UserRepo,
private readonly workspaceRepo: WorkspaceRepo,
private readonly tokenService: TokenService,
private readonly environmentService: EnvironmentService,
) {}
onModuleInit() {
// Boot log so the kill-switch state after each deploy is verifiable in logs.
const enabled = this.environmentService.isApiKeysEnabled();
const raw = this.environmentService.getApiKeysEnabledRaw();
this.logger.log(
`API keys: ${enabled ? 'ENABLED' : 'DISABLED'} (API_KEYS_ENABLED=${
raw ?? 'unset'
})`,
);
}
/**
* Mint a new key for `user`. mint-then-insert ordering (R1):
* 1. generate the id first it must be in the JWT payload before the row.
* 2. mint the JWT (no `exp` claim). A mint failure aborts before any row is
* written (inert), so a half-created key cannot exist.
* 3. insert the row last. A lost response leaves an orphaned row that is
* visible in `list` and self-heals (the user revokes it).
* The token is returned ONCE and never stored the JWT is self-contained, so
* no token material lives in the table.
*
* `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit);
* a Date -> that instant (a past date is rejected at the DTO layer).
*/
async create(
user: User,
name: string,
expiresAt?: Date | null,
): Promise<{ token: string; key: ApiKey }> {
const resolvedExpiresAt =
expiresAt === undefined
? new Date(Date.now() + DEFAULT_LIFETIME_MS)
: expiresAt;
const apiKeyId = uuid7();
const token = await this.tokenService.generateApiToken({
apiKeyId,
user,
workspaceId: user.workspaceId,
});
const key = await this.apiKeyRepo.insert({
id: apiKeyId,
name,
creatorId: user.id,
workspaceId: user.workspaceId,
expiresAt: resolvedExpiresAt,
});
return { token, key };
}
/**
* The single validator for an api-key principal, shared by jwt.strategy and the
* /mcp Bearer router. Returns `{ user, workspace }` (the same shape the access
* path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work
* unchanged.
*
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact feature
* disabled, missing/revoked/expired row, workspace mismatch, disabled user
* throws a bare `UnauthorizedException` (a single generic 401 for every case;
* an agent cannot distinguish expired from revoked, and its reaction is
* identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so
* the surface returns 5xx, never a masked 401 (deny-on-decision / 5xx-on-infra).
* There is NO validate cache: it is 23 PK lookups (~1ms), two orders of
* magnitude cheaper than the bcrypt it replaces; a cache would only add a
* Date-serialization trap and a revocation lag. Revocation is immediate.
*/
async validate(
payload: JwtApiKeyPayload,
): Promise<{ user: User; workspace: Workspace }> {
// Kill-switch OFF: deny unconditionally (same generic 401). Note the
// endpoints additionally 404 at the controller; here we deny the token.
if (!this.environmentService.isApiKeysEnabled()) {
throw new UnauthorizedException();
}
if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) {
throw new UnauthorizedException();
}
const row = await this.apiKeyRepo.findById(
payload.apiKeyId,
payload.workspaceId,
);
// Absent row = revoked (soft-deleted, invisible to findById), orphaned
// (creator/workspace cascade-deleted), or never existed. All terminal deny.
if (!row) {
throw new UnauthorizedException();
}
// Expiry is read from the ROW, never an `exp` JWT claim.
if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) {
throw new UnauthorizedException();
}
const user = await this.userRepo.findById(
payload.sub,
payload.workspaceId,
{ includeIsAgent: true },
);
if (!user || isUserDisabled(user)) {
throw new UnauthorizedException();
}
// The key acts only as its creator (defence in depth against a token whose
// signed `sub` ever drifted from the row's owner).
if (row.creatorId !== user.id) {
throw new UnauthorizedException();
}
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
if (!workspace) {
throw new UnauthorizedException();
}
// Best-effort, throttled, fire-and-forget forensics stamp AFTER all checks.
this.touchLastUsed(row);
return { user, workspace };
}
/**
* Revoke (soft-delete) a key. Authorization/ownership is decided by the caller
* (the controller, via CASL); this only performs the terminal write. Idempotent:
* a second revoke is a no-op (the row is already invisible).
*/
async revoke(id: string, workspaceId: string): Promise<void> {
await this.apiKeyRepo.softDelete(id, workspaceId);
}
// Throttled best-effort last_used_at bump: skip if it was touched within the
// window; otherwise fire-and-forget so a stamp write never fails or slows the
// request (mirrors SessionActivityService.trackActivity).
private touchLastUsed(row: ApiKey): void {
const last = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
if (Date.now() - last < LAST_USED_THROTTLE_MS) return;
void this.apiKeyRepo.touchLastUsed(row.id).catch((err) => {
this.logger.warn(
`Failed to update api_key last_used_at for ${row.id}: ${
(err as Error)?.message ?? err
}`,
);
});
}
}
@@ -1,51 +0,0 @@
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
registerDecorator,
ValidationOptions,
} from 'class-validator';
/**
* `expiresAt` must be strictly in the future. Skips validation for `null`
* (explicit "unlimited") and `undefined` (server applies the 1-year default),
* so only an actually-supplied date is range-checked. Rejecting a PAST date at
* the DTO layer means a caller cannot mint an already-dead key.
*/
function IsFutureDateString(options?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isFutureDateString',
target: object.constructor,
propertyName,
options: {
message: 'expiresAt must be a date in the future',
...options,
},
validator: {
validate(value: unknown) {
if (value === null || value === undefined) return true;
if (typeof value !== 'string') return false;
const t = Date.parse(value);
return !Number.isNaN(t) && t > Date.now();
},
},
});
};
}
export class CreateApiKeyDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;
// undefined -> default 1 year (applied server-side); null -> unlimited
// (explicit); an ISO date string -> that instant, which must be in the future.
@IsOptional()
@IsDateString()
@IsFutureDateString()
expiresAt?: string | null;
}
@@ -1,6 +0,0 @@
import { IsUUID } from 'class-validator';
export class RevokeApiKeyDto {
@IsUUID()
id: string;
}
@@ -20,71 +20,3 @@ describe('AuthController', () => {
expect(controller).toBeDefined();
});
});
// The collab-token handler is the ARM-SEAM of the #501 anti-laundering defense:
// it derives the api-key origin args ({ apiKeyId }) from the SIGNED-derived
// `req.raw` fields (stamped by jwt.strategy) and threads them into the collab
// token mint, forcing principal='api_key' so a later key revoke rejects NEW
// collab connections. A regression here (reading `body`, dropping `apiKeyId`,
// or inverting the ternary) would silently mint api-key requests as
// principal='session' and reopen the laundering hole with a green suite. These
// tests pin the EXACT args the controller passes to authService.getCollabToken.
describe('AuthController.collabToken arms the api-key origin (#501)', () => {
let controller: AuthController;
let getCollabToken: jest.Mock;
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
const workspace = { id: 'ws-1' } as any;
beforeEach(() => {
getCollabToken = jest.fn().mockResolvedValue({ token: 'ct' });
controller = new AuthController(
{ getCollabToken } as any, // authService
{} as any, // sessionService
{} as any, // environmentService
{} as any, // moduleRef
{} as any, // auditService
);
});
it('threads { apiKeyId } when req.raw marks an api_key principal (ARMED)', async () => {
const req = { raw: { authType: 'api_key', apiKeyId: 'k-1' } } as any;
await controller.collabToken(user, workspace, req);
expect(getCollabToken).toHaveBeenCalledTimes(1);
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', {
apiKeyId: 'k-1',
});
});
it('passes undefined for a normal session request (NOT armed)', async () => {
const req = { raw: {} } as any;
await controller.collabToken(user, workspace, req);
expect(getCollabToken).toHaveBeenCalledTimes(1);
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
});
it('does NOT arm when api_key authType lacks an apiKeyId', async () => {
const req = { raw: { authType: 'api_key' } } as any;
await controller.collabToken(user, workspace, req);
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
});
it('reads the SIGNED req.raw, never a spoofable client body', async () => {
// A client-supplied body claiming api_key must be ignored: only the
// jwt.strategy-stamped req.raw can arm the api-key origin.
const req = {
raw: {},
body: { authType: 'api_key', apiKeyId: 'attacker' },
} as any;
await controller.collabToken(user, workspace, req);
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
});
});
+1 -13
View File
@@ -207,20 +207,8 @@ export class AuthController {
async collabToken(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
// Thread the api-key origin (#501): when the requester authenticated with an
// api key (jwt.strategy stamped req.raw.authType/apiKeyId), the minted collab
// token carries principal='api_key' + apiKeyId so a later revoke of the key
// rejects NEW collab connections. A normal session request mints a
// principal='session' token. Reading the SIGNED-derived req.raw fields (never
// a client body) keeps it unspoofable.
const raw = req.raw as { authType?: string; apiKeyId?: string };
const apiKey =
raw.authType === 'api_key' && raw.apiKeyId
? { apiKeyId: raw.apiKeyId }
: undefined;
return this.authService.getCollabToken(user, workspace.id, apiKey);
return this.authService.getCollabToken(user, workspace.id);
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
+1 -5
View File
@@ -5,13 +5,9 @@ import { JwtStrategy } from './strategies/jwt.strategy';
import { WorkspaceModule } from '../workspace/workspace.module';
import { SignupService } from './services/signup.service';
import { TokenModule } from './token.module';
import { ApiKeyModule } from '../api-key/api-key.module';
@Module({
// ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an
// api_key Bearer/cookie token is validated directly (replacing the absent EE
// `ee/api-key` dynamic require).
imports: [TokenModule, WorkspaceModule, ApiKeyModule],
imports: [TokenModule, WorkspaceModule],
controllers: [AuthController],
providers: [AuthService, SignupService, JwtStrategy],
exports: [SignupService, AuthService],
@@ -33,17 +33,6 @@ export type JwtPayload = {
aiChatId?: string | null;
};
// The AUTH-PRINCIPAL kind behind a collab token — distinct from `actor`
// (provenance). Stamped into EVERY newly-minted collab token (#501): 'session'
// for a normal user/session (incl. the internal AI agent, which is session-
// backed), 'api_key' when the token was minted by an api-key principal (an
// external MCP agent). The discriminator keys on the token's ORIGIN, so the
// collab seam can re-check a revoked api key on connect and reject a claimless
// token after the rollout grace window. NOT keyed on `actor:'agent'` — the
// internal agent is 'agent' but session-backed, so it must stay on the no-check
// path.
export type CollabPrincipal = 'session' | 'api_key';
export type JwtCollabPayload = {
sub: string;
workspaceId: string;
@@ -55,13 +44,6 @@ export type JwtCollabPayload = {
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
// an 'agent' actor with a null aiChatId.
aiChatId?: string | null;
// Auth-principal discriminator (#501). Present on every post-rollout token;
// its absence on a still-valid token past the grace window is treated as an
// error, not trust (fail-closed).
principal?: CollabPrincipal;
// Only when principal === 'api_key': the minting key's id, so the collab seam
// can row-check (and reject) a revoked key on connect.
apiKeyId?: string;
};
export type JwtExchangePayload = {
@@ -375,20 +375,10 @@ export class AuthService {
}
}
async getCollabToken(
user: User,
workspaceId: string,
// Origin of the request minting this collab token (#501). When the caller is
// an api-key principal, its apiKeyId is threaded into the token so the collab
// seam can re-check the key on connect (closing api-key -> long-lived-collab
// laundering). Absent for a normal session/human request.
apiKey?: { apiKeyId: string },
) {
async getCollabToken(user: User, workspaceId: string) {
const token = await this.tokenService.generateCollabToken(
user,
workspaceId,
undefined,
apiKey,
);
return { token };
}
@@ -1,5 +1,4 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { TokenService } from './token.service';
import { JwtType } from '../dto/jwt-payload';
@@ -214,175 +213,4 @@ describe('TokenService.generateCollabToken', () => {
aiChatId: 'chat-456',
});
});
// #501 fail-closed discriminator: EVERY collab token carries a principal.
it("defaults principal to 'session' with NO apiKeyId (normal/internal-agent path)", async () => {
const { service, jwtService } = makeTokenService();
await service.generateCollabToken(makeUser() as never, 'ws-1');
const [payload] = jwtService.sign.mock.calls[0];
expect(payload.principal).toBe('session');
expect(payload).not.toHaveProperty('apiKeyId');
});
it("the internal agent (provenance, NO apiKey) still gets principal='session'", async () => {
const { service, jwtService } = makeTokenService();
await service.generateCollabToken(
makeUser() as never,
'ws-1',
{ actor: 'agent', aiChatId: 'chat-1' },
);
const [payload] = jwtService.sign.mock.calls[0];
// Keyed on api-key ORIGIN, not actor: an is_agent session token is 'session'.
expect(payload.principal).toBe('session');
expect(payload).not.toHaveProperty('apiKeyId');
});
it("stamps principal='api_key' + apiKeyId when minted by an api-key principal", async () => {
const { service, jwtService } = makeTokenService();
await service.generateCollabToken(
makeUser() as never,
'ws-1',
undefined,
{ apiKeyId: 'key-9' },
);
const [payload] = jwtService.sign.mock.calls[0];
expect(payload.principal).toBe('api_key');
expect(payload.apiKeyId).toBe('key-9');
});
});
/**
* API-key token minting MUST carry NO `exp` claim the ONLY source of truth for
* a key's lifetime/revocation is its `api_keys` row, checked on every request.
*
* This is a LIVE-bug regression: the shared JwtService is registered with a
* global `signOptions.expiresIn` (default '90d') that merges into every sign(),
* so an api-key minted through it silently gets exp=now+90d and an "unlimited"
* key dies in 90 days. TokenService.generateApiToken mints through a dedicated
* no-expiry signer instead. These tests construct the REAL signer (a real secret
* via the stubbed EnvironmentService) and decode the produced JWT to assert the
* observable property: no `exp`.
*/
describe('TokenService.generateApiToken (no exp claim ever)', () => {
const APP_SECRET_LOCAL = 'apikey-secret';
function makeRealSignerService() {
// Give the SHARED jwtService a global expiresIn so a regression (minting
// through it) would show up as an exp claim — the exact live bug.
const { JwtService } = require('@nestjs/jwt');
const sharedJwt = new JwtService({
secret: APP_SECRET_LOCAL,
signOptions: { expiresIn: '90d', issuer: 'Docmost' },
});
const environmentService = {
getAppSecret: () => APP_SECRET_LOCAL,
};
const service = new (TokenService as unknown as new (
...args: unknown[]
) => TokenService)(sharedJwt, environmentService);
return { service };
}
const user = makeUser({ id: 'svc-1', workspaceId: 'ws-1' });
it('mints an api-key JWT with NO exp claim and issuer Docmost', async () => {
const { service } = makeRealSignerService();
const token = await service.generateApiToken({
apiKeyId: 'key-1',
user: user as never,
workspaceId: 'ws-1',
});
const decoded = jwt.decode(token) as Record<string, unknown>;
// The observable security property: no expiry lives in the JWT.
expect(decoded.exp).toBeUndefined();
expect(decoded).toMatchObject({
sub: 'svc-1',
apiKeyId: 'key-1',
workspaceId: 'ws-1',
type: JwtType.API_KEY,
iss: 'Docmost',
});
});
it('demonstrates the live bug it guards: the SHARED signer WOULD add exp', () => {
const { JwtService } = require('@nestjs/jwt');
const sharedJwt = new JwtService({
secret: APP_SECRET_LOCAL,
signOptions: { expiresIn: '90d', issuer: 'Docmost' },
});
// Even with empty per-call options the global expiresIn merges in.
const leaky = sharedJwt.sign({ sub: 'x', type: JwtType.API_KEY }, {});
expect((jwt.decode(leaky) as Record<string, unknown>).exp).toBeDefined();
});
it('refuses to mint for a disabled user', async () => {
const { service } = makeRealSignerService();
await expect(
service.generateApiToken({
apiKeyId: 'key-1',
user: makeUser({ deactivatedAt: new Date() }) as never,
workspaceId: 'ws-1',
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
/**
* verifyJwtOneOf is the type-routing primitive: verify the signature once and
* assert the token type is on an explicit allowlist. It must NOT degrade into a
* "return whatever type" helper a token whose type is off the allowlist is
* rejected with the same generic error as a single-type mismatch.
*/
describe('TokenService.verifyJwtOneOf (allowlist type-routing)', () => {
it('returns the payload when the type is on the allowlist', async () => {
const verifyAsync = jest
.fn()
.mockResolvedValue({ type: JwtType.API_KEY, sub: 'u-1' });
const { service } = makeTokenService({ verifyAsync });
const payload = await service.verifyJwtOneOf(token123(), [
JwtType.ACCESS,
JwtType.API_KEY,
]);
expect(payload).toMatchObject({ type: JwtType.API_KEY, sub: 'u-1' });
expect(verifyAsync).toHaveBeenCalledTimes(1);
});
it('accepts the OTHER allowed type too', async () => {
const verifyAsync = jest
.fn()
.mockResolvedValue({ type: JwtType.ACCESS, sub: 'u-1' });
const { service } = makeTokenService({ verifyAsync });
await expect(
service.verifyJwtOneOf(token123(), [JwtType.ACCESS, JwtType.API_KEY]),
).resolves.toMatchObject({ type: JwtType.ACCESS });
});
it('rejects a token whose type is OFF the allowlist (confused-deputy guard)', async () => {
const verifyAsync = jest
.fn()
.mockResolvedValue({ type: JwtType.COLLAB, sub: 'u-1' });
const { service } = makeTokenService({ verifyAsync });
await expect(
service.verifyJwtOneOf(token123(), [JwtType.ACCESS, JwtType.API_KEY]),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('verifies the signature exactly ONCE', async () => {
const verifyAsync = jest
.fn()
.mockResolvedValue({ type: JwtType.ACCESS });
const { service } = makeTokenService({ verifyAsync });
await service.verifyJwtOneOf(token123(), [JwtType.ACCESS]);
expect(verifyAsync).toHaveBeenCalledTimes(1);
});
});
function token123(): string {
return 'a.b.c';
}
@@ -4,6 +4,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
JwtApiKeyPayload,
@@ -61,12 +62,6 @@ export class TokenService {
// token carries no actor/aiChatId and is treated as 'user' downstream.
// aiChatId is nullable for an external agent with no internal ai_chats row.
provenance?: { actor: 'agent'; aiChatId: string | null },
// Optional api-key origin (#501). When the collab token is minted by an
// api-key principal (an external MCP agent), the caller passes the key id so
// the token carries principal='api_key' + apiKeyId and the collab seam can
// re-check the key on connect. Absent -> principal='session' (a normal
// user/session, including the internal session-backed AI agent).
apiKey?: { apiKeyId: string },
): Promise<string> {
if (isUserDisabled(user)) {
throw new ForbiddenException();
@@ -76,10 +71,6 @@ export class TokenService {
sub: user.id,
workspaceId,
type: JwtType.COLLAB,
// Fail-closed discriminator on EVERY minted token: 'api_key' when minted by
// an api-key principal, else 'session'.
principal: apiKey ? 'api_key' : 'session',
...(apiKey ? { apiKeyId: apiKey.apiKeyId } : {}),
...(provenance
? { actor: provenance.actor, aiChatId: provenance.aiChatId }
: {}),
@@ -132,8 +123,9 @@ export class TokenService {
apiKeyId: string;
user: User;
workspaceId: string;
expiresIn?: StringValue | number;
}): Promise<string> {
const { apiKeyId, user, workspaceId } = opts;
const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -145,32 +137,7 @@ export class TokenService {
type: JwtType.API_KEY,
};
// API-key tokens carry NO `exp` claim EVER — the ONLY source of truth for a
// key's lifetime and revocation is its `api_keys` row (checked on every
// request), not the JWT. This CANNOT use `this.jwtService`: TokenModule
// registers it with a global `signOptions.expiresIn` (JWT_TOKEN_EXPIRES_IN,
// default '90d'), which merges into EVERY sign() call — even `sign(payload,
// {})` — and `{ expiresIn: undefined }` THROWS rather than stripping it
// (verified empirically). So an "unlimited" key minted through the shared
// signer would silently get exp=now+90d and die in 90 days regardless of its
// row. We mint through a dedicated no-expiry signer, re-stamping only
// `issuer: 'Docmost'` for claim parity with the shared signer.
return this.apiKeyJwtService().sign(payload);
}
// Lazily-built JWT signer for API-key tokens: same APP_SECRET, same 'Docmost'
// issuer, but WITHOUT the global `expiresIn` — so minted API-key tokens have no
// `exp` claim. Built once and cached. Verification still goes through the
// shared verifier (same secret); `verifyAsync` does not require an `exp`.
private _apiKeyJwtService?: JwtService;
private apiKeyJwtService(): JwtService {
if (!this._apiKeyJwtService) {
this._apiKeyJwtService = new JwtService({
secret: this.environmentService.getAppSecret(),
signOptions: { issuer: 'Docmost' },
});
}
return this._apiKeyJwtService;
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
}
async generatePdfRenderToken(
@@ -210,31 +177,4 @@ export class TokenService {
return payload;
}
/**
* Verify a token's signature ONCE and assert its `type` is one of `allowed`.
*
* This is the type-routing primitive for surfaces that legitimately accept
* more than one token type on the same Bearer slot (the /mcp Bearer path
* accepts both an ACCESS and an API_KEY token). It is deliberately NOT a
* "verify-and-return-whatever-type" helper that would be a reusable
* confused-deputy footgun (any caller could then feed an attachment/collab
* token where an access token is expected). An explicit allowlist preserves
* the type-pinning property of `verifyJwt`: a token whose `type` is not in the
* allowlist is rejected with the SAME generic error as a type mismatch, and
* the signature is verified exactly once (no double-verify).
*/
async verifyJwtOneOf(token: string, allowed: JwtType[]) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
});
if (!allowed.includes(payload.type)) {
throw new UnauthorizedException(
'Invalid JWT token. Token type does not match.',
);
}
return payload;
}
}

Some files were not shown because too many files have changed in this diff Show More