Compare commits

...

8 Commits

Author SHA1 Message Date
agent_coder 03c91dfb0b docs(deploy): поправить устаревший коммент про auto-reload скрытой вкладки под вариант C (#481, ревью F1)
r1-коммент утверждал «auto-reload on a hidden tab», но вариант C (r2) убрал
авто-reload скрытой вкладки ради защиты несохранённого ввода. Переписал под
фактическое поведение: баннер + отложенный reload на следующей in-app навигации.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 04:10:22 +03:00
agent_coder 83f792ca39 fix(deploy): variant C — reload по in-app навигации вместо visibilitychange + лог-след + CHANGELOG (#481, ревью)
Правки по ревью #499. Владелец выбрал вариант C по вопросу потери несохранённого
ввода.

F1 (reload по навигации): убрал ветку visibilitychange→reload И немедленный reload
скрытой-при-получении вкладки — visibility больше не влияет на авто-reload вообще.
На реальном mismatch: баннер (кнопка «Обновить» — немедленный reload под тем же
one-shot гардом) + модульный флаг pendingNavReload. Хук useVersionReloadOnNavigation
(useLocation + useEffect по location.key, пропуск первого рендера через useRef;
react-router v7 BrowserRouter компонентный, объекта роутера нет — подписка изнутри
дерева) на СЛЕДУЮЩЕЙ навигации зовёт consumeNavigationReload → performAutoReload
(mark-перед-reload, при spent/непишущемся storage — только баннер). Скрытая вкладка
идёт тем же путём (C единообразно). chunk-load-error-boundary reload не тронут —
остаётся бэкапом для не-навигирующей вкладки. Так reload в безопасной точке (юзер
и так уходит со страницы), а не когда свернул с недописанным комментарием.

F2 (лог-след): общий recordReloadBreadcrumb/takeReloadBreadcrumb (sessionStorage,
переживает reload). performAutoReload пишет breadcrumb {path:proactive,server,
client} + console.warn перед reload; chunk-boundary пишет {path:chunk-boundary};
surfacePreviousReloadBreadcrumb на маунте UserProvider логирует прошлый след.

F3 (CHANGELOG + AGENTS.md): user-facing запись про version-coherence + строка про
артефакт client/dist/version.json.

Тесты: vitest version-coherence+reload-guard+guarded-reload 23 (переписан на
MemoryRouter+useNavigate: reload РОВНО раз на первой навигации после mismatch, НЕ
на 2-й навигации, НЕ на 2-м mismatch one-shot, НЕ от ухода в фон; скрытая вкладка
— только на навигации; Update — сразу; banner-only/write-fail — никогда). Шире:
features/user 34.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 03:01:07 +03:00
agent_coder 1e4925007d feat(deploy): version-coherence — WS-анонс версии сервера + guarded reload устаревших вкладок (#481)
SPA — долгоживущий клиент: вкладку держат часами, сервер за это время
передеплоивают. Старый index.html ссылается на content-hashed чанки, которых в
новом образе нет → ленивый import() → catch-all отдаёт index.html как text/html →
WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Реактивный
предохранитель (chunk-error-boundary) ловит УЖЕ случившуюся ошибку. Делаем сигнал
ПРОАКТИВНЫМ: сервер сообщает версию по существующему WS, клиент сравнивает и
делает guarded reload ДО битого чанка. closes #481

- Единый источник версии: vite.config.ts вычисляет resolveAppVersion РОВНО раз →
  и в define.APP_VERSION, и в плагине, пишущем dist/version.json. Бандл и файл
  тождественны by construction. Сервер читает client/dist/version.json при старте
  (client-version.ts: resolveClientDistPath вынесен из static.module — единый
  источник пути; readClientBuildVersion → версия или '' на любой ошибке,
  fail-safe). НИКАКИХ Docker/ENV-изменений.
- WS: отдельное событие app-version {version} (НЕ в message/WebSocketEvent —
  член без operation сломал бы union). ws.gateway читает версию в onModuleInit +
  boot-лог ACTIVE/DISABLED; emit в handleConnection (success-ветка, после join).
  ТОЛЬКО per-connect анонс, БЕЗ broadcast (broadcast в кластере → thundering-herd
  reload флота; естественный реконнект покрывает и single-container, и кластер).
- Клиент: listener навешан СИНХРОННО в том же useEffect, что создаёт сокет (до
  connect — иначе гонка с немедленным emit сервера). Чистая decideVersionAction
  (reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка
  triggerGuardedReload: модульный latch, hidden→немедленный reload, visible→баннер
  + reload-on-hidden {once}, фикс-id закрываемый баннер с кнопкой «Обновить».
- Общий one-shot флаг: reload-guard.ts (hasAutoReloaded/markAutoReloaded над
  существующим chunk-reload-attempted); chunk-load-error-boundary переведён на
  него. Проактивный + реактивный reload делят ОДИН счётчик → максимум одна
  авто-перезагрузка за сессию суммарно; permanent skew / oscillation / disabled
  storage → после первого reload только баннер, петли нет (проверено трассировкой).

Проверка: server jest client-version 7/7; client vitest version-coherence+
reload-guard+guarded-reload 16/16 (coverage 97.9%). Build-proof единого источника:
APP_VERSION=test-A → dist/version.json {"version":"test-A"} + вшито в бандл.
Полный staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб) — вне
автостенда, шаги приёмки #481 (крит.1-4) на ручную проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:14:12 +03:00
vvzvlad 6d7dba970c Merge pull request 'refactor(mcp): структурные инварианты конкурентной записи — UUID-assert, self-resolve seams, no-await-guard (#449)' (#475) from refactor/449-write-invariants into develop
Reviewed-on: #475
2026-07-11 01:32:30 +03:00
agent_vscode 5c1ab9c7b5 fix(docker): ship packages/mcp/data into runtime image
drawioFromGraph и каталог фигур читают данные в рантайме по пути
packages/mcp/data/ (относительно build/lib/*.js через import.meta.url).
tsc эмитит только build/, а стадия installer копировала лишь build/ и
package.json — в образе не было ни drawio-presets.json (#425), ни
drawio-shape-index.json.gz (#440), из-за чего drawioFromGraph падал с
ENOENT, а иконки каталога фигур молча не резолвились.

Добавлен COPY packages/mcp/data в стадию installer. .dockerignore /data
заякорен на корень и этот путь не затрагивает.
2026-07-11 01:13:48 +03:00
agent_vscode 363f20ab75 docs(agents): add architectural invariants (non‑negotiable rules)
Introduce a new “ARCHITECTURAL INVARIANTS — NON-NEGOTIABLE” section that
lists ten hard constraints derived from past production incidents. These
rules act as non‑negotiable guidelines for future development and code
review.
2026-07-11 01:04:10 +03:00
agent_vscode 3411bda2d1 fix(mcp): adapt e2e node-ops calls to the #413 XOR input of patchNode/insertNode
#413 changed patchNode/insertNode to take { markdown? | node? } (exactly
one), but test-e2e.mjs still passed the raw ProseMirror node directly,
so the e2e-mcp CI job died with the XOR guard error right after the
node_ops seed step. Wrap both call sites in { node: ... }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:08:00 +03:00
agent_vscode e670f7498a fix(ai-chat): sync CORE_TOOL_KEYS with #443 core tools — restore mcp-server-parity
The #443 merge added getTree and getPageContext to the shared registry
(packages/mcp/src/tool-specs.ts) with tier 'core', but the server-side
authoritative core list CORE_TOOL_KEYS was never updated. Two guard tests
in tool-tiers.spec.ts failed on develop (CI job test / mcp-server-parity):
tier agreement SHARED_TOOL_SPECS <-> CORE_TOOL_SET, and the live-toolset
<-> deferred-catalog partition (both tools were live, non-core and absent
from the catalog).

- add 'getTree' and 'getPageContext' to CORE_TOOL_KEYS (kept core, not
  demoted: cheap single-call navigation tools; core listPages description
  itself points to getTree)
- update the CORE_TOOL_KEYS JSDoc accordingly
- pin the first tool-tiers spec to the new 17-entry list with membership
  assertions for both #443 tools

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:03:34 +03:00
22 changed files with 1035 additions and 34 deletions
+134
View File
@@ -5,6 +5,139 @@ repository. It has two layers: **how to run a task end-to-end** (the
sections below), and **how the codebase is built** (the technical sections sections below), and **how the codebase is built** (the technical sections
further down, formerly in `CLAUDE.md`). further down, formerly in `CLAUDE.md`).
## ARCHITECTURAL INVARIANTS — NON-NEGOTIABLE
THE TEN RULES BELOW ARE HARD CONSTRAINTS. Each one was paid for with a real
production incident or a multi-PR bug chain in THIS repository (cited inline).
They override convenience, deadlines and "it's just a small feature". A PR that
violates any of them MUST be rejected in review regardless of how good the rest
of it is. If a task genuinely seems to require breaking one — STOP and raise it
with the owner; do not code around it.
### 1. EVERY BUFFER, CACHE, HISTORY AND PAYLOAD HAS AN EXPLICIT SIZE BUDGET
Nothing accumulates unboundedly. A row/item cap is NOT a byte cap. Anything
replayed to a model, buffered in memory, persisted per step, or refetched by a
poll must state its budget in bytes/tokens and enforce it. Rewriting a growing
structure in full on every increment is FORBIDDEN — append or diff instead;
O(n²) write/serialize patterns do not pass review.
(Paid for by: full-row rewrite on every agent step — hundreds of MB of Postgres
writes per 50-step run, with every tool output serialized twice; unbounded
history replay killing long chats on the provider context window; 32 MB replay
buffers per active run.)
### 2. EVERYTHING LONG-RUNNING TERMINATES BY CONSTRUCTION
Every run / row / session / lease / subscriber / queue entry must define AT
DESIGN TIME: its owner; every terminal state; who writes the terminal state on
EVERY path (success, error, abort, disconnect in each phase, process restart);
retries for the terminal write; and a periodic sweeper that does not depend on
a reboot. A best-effort terminal write with no retry and no sweep is FORBIDDEN.
(Paid for by: assistant rows stuck 'streaming' forever; runs stuck 'running'
409-locking their chat until a restart — the #183/#184 follow-up chain.)
### 3. EVERY AWAIT IS CANCELLABLE AND DEADLINED; NEVER BLOCK THE EVENT LOOP
Every async step inside a request or agent turn honors the turn's AbortSignal
AND a wall-clock deadline — including in-app tools, lock queues and pagination
loops, not just external calls. Synchronous CPU work beyond ~50 ms goes to a
worker_thread. Promise.race DOES NOT cancel synchronous work — using it as a
"timeout" for sync computation is forbidden (the timer only fires after the
event loop is free again, i.e. after the damage is done).
(Paid for by: in-app tools ignoring abortSignal and writing pages AFTER Stop;
the synchronous ELK layout freezing every SSE stream in the process; the
step-0 MCP handshake hang — #397.)
### 4. ONE SOURCE OF TRUTH; EVERYTHING ELSE IS A REBUILDABLE CACHE
Postgres is the authoritative state. Every in-memory structure (registries,
caches, client stores) must be reconstructible from the DB and treated as
lossy. The client renders SERVER-DECLARED state — "a run is active" is a server
fact delivered as data, never inferred from side signals (204 vs 2xx, the
flavor of a disconnect). A new feature must name the owner of each piece of
state before implementation starts.
(Paid for by: the strip/restore resume machinery, silently frozen UIs and
ghost sends after unmount — the #381#432#456 chain.)
### 5. STATE MACHINES ARE EXPLICIT — ONE-SHOT FLAGS ARE FORBIDDEN
A complex lifecycle (chat thread, resume/reconnect, run) lives in a named-state
automaton (reducer / enum) where every state has an owner and a rendered
representation — including the failure states. Adding a boolean ref that one
callback arms and another reads-and-clears is FORBIDDEN in the AI-chat client.
New behavior = a new named state + explicit transitions, and the interruption
matrix (disconnect in each phase × restart × stop × supersede) is enumerated at
design time, not discovered one incident at a time.
(Paid for by: 26 one-shot useRef flags in chat-thread.tsx and the drip of
"one more missing transition" across #381#386/#389#432#456.)
### 6. NO NEW MODE FORKS; A FLAG IS FOR ROLLOUT, THEN IT DIES
A behavior flag that forks a code path must ship with a written sunset
condition; stacking a new flag onto the existing matrix without deleting or
scheduling an old one is forbidden. While a temporary fork exists, BOTH sides
must share identical lifecycle handling (abort semantics, error listeners,
concurrency gates) — asymmetric forks are outlawed.
(Paid for by: legacy vs autonomous divergence — the one-active-run gate and
the socket 'error' listener each existing on only ONE side; 2^4 flag
combinations each with different abort semantics.)
### 7. NO HAND-SYNCED MIRRORS — CODEGEN OR A CI PARITY TEST, NOTHING LESS
Two copies of the same knowledge (schema, tool registry, glyph map, probe
body, hash/normalize algorithm, label list) require either generation from a
single source or a CI test that FAILS on drift. A "mirror this change over
there" comment is NOT a guard and does not pass review.
(Paid for by: #293 — three drifting converter copies losing data; #447
REGISTRY_STAMP covering only one of the mirrored files; ~10 still-unguarded
mirrors across the MCP layer.)
### 8. CACHES, HEADERS, BUFFERS AND FSM TRANSITIONS GET AN INTEGRATION TEST OF THE OBSERVABLE PROPERTY
A unit test of a pure helper DOES NOT COUNT for these. Test the real header on
the real HTTP response, the real cache hit under real token sources, the real
transition under a really-killed socket. If the observable property cannot be
tested, the design is wrong — fix the design, not the test.
(Paid for by: #431#439 — a cache keyed on a fresh-per-call JWT, so it NEVER
hit and became prod incident #435 while its unit tests stayed green; and by
the #352#455 immutable-cache header silently overwritten by a framework
default AFTER the unit-tested code ran.)
### 9. CLIENT INPUT IS HOSTILE UNTIL VALIDATED — ALSO BEFORE PERSISTENCE
Anything from the browser (message parts, ids, titles, selections, flags) is
validated/sanitized BEFORE it is persisted into a row that will later be
replayed into a prompt, a converter or another subsystem. A poisoned row must
never be able to permanently brick a chat or a page on every subsequent read.
(Paid for by: unvalidated UIMessage parts persisted verbatim — one bad row
500s the chat on every later turn; #159 client-spoofed page titles; #388
selection re-sanitized server-side for the same reason.)
### 10. FAILURES ARE LOUD AND SPECIFIC; SILENT DEGRADATION IS FORBIDDEN
Extends the error convention below: a fire-and-forget write is allowed ONLY
with a metric or a greppable ERROR log; a degraded mode (dead cached MCP
client, stopped poll, exhausted retries, evicted buffer) must be VISIBLE to
the user or the operator. A feature that can quietly stop working — a frozen
"streaming…" UI, a poll that silently gives up, a cache serving corpses — does
not pass review.
(Paid for by: the degraded poll's silent 10-minute death leaving a forever-
"streaming" answer; dead MCP clients served from cache while every external
tool call failed; #435 being caught in minutes ONLY because metrics — #403
existed.)
## Default skill for feature design
For any feature-design request — the user hands over a raw feature idea, asks
to design or think through a feature, or to draft an issue («спроектируй»,
«продумай фичу», «составь ишью», "design X", "write an issue for X") — invoke
the `orchestrator-feature-designer` skill (Skill tool) BEFORE any other work.
It is the default operating mode for design work in this repository: research
→ design checklist (R1–R10) → forks resolved with the human → adversarial
self-attack → filed PR-sized issues. Do not design features or write issues
ad-hoc while this skill is available. This does not apply to non-design work
(bug fixes, reviews, retrospectives, refactors already specified by an issue).
## Task lifecycle ## Task lifecycle
### 1. Start: sync with develop ### 1. Start: sync with develop
@@ -330,6 +463,7 @@ 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`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `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`. - 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`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `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`. - 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`. - 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 ## Conventions
+12
View File
@@ -117,6 +117,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- **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 browser session (shared with the existing chunk-load
recovery), so a permanent version skew degrades to the banner rather than a
reload loop. When the build carries no version info the feature stays inert.
- **Place several images side by side in a row.** A new "Inline (side by - **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 side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. The row is images as a row that wraps onto the next line on narrow screens. The row is
+5
View File
@@ -45,6 +45,11 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
# The mcp package reads its data files (drawio-presets.json, drawio-shape-index.json.gz)
# at runtime via `new URL("../../data/…", import.meta.url)` relative to build/lib/*.js,
# i.e. from packages/mcp/data/. tsc emits only build/, so ship data/ explicitly or
# drawioFromGraph and the shape catalog die with ENOENT on packages/mcp/data/*.
COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports # mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/ # it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
# markdown-converter.js). Ship the built package + its manifest, or the prod # markdown-converter.js). Ship the built package + its manifest, or the prod
@@ -1,4 +1,5 @@
{ {
"A new version is available": "A new version is available",
"Account": "Account", "Account": "Account",
"Active": "Active", "Active": "Active",
"Add": "Add", "Add": "Add",
@@ -1,4 +1,5 @@
{ {
"A new version is available": "Доступна новая версия",
"Account": "Аккаунт", "Account": "Аккаунт",
"Active": "Активный", "Active": "Активный",
"Add": "Добавить", "Add": "Добавить",
@@ -1,8 +1,11 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { Button, Center, Stack, Text } from "@mantine/core"; import { Button, Center, Stack, Text } from "@mantine/core";
import {
const RELOAD_FLAG = "chunk-reload-attempted"; hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
} from "@/lib/reload-guard";
// Heuristic detection of a failed dynamic import. Since the code-splitting work, // Heuristic detection of a failed dynamic import. Since the code-splitting work,
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy // every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
@@ -25,16 +28,15 @@ function handleError(error: unknown) {
if (!isChunkLoadError(error)) return; if (!isChunkLoadError(error)) return;
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and // A stale-chunk 404 is cured by a full reload that re-fetches index.html and
// the new chunk manifest. Auto-reload once, guarding against a reload loop // the new chunk manifest. Auto-reload once, guarding against a reload loop
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the // (e.g. a genuinely missing chunk) with the shared one-shot session flag
// flag is already set we fall through to the manual recovery UI below. // (see @/lib/reload-guard — shared with the proactive version-coherence
try { // path). If it is already set, or the write fails (storage unavailable), we
if (sessionStorage.getItem(RELOAD_FLAG)) return; // fall through to the manual recovery UI below rather than risk a loop.
sessionStorage.setItem(RELOAD_FLAG, "1"); if (hasAutoReloaded()) return;
} catch { if (!markAutoReloaded()) return;
// sessionStorage unavailable (private mode / disabled): skip the automatic // Trace before the reload clears the console (same diagnostic breadcrumb the
// reload rather than risk an unguarded loop; the fallback UI still recovers. // proactive version-coherence path writes, tagged with this path).
return; recordReloadBreadcrumb({ path: "chunk-boundary" });
}
window.location.reload(); window.location.reload();
} }
@@ -0,0 +1,195 @@
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);
});
});
@@ -0,0 +1,187 @@
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 (first this session) → banner + arm navigation reload. The
* banner's "Update" button reloads immediately (same one-shot guard). The tab
* is NOT reloaded on visibility change.
* - auto-reload already used / storage error → banner only (no arm), so there is
* at most one automatic reload per session (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 spent
// auto-reload). Log for diagnosability; show the manual banner.
console.warn(
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
"auto-reload already spent this session — 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 = "";
}
@@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { makeConnectHandler } from "@/features/user/connect-resync.ts"; 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) { export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom); const [, setCurrentUser] = useAtom(currentUserAtom);
@@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
// fetch collab token on load // fetch collab token on load
const { data: collab } = useCollabToken(); 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(() => { useEffect(() => {
if (isLoading || isError) { if (isLoading || isError) {
return; return;
@@ -47,6 +63,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
handleConnect(); 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 () => { return () => {
console.log("ws disconnected"); console.log("ws disconnected");
newSocket.disconnect(); newSocket.disconnect();
@@ -0,0 +1,64 @@
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");
});
});
@@ -0,0 +1,32 @@
// 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` = a session-wide automatic reload has already happened,
* so we must not auto-reload again (loop safety, shared 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 session already spent
return "reload"; // real mismatch, first time this session
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
} from "./reload-guard";
const FLAG = "chunk-reload-attempted";
describe("reload-guard", () => {
beforeEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
it("hasAutoReloaded is false before any reload, true after mark", () => {
expect(hasAutoReloaded()).toBe(false);
expect(markAutoReloaded()).toBe(true);
expect(hasAutoReloaded()).toBe(true);
// Uses the same key the reactive chunk-load boundary reads.
expect(sessionStorage.getItem(FLAG)).toBe("1");
});
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("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();
}
});
});
+90
View File
@@ -0,0 +1,90 @@
// Shared one-shot auto-reload guard.
//
// 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 session-scoped flag. Net guarantee: at most a single
// automatic reload per browser session across both paths. Once the flag is set
// (or sessionStorage is unavailable), every further mismatch degrades to a
// manual banner/UI — no reload loop under permanent skew, node oscillation, or
// a disabled storage.
const RELOAD_FLAG = "chunk-reload-attempted";
/**
* Has an automatic reload already been performed (or attempted) this session?
*
* 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.
*/
export function hasAutoReloaded(): boolean {
try {
return sessionStorage.getItem(RELOAD_FLAG) !== null;
} catch {
return true;
}
}
/**
* Record that an automatic reload is being performed this session.
*
* Returns whether the write succeeded. A `false` return (storage unavailable)
* means the caller MUST NOT reload — otherwise the flag would never stick and
* the reload could loop.
*/
export function markAutoReloaded(): boolean {
try {
sessionStorage.setItem(RELOAD_FLAG, "1");
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;
}
}
+29 -2
View File
@@ -1,7 +1,8 @@
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv, type Plugin } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2"; import { compression } from "vite-plugin-compression2";
import * as path from "path"; import * as path from "path";
import * as fs from "node:fs";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
const envPath = path.resolve(process.cwd(), "..", ".."); const envPath = path.resolve(process.cwd(), "..", "..");
@@ -24,7 +25,32 @@ 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 }) => { export default defineConfig(({ mode }) => {
const appVersion = resolveAppVersion(envPath);
const { const {
APP_URL, APP_URL,
FILE_UPLOAD_SIZE_LIMIT, FILE_UPLOAD_SIZE_LIMIT,
@@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => {
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
}, },
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)), APP_VERSION: JSON.stringify(appVersion),
}, },
plugins: [ plugins: [
react(), react(),
versionJsonPlugin(appVersion),
// Emit .br and .gz next to every built asset so the server can serve the // 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). // precompressed copy (see @fastify/static preCompressed in static.module.ts).
compression({ compression({
@@ -0,0 +1,52 @@
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('');
});
});
@@ -0,0 +1,36 @@
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,3 +3,4 @@ export * from './nanoid.utils';
export * from './file.helper'; export * from './file.helper';
export * from './constants'; export * from './constants';
export * from './security-headers'; export * from './security-headers';
export * from './client-version';
@@ -27,10 +27,12 @@ import type { DocmostClientLike } from './docmost-client.loader';
*/ */
describe('tool tier metadata (#332)', () => { describe('tool tier metadata (#332)', () => {
it('core set is the documented 13 + searchInPage + insertFootnote (15)', () => { it('core set is the documented 13 + searchInPage + insertFootnote + getTree + getPageContext (17, #443)', () => {
expect(CORE_TOOL_KEYS).toHaveLength(15); expect(CORE_TOOL_KEYS).toHaveLength(17);
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core
expect(CORE_TOOL_SET.has('getTree')).toBe(true); // #443, promoted to core
expect(CORE_TOOL_SET.has('getPageContext')).toBe(true); // #443, promoted to core
// loadTools is a meta-tool, not a normal core key. // loadTools is a meta-tool, not a normal core key.
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false); expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
}); });
@@ -39,12 +39,14 @@ export interface ToolCatalogEntry {
/** /**
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools + `searchInPage` * CORE (always-active) in-app tool keys — 13 frequent/tiny tools + `searchInPage`
* (#330) + `insertFootnote` (#410). `searchInPage` is core because it is frequent * (#330) + `insertFootnote` (#410) + `getTree`/`getPageContext` (#443).
* for the editorial roles this feature targets; `insertFootnote` is core so the * `searchInPage` is core because it is frequent for the editorial roles this
* footnote tool is NOT hidden while its natural sibling `editPageText` is always * feature targets; `insertFootnote` is core so the footnote tool is NOT hidden
* active (that asymmetry is exactly what pushed the agent to write literal * while its natural sibling `editPageText` is always active (that asymmetry is
* `^[...]`). `loadTools` is active too but is not a normal tool key (it is added * exactly what pushed the agent to write literal `^[...]`). `getTree` and
* to activeTools separately). * `getPageContext` are the single-call navigation/lookup tools — core so the
* agent never has to loadTools just to orient itself. `loadTools` is active too
* but is not a normal tool key (it is added to activeTools separately).
*/ */
export const CORE_TOOL_KEYS = [ export const CORE_TOOL_KEYS = [
'searchPages', 'searchPages',
@@ -66,6 +68,11 @@ export const CORE_TOOL_KEYS = [
// #410 insertFootnote — core so pinpoint citations to already-written text // #410 insertFootnote — core so pinpoint citations to already-written text
// don't degrade into literal `^[...]`; kept symmetric with editPageText. // don't degrade into literal `^[...]`; kept symmetric with editPageText.
'insertFootnote', 'insertFootnote',
// #443 getTree + getPageContext — cheap single-call navigation/lookup tools
// (the core listPages even points to getTree); core so the agent never has
// to loadTools just to orient itself.
'getTree',
'getPageContext',
] as const; ] as const;
/** O(1) membership test for the core tier. */ /** O(1) membership test for the core tier. */
@@ -4,6 +4,7 @@ import { join } from 'path';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from '../environment/environment.service';
import { resolveClientDistPath } from '../../common/helpers/client-version';
/** /**
* Resolve the response headers for a statically served client asset. * Resolve the response headers for a statically served client asset.
@@ -56,14 +57,7 @@ export class StaticModule implements OnModuleInit {
const httpAdapter = this.httpAdapterHost.httpAdapter; const httpAdapter = this.httpAdapterHost.httpAdapter;
const app = httpAdapter.getInstance(); const app = httpAdapter.getInstance();
const clientDistPath = join( const clientDistPath = resolveClientDistPath();
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
const indexFilePath = join(clientDistPath, 'index.html'); const indexFilePath = join(clientDistPath, 'index.html');
+37 -2
View File
@@ -9,10 +9,14 @@ import {
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { TokenService } from '../core/auth/services/token.service'; import { TokenService } from '../core/auth/services/token.service';
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload'; import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common'; import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { WsService } from './ws.service'; import { WsService } from './ws.service';
import { getSpaceRoomName, getUserRoomName } from './ws.utils'; import { getSpaceRoomName, getUserRoomName } from './ws.utils';
import {
readClientBuildVersion,
resolveClientDistPath,
} from '../common/helpers/client-version';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
@WebSocketGateway({ @WebSocketGateway({
@@ -20,17 +24,40 @@ import * as cookie from 'cookie';
transports: ['websocket'], transports: ['websocket'],
}) })
export class WsGateway export class WsGateway
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy implements
OnGatewayConnection,
OnGatewayInit,
OnModuleInit,
OnModuleDestroy
{ {
@WebSocketServer() @WebSocketServer()
server: Server; server: Server;
private readonly logger = new Logger(WsGateway.name);
// The build version of the client bundle shipped in this image, read once at
// startup from client/dist/version.json (single source of truth, same value
// baked into the client's APP_VERSION). Empty string => version.json missing
// or empty => the proactive version-coherence reload feature stays inert.
private appVersion = '';
constructor( constructor(
private tokenService: TokenService, private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo, private spaceMemberRepo: SpaceMemberRepo,
private wsService: WsService, private wsService: WsService,
) {} ) {}
onModuleInit(): void {
this.appVersion = readClientBuildVersion(resolveClientDistPath());
if (this.appVersion) {
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
} else {
this.logger.log(
'app-version reload: DISABLED (version.json missing/empty)',
);
}
}
afterInit(server: Server): void { afterInit(server: Server): void {
this.wsService.setServer(server); this.wsService.setServer(server);
} }
@@ -55,6 +82,14 @@ export class WsGateway
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id)); const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
client.join([userRoom, workspaceRoom, ...spaceRooms]); client.join([userRoom, workspaceRoom, ...spaceRooms]);
// Announce this container's client build version to the freshly
// authenticated socket. On a redeploy the client reconnects to the new
// container and receives the new version here, letting it guard-reload
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
// natural reconnect covers both single-container and cluster without a
// thundering-herd fleet reload.
client.emit('app-version', { version: this.appVersion });
} catch (err) { } catch (err) {
client.emit('Unauthorized'); client.emit('Unauthorized');
client.disconnect(); client.disconnect();
+3 -2
View File
@@ -316,7 +316,8 @@ async function main() {
const [idA, idB, idC] = seedIds; const [idA, idB, idC] = seedIds;
// patchNode: replace the middle paragraph; siblings' ids must be unchanged. // patchNode: replace the middle paragraph; siblings' ids must be unchanged.
await client.patchNode(nid, idB, mkPara(idB, "Bravo PATCHED.")); // #413 XOR input: the raw ProseMirror node goes under the `node` key.
await client.patchNode(nid, idB, { node: mkPara(idB, "Bravo PATCHED.") });
await new Promise((r) => setTimeout(r, 16000)); await new Promise((r) => setTimeout(r, 16000));
const afterPatch = (await client.getPageJson(nid)).content; const afterPatch = (await client.getPageJson(nid)).content;
const patchText = JSON.stringify(afterPatch); const patchText = JSON.stringify(afterPatch);
@@ -327,7 +328,7 @@ async function main() {
// insertNode: place a new block after the first paragraph. // insertNode: place a new block after the first paragraph.
await client.insertNode( await client.insertNode(
nid, nid,
mkPara("nodeops-ins", "Inserted paragraph."), { node: mkPara("nodeops-ins", "Inserted paragraph.") },
{ position: "after", anchorNodeId: idA }, { position: "after", anchorNodeId: idA },
); );
await new Promise((r) => setTimeout(r, 16000)); await new Promise((r) => setTimeout(r, 16000));