Compare commits

..

27 Commits

Author SHA1 Message Date
agent_vscode cfda79ef70 style(comment): adjust highlight padding and reduce border radius
Remove the fixed horizontal padding on the mark element and replace it with a
zero‑padding plus a 1 px horizontal padding/margin trick to keep intra‑word
highlights snug. Reduce the border radius from 3 to 2 to match the new design.
2026-07-13 01:53:51 +03:00
vvzvlad 624e03a9e3 Merge pull request 'feat(comments): редизайн панели комментариев — обе карточки на новом визуале (#561)' (#577) from feat/561-comments-redesign into develop
Reviewed-on: #577
2026-07-12 23:22:43 +03:00
agent_coder be8ed579a9 fix(comments): review fixes for the edit-card redesign (#561)
- F1: restore the "Applied" badge on an applied suggested-edit card (green light
  Badge + aria-label, reusing the pre-redesign t("Applied") key). Renders only
  when suggestionAppliedAt is set (a reply-kept, resolved suggestion — a
  childless applied one is hard-deleted per #329), never for a pending edit.
- F2: add the four new t() keys introduced by the redesign to both locales —
  "Critical", "Major", "{{count}} edits", "{{count}} major" (en value==key; ru
  translated, ICU plural for the count keys, placeholders intact).
- F3: test RunHeader — edit count, major = major+critical only (not
  minor/unknown, non-vacuous), provenance line, no "Accept all" control. Uses an
  isolated initialised i18n instance so the interpolated count keys render.
- F4: restore the card aria-label (t("Jump to comment selection")) on the
  clickable edit card for screen readers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:14:01 +03:00
agent_coder d542fc58ae feat(comments): интеграция редизайна панели комментариев (обе карточки) (#561)
Port the NewDesign/CommentsPanel prototype onto the entire live comment panel,
both card types (agent suggested-edit + human/agent thread) on the new visual,
reusing existing mutations/gates/editor/menu/resolve/anchor-nav (not forked),
zero backend changes. New: agent-edit-card, severity.ts, group-agent-runs.ts.
Delete only the CommentsPanel prototype (TimeWorkedModal/PageHistoryModal belong
to #566/#568 and are deleted by their own PRs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:14:01 +03:00
agent_vscode 85ffdea06b Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 21:35:41 +03:00
agent_vscode 9aa7620461 fix(client): номер сноски встаёт инлайн, а не на отдельной строке
Номер «N.» рисовался через .definitionContent > :first-child::before, но
tiptap-react оборачивает содержимое NodeViewContent в дополнительный блочный
div [data-node-view-content-react], поэтому :first-child — это обёртка, а не
<p>. Инлайновый ::before на блочной обёртке с блочным <p> внутри падал на
свою строку (регрессия «+1 строка» из #420). Селекторы переведены на
.definitionContent p:first-child / p:last-child — номер и сброс полей теперь
бьют по самому абзацу (контент определения — paragraph+, вложенных абзацев
нет, так что попадание однозначно), работает и с обёрткой, и без неё.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:35:37 +03:00
vvzvlad 710a690c78 Merge pull request 'feat(search) Фаза B PR-1: семантический слой — TEI e5-small + гибрид вектор+RRF (#530)' (#567) from feat/530-search-semantic into develop
Reviewed-on: #567
2026-07-12 20:39:43 +03:00
vvzvlad 50f8ed717a Merge pull request 'feat(work-time): редизайн модалки Time worked — таймлайны с временем суток (#566)' (#575) from feat/566-worktime-modal-redesign into develop
Reviewed-on: #575
2026-07-12 20:21:53 +03:00
agent_coder 1fc9c25681 feat(search): semantic fusion layer (Phase B PR-1) over lexical RRF (#530)
Fuse a vector-similarity branch into #529's lexical RRF. Query embed via a
global TEI sidecar (or per-workspace provider), degrading transparently to the
byte-identical Phase-A lexical path on any failure; a kill-switch and env knobs
gate it.

- ai.service: resolveEmbeddingProvider (workspace→global TEI fallback) with a
  deterministic config fingerprint; embedQuery (query prefix + short 800ms
  timeout); extract embedWithModel core shared with embedTexts.
- page-embedding.repo: vectorCandidateArm fragment (page-level NN, dim +
  active-fingerprint filtered) + fingerprint on insertChunks.
- search.service: 3-branch RRF over lexical ∪ vector candidates; try/catch wraps
  only the embed (permission filter stays outside, fail-closed); semantic
  degrade emits search.semantic.degraded; response gains semantic{state,available,reason}.
- indexer: resolve provider, prepend doc prefix, stamp fingerprint per row.
- migration 20260712T120000: add nullable page_embeddings.fingerprint + composite index.
- infra: TEI embeddings sidecar in docker-compose + EMBEDDING_*/SEARCH_* in .env.example.
- tests: 7 semantic int cases (vector-only hit, sidecar-down, no-provider,
  permission-over-union fail-closed, hung sidecar, lexical∪vector de-dup,
  fingerprint isolation) + fingerprint/prefix/timeout unit tests.

total now = permission-filtered size of (lexical ∪ vector top-N) — a documented
change from Phase A's exact lexical count (falls back to it on degrade).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:21:19 +03:00
vvzvlad e074b101c7 Merge pull request 'feat(#370 Stage A): MCP save_page_version — агентские версии страниц' (#565) from feat/370-agent-save-version into develop
Reviewed-on: #565
2026-07-12 19:56:05 +03:00
vvzvlad 4496afb481 Merge pull request 'recover(page-tree/dnd): вернуть в develop осиротевший фикс drop-раскрытия (#523/#532)' (#573) from fix/523-tree-dnd into develop
Reviewed-on: #573
2026-07-12 19:43:34 +03:00
agent_coder 3cda008012 feat(work-time): redesign «Time worked» modal with time-of-day timelines (#566)
Port the NewDesign/TimeWorkedModal prototype onto the existing work-time
feature with zero backend changes. Each daily track now shows WHEN work
happened: a sticky 00/06/12/18/24 hour axis, shaded night hours (0–6, 21–24),
a per-block hover tooltip "start – end · duration", and a "now" boundary on
today's row. Work vs agent windows keep their existing colour semantics.

The prototype's invented props (DaySummary[], pre-made labels) are replaced by
a pure, unit-tested adapter (work-time-adapter.ts) over the real IPageWorkTime:
windows → time-of-day blocks (epoch kept for a DST-safe tooltip), ms → labels
via the shared formatters, in-place empty-run collapsing, and the today-only
now-line. The agent-only fail-safe (#395/#551) is preserved — the agent
estimate fills the main slot so it never renders empty.

Reuses usePageWorkTime, the endpoint, tz bucketing and format-work-time; deletes
the scratch prototype. New tooltip i18n key added to en-US/ru-RU.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:37:25 +03:00
vvzvlad 78fc7c4842 Merge pull request 'recover(api-key): вернуть в develop осиротевший UI фазы-2 (#506/#533)' (#572) from recover/506-apikey-ui into develop
Reviewed-on: #572
2026-07-12 19:24:36 +03:00
agent_coder 1644de87f7 fix(#370): F2 terminal skip reply for empty/missing page + F1 lifecycle tests
Reviewer changes-requested on #565.

F2 (real bug): a save on an empty page (or a missing page row) hung the client
for the full 20s ack window and then falsely reported the collab server as
unreachable + advised retry. Root cause: handleSaveVersion gated its
broadcastStateless on `if (result)`, and `result` stays undefined on the two
reachable early returns (isEmptyParagraphDoc and !page), so the server sent
NOTHING and the client waited out its timeout.
- SERVER (persistence.extension.ts): both early-return branches now record a
  skip reason and the tail broadcasts a terminal `version.skipped` reply
  (reason 'empty' | 'page-not-found'). Exactly one terminal reply per handled
  save. Added VERSION_SAVED/VERSION_SKIPPED message consts.
- CLIENT (collaboration.ts): the predicate now matches both version.saved and
  version.skipped; SaveVersionResult gains {saved, skipped, reason}. An empty
  skip resolves to a clean {saved:false, skipped:true, reason:'empty'} (no
  stall); page-not-found throws an immediate, truthful error; the genuine
  no-reply timeout path is unchanged (that is the real "unreachable" case). A
  terminal reply leaves the session cached (healthy connection); only a
  transport failure destroys it. Kept the message-literal in-sync comments.
- tool-specs.ts / pages.ts: description + docstring reflect the new result shape.

F1 (coverage): added tests for the 4 previously-uncovered sendStatelessAndAwait
branches — unrelated-then-real (predicate filters noise), teardown-mid-wait
(rejects + removes the stateless listener, no leak), concurrent-in-flight
guard — plus the F2 empty-skip / page-not-found client outcomes. Server spec
gains empty-page and page-not-found terminal-reply tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:23:07 +03:00
agent_coder 498a87f3c4 fix(#370): add savePageVersion to DocmostClientMethod union (server in-app tool parity)
The Stage-A save_page_version tool's client method must be listed in the
DocmostClientMethod union so Pick<DocmostClient, DocmostClientMethod> exposes it
to the in-app tool adapter; the shared-tool-specs contract spec asserts parity.
Mirrors the existing restorePageVersion entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:23:07 +03:00
agent_coder 67c94de6ef feat(mcp): agent save_page_version tool — collab stateless transport + role prompts (#370)
Stage A of #370: wire the MCP client transport for the already-shipped server
save-version handler (PR-1 #374). An agent can now pin an intentional named
version (kind='agent', derived server-side from the signed actor) of a page's
CURRENT live collaboration content.

- collab-session.ts: add CollabSession.sendStatelessAndAwait(payload, predicate,
  timeoutMs) — sends a stateless message over the live provider and resolves on
  the first matching reply, with a bounded timeout; lifecycle (inflightReject,
  ready-guard, concurrent fail-fast, idle re-arm) mirrors mutate(). Extend
  CollabProviderLike with sendStateless + the stateless on/off overloads.
- collaboration.ts: add savePageVersionRealtime() — under withPageLock, reuse the
  cached agent-authenticated CollabSession (#400), send {type:'save-version'},
  await {type:'version.saved', …}; no REST read (would race the stale page row).
- client/pages.ts: add savePageVersion(pageId) — resolvePageId +
  getCollabTokenWithReauth + writeWithCollabAuthRetry (#486 self-heal).
- tool-specs.ts: add the deferred savePageVersion spec (+ DocmostClientLike Pick);
  auto-registered on both hosts by the shared loops.
- server-instructions.ts: HISTORY family + routing-prose mention.
- ai-chat-tools.service.ts: contract type-assert for the new client method.
- agent-roles-catalog: tell the content-authoring roles (researcher,
  call-summarizer, ru+en) to save a version when the document is done; bump
  their catalog versions.
- test: unit coverage for savePageVersionRealtime (ack + bounded-timeout paths).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:23:07 +03:00
agent_coder be25b31a0e fix(api-key): add missing i18n keys for api-key UI + correct service comment
en-US (source of truth) and ru-RU were missing ~25 strings introduced by the
api-key management UI, so Russian users saw the controls in English and en-US
was incomplete. Add every new t() key to both locales (24 new in en-US, 26 in
ru-RU) with natural Russian translations, placeholders ({{name}}/{{date}})
preserved; existing keys left untouched (insertions only, no reordering).

Also fix the createApiKey() lifecycle comment: the gcTime:0 + invalidation
lives in queries/api-key-query.ts (there is no use-api-key-query file) and the
reset()-after-read of the token lives in components/api-keys-manager.tsx
handleCreate (create-api-key-modal.tsx only resets the form).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:21:27 +03:00
agent_coder d6411424c1 feat(api-key): distinguish expired from expiring-soon + cover service unwrap
Fix 1: an already-expired key rendered the forward-looking "Expiring soon"
badge/tooltip ("expires within 30 days"), which is false for a past expiry.
Add a pure isExpired() helper and, in ApiKeysManager, render a red "Expired"
badge ("This key has expired") for past expiries; keep the orange "Expiring
soon" badge only for keys expiring in the future within 30 days (the two
states are made mutually exclusive at the call site). Extend utils.test.ts
with isExpired coverage (past/future/null/exact-now boundary) and add a
component assertion that an expired key shows "Expired" and NOT "Expiring
soon".

Fix 2: the response-contract unwrap (res.data turning the server envelope
{data:{token,apiKey},success,status} into {token,apiKey}) was exercised by no
test — component tests fully mock the service. Add a service-layer unit test
that mocks the api-client (axios instance) and asserts createApiKey unwraps to
the inner {token,apiKey} payload and getApiKeys resolves to the row array.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:20:17 +03:00
agent_coder 199a9a1750 feat(client): страница управления API-ключами в настройках аккаунта (#506)
Фаза 2 поверх серверных эндпоинтов #501: страница «Настройки → Аккаунт →
API-ключи» (lazy-роут, отдельный чанк) — список, создание, показ токена
один раз и отзыв.

- Список: явная дата истечения (не «через N дней»), подсветка ключей с
  истечением < 30 дней, «использован» с семантикой «в течение последнего
  часа» (last_used_at троттлится на 1 ч серверно).
- Создание: имя + срок (30д/90д/1 год[дефолт]/бессрочный → null). После
  submit — модалка показа токена один раз с копированием и явной датой.
- Токен-материал живёт ТОЛЬКО в state открытой модалки: mutateAsync +
  reset() чистит копию из query-cache, ничего не пишется в localStorage.
  Закрытие модалки — токен исчезает навсегда.
- Отзыв: подтверждение → revoke → строка уходит из списка.
- Admin (CASL Manage на API = owner/admin) видит ключи всего воркспейса с
  колонкой «автор»; обычный член — только свои.

Тесты (vitest): показ-один-раз + отсутствие токена в localStorage/кэше,
явная дата + подсветка < 30 дней, отзыв убирает строку, admin/member вид,
дефолт срока = 1 год и «бессрочный» → null. Стаб ResizeObserver добавлен в
общий vitest.setup для рендера Mantine ScrollArea/Table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:20:17 +03:00
agent_vscode 6b825ad440 fix(client): anchor toasts to the top edge of the viewport
Move the top-anchored Mantine notification containers from top:96px to
top:8px so success toasts (e.g. "Comment resolved successfully") appear
at the very top of the page, above the header/search, instead of
covering page content. Update the accompanying comment to reflect the
new intent (toast renders over the header chrome while visible).
2026-07-12 19:09:30 +03:00
agent_vscode 0cee2cc9dc Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 19:09:13 +03:00
agent_vscode e4af672c2c feat(ui): add UI components for comments, history, and time
Introduce redesigned UI elements for the new design system:
- **CommentsPanel** – displays agent‑driven edits with diff view, batch actions, and filtering.
- **PageHistoryModal** – full‑screen modal for browsing page revisions, includes a mini‑calendar and version rendering.
- **TimeWorkedModal** – visualizes work and agent sessions per day with grid or phase axes.

These components replace legacy panels, use Mantine v7, support dark/light themes, and provide richer interactions.
2026-07-12 19:09:03 +03:00
vvzvlad ec8dd7d110 Merge pull request 'fix(prosemirror-markdown): литеральный <br> в тексте экранируется, не становится hardBreak (#554)' (#560) from fix/554-literal-br into develop
Reviewed-on: #560
2026-07-12 18:57:02 +03:00
vvzvlad 4423b19850 Merge pull request 'fix(ai-chat): реактивная рекавери — итеративная эскалация 0.5^k + пол вместо фикс. 0.5× (#520)' (#546) from fix/520-recovery-escalation into develop
Reviewed-on: #546
2026-07-12 18:56:49 +03:00
agent_coder 688cb54f26 fix(ai-chat): рекавери режет реплей НИЖЕ настроенного бюджета (#520 Опция B)
Решение владельца по #520 (эпик #497 итерация 5): при повторном overflow реактивная
рекавери ДОЛЖНА иметь право резать бюджет реплея ниже явно настроенного окна —
«чат не должен кирпичиться на overflow» это ИНВАРИАНТ реактивной ветки, а
настроенное окно — заявление о ёмкости модели, не обещание о размере реплея; когда
провайдер 400-ит, реальность бьёт конфиг. (Не конфликтует с #510 Опция A: та про
верхнюю границу НОРМАЛЬНОГО пути — уважать конфиг, пока он влезает.)

- Пол эскалации: max(scaled, min(REPLAY_MIN_FLOOR_TOKENS, floor(0.5×threshold))).
  Было min(REPLAY_MIN_FLOOR_TOKENS, threshold) — жёсткий пол на настроенном бюджете.
  Теперь БОЛЬШОЕ окно эскалирует НИЖЕ настроенного бюджета до фикс. пола 8k; МАЛОЕ
  окно (0.5×threshold < 8k) падает до floor(0.5×threshold) — минимум старый 0.5× cut
  (никогда не хуже, чем раньше), но и не поднимается выше самого бюджета.
  REPLAY_MIN_FLOOR_TOKENS=8k без изменений (константа уже была). Сброс k на чистом
  ходу сохранён (счётчик replayOverflowCount не пишется на чистом финализе).
- Наблюдаемость: warn-лог (настроенный бюджет не влезает, реплей ниже него, уровень
  k) + метка турна metadata.replayBelowConfiguredBudget=true — только когда реплей
  реально ниже настроенного бюджета (не на нормальном in-budget реплее).
- Тесты: перепиновка «не раздувает малый бюджет выше себя» (теперь МОЖЕТ резать ниже,
  Опция B) в обоих spec; новый тест эскалации ниже бюджета (окно 8000→бюджет 5600,
  k=1→2800, сходимость к полу, сброс на чистом ходу) + большое окно (140k→8k
  ступенями); тест метки метадаты. Мутации: пол=бюджет краснит below-budget тесты,
  снятие метки краснит observability-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:01:07 +03:00
agent_coder ec5416068b fix(ai-chat): итеративная эскалация реактивной рекавери при overflow (#520)
Остаточный кирпич (#490/#510): при незаданном chatContextWindow база = плоский
дефолт 100k, а реальное окно модели маленькое (<50k). Фиксированный одинарный
cut 0.5×100k=50k всё равно превышал реальное окно → провайдер снова 400,
строка переставлялась replayOverflow, но булев priorOverflowed уже был true →
второго ужатия не происходило. Чат навсегда застревал на 50k и не восстанавливался.

Фикс (без парсинга тел 400):
- Сигнал из булева переведён в счётчик `metadata.replayOverflowCount` = число
  ПОДРЯД идущих overflow-ходов: инкремент (prior+1) на каждом overflow, сброс в 0
  на любом чистом финализе (чистая строка не пишет поле → читается как 0).
  BACK-COMPAT: старая строка с булевым `replayOverflow:true` читается как k=1.
- resolveEffectiveReplayThreshold(threshold, k) = max(floor(threshold·0.5**k),
  min(REPLAY_MIN_FLOOR_TOKENS, threshold)). k=0 → база; k=1 → 0.5×; k=2 → 0.25×;
  большой k → упирается в пол 8k (сходимость). null-база (trimming OFF) не трогается;
  пол никогда не поднимает легитимно малый настроенный бюджет выше него самого.
- Пол REPLAY_MIN_FLOOR_TOKENS=8k: ниже него чат не несёт осмысленный недавний
  контекст, и даже малое реальное окно его вмещает; keep-recent-turns сверху.

Тесты: таблица эскалации (k=0/1/2/большой/null), регрессия остаточного кирпича
(база 100k, окно ~40k → сходится ниже 40k, чего фиксированный 0.5× никогда не мог),
жизненный цикл счётчика на реальном pg (инкремент/сброс/back-compat через jsonb),
мутация **k→**1 краснит convergence-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:01:07 +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
76 changed files with 6185 additions and 603 deletions
+36
View File
@@ -198,6 +198,42 @@ MCP_DOCMOST_PASSWORD=
# A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000
# ─── #530 Semantic search (Phase B) ──────────────────────────────────────────
# The GLOBAL embedding provider used by search (query embed) AND the indexer
# (document embed) when a workspace has NO embedding provider of its own. It is
# an OpenAI-compatible endpoint — the docker-compose `embeddings` (TEI) sidecar.
# A workspace that configures its own embedding provider OVERRIDES all of this.
# When neither resolves, search runs lexical-only (semantic.reason=no-provider).
EMBEDDING_ENDPOINT=http://embeddings:80/v1
EMBEDDING_MODEL=intfloat/multilingual-e5-small
# Dummy — the self-hosted TEI sidecar is keyless.
EMBEDDING_API_KEY=unused
EMBEDDING_DIMENSIONS=384
# PLACEHOLDER — set to a PINNED intfloat/multilingual-e5-small commit sha (used
# both as the TEI --revision and as part of the embedding fingerprint). Keep this
# in lockstep with docker-compose; a bump changes the fingerprint (PR-2 handles
# the generational swap/GC — PR-1 uses a single active fingerprint).
EMBEDDING_REVISION=REPLACE_WITH_PINNED_E5_SMALL_COMMIT_SHA
# e5 models require these input prefixes. Empty them for a non-e5 model.
EMBEDDING_QUERY_PREFIX="query: "
EMBEDDING_DOC_PREFIX="passage: "
# Per-request timeout (ms) for the interactive SEARCH query embed — much shorter
# than the batch indexer timeout: a slow/hung sidecar degrades search fast to the
# lexical-only path instead of blocking the request. Default 800.
# SEARCH_EMBED_TIMEOUT_MS=800
# RRF weight of the vector leg relative to each lexical leg (1.0 = equal). Default 1.0.
# SEARCH_VECTOR_WEIGHT=1.0
# Vector top-N pulled into the fused candidate union per request. Default 50.
# SEARCH_VECTOR_CANDIDATES=50
# Per-statement timeout (ms) bounding ONLY the fused vector query (the brute-force
# KNN seq scan — there is no ANN index). If the vector scan exceeds this it is
# cancelled and search degrades to lexical-only (never hangs the request). Scoped
# per-query (SET LOCAL), so it never affects other queries. Default 2000.
# SEARCH_VECTOR_STATEMENT_TIMEOUT_MS=2000
# Kill-switch: set to `off` to disable the semantic layer entirely (search then
# runs the byte-identical Phase-A lexical path). Default on.
# SEARCH_SEMANTIC=on
# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic.
# Bounds time-to-first-byte and the gap BETWEEN chunks (NOT the total turn length),
# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung
+362
View File
@@ -0,0 +1,362 @@
/**
* PageHistoryModal — редизайн окна «Page history».
* Mantine v7. Light/dark. Полноразмерное модальное окно.
*
* ─────────────────────────────────────────────────────────────────────────
* ЧТО ЭТО
* ─────────────────────────────────────────────────────────────────────────
* Окно истории версий страницы. Слева — панель навигации: мини-календарь
* (heatmap: яркость дня = число ревизий, обводка = выбранный день) + плотный
* список ревизий (одна ревизия = одна строка). Справа — реально отрендеренная
* версия страницы с подсветкой изменений. Все контролы — в одну строку шапки.
*
* Ключевые решения:
* - Плотный список: одна ревизия на строку (аватар · время · автор · [SAVED]).
* Бейдж показывается ТОЛЬКО у сохранённых версий; всё остальное — autosave.
* - Агентские ревизии визуально отличаются: квадратный глиф роли (К/Ф) вместо
* круглого аватара пользователя + «via <кто запустил>».
* - Календарь объединён со списком в одной панели (мини-календарь = date jumper).
* - Highlight changes + навигация по изменениям (N of M, ↑/↓) вынесены в шапку.
* - Текущая версия помечена; Restore для неё недоступен.
*
* ─────────────────────────────────────────────────────────────────────────
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
* ─────────────────────────────────────────────────────────────────────────
* import { PageHistoryModal, Revision } from './PageHistoryModal';
*
* <PageHistoryModal
* opened={open}
* onClose={() => setOpen(false)}
* revisions={revisions} // Revision[]
* selectedId={selId}
* onSelect={setSelId} // клик по ревизии → рендер справа
* renderVersion={(rev) => <ArticleView versionId={rev.id} />} // ваш рендер страницы
* highlightChanges={hl}
* onToggleHighlight={setHl}
* changeNav={{ index: 1, total: 3, onPrev, onNext }} // навигация по диффу
* onlySaved={onlySaved}
* onToggleOnlySaved={setOnlySaved}
* onRestore={(rev) => api.restore(rev.id)} // async; текущая версия → disabled
* />
*
* Требует MantineProvider на корне приложения (defaultColorScheme="auto" для тем).
*
* ─────────────────────────────────────────────────────────────────────────
* ФОРМАТ ДАННЫХ
* ─────────────────────────────────────────────────────────────────────────
* interface Revision {
* id: string;
* at: Date | string; // время ревизии; форматируется вызывающим/util-ом
* dayGroup: string; // 'Today' | 'Yesterday' | 'Mon 12 Jul' — заголовок группы
* saved: boolean; // true → бейдж SAVED; false → autosave (без бейджа)
* author: { name: string } & (
* | { kind: 'human' }
* | { kind: 'agent'; role: string; triggeredBy: string } // роль + кто запустил
* );
* isCurrent?: boolean; // текущая (последняя) версия — Restore недоступен
* }
*
* Ревизии приходят плоским массивом; группировка по dayGroup — на клиенте.
* Никаких изменений API не требуется: агентское авторство берётся из author.kind.
*
* ─────────────────────────────────────────────────────────────────────────
* СОСТОЯНИЯ (нарисованы/поддержаны)
* ─────────────────────────────────────────────────────────────────────────
* - Ревизия: обычная / hover / выбранная / текущая / агентская / человеческая.
* - Restore: default / disabled (выбрана текущая) / loading (спиннер после клика).
* - Highlight changes: on/off; при off навигация по изменениям неактивна.
* - Пустая история: одна версия / нет ревизий → EmptyState.
* - Тёмная тема: через токены Mantine (useMantineColorScheme, var(--mantine-*)).
*/
import { useMemo, useState, useCallback } from 'react';
import {
Modal, Box, Group, Stack, Text, Switch, Avatar, Badge, Button, ActionIcon,
ScrollArea, useMantineTheme, useMantineColorScheme,
} from '@mantine/core';
/* ─────────────────────────── Типы ─────────────────────────── */
export type Author =
| { name: string; kind: 'human' }
| { name: string; kind: 'agent'; role: string; triggeredBy: string };
export interface Revision {
id: string;
at: Date | string;
atLabel: string; // предформатированное «5:35AM»
dayGroup: string; // «Today» | «Yesterday» | «Mon 12 Jul»
saved: boolean;
author: Author;
isCurrent?: boolean;
}
export interface CalendarDay {
label: string; // «12»
inMonth: boolean;
count: number; // число ревизий за день (для heatmap)
selected?: boolean;
date: Date | string;
}
export interface PageHistoryModalProps {
opened: boolean;
onClose: () => void;
revisions: Revision[];
selectedId: string | null;
onSelect: (id: string) => void;
renderVersion: (rev: Revision | null) => React.ReactNode;
highlightChanges: boolean;
onToggleHighlight: (v: boolean) => void;
changeNav?: { index: number; total: number; onPrev: () => void; onNext: () => void };
onlySaved: boolean;
onToggleOnlySaved: (v: boolean) => void;
onRestore: (rev: Revision) => Promise<void>;
/** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */
calendar?: {
monthLabel: string; // «July 2025»
weekdays: string[]; // ['Mon',…,'Sun']
days: CalendarDay[]; // обычно 35/42 ячейки
onPrevMonth: () => void;
onNextMonth: () => void;
onToday: () => void;
onPickDay: (d: CalendarDay) => void;
};
}
/* ─────────────────────────── Утилиты представления ─────────────────────────── */
const USER_PALETTE = ['#495057', '#e8590c', '#1c7ed6', '#7048e8', '#0ca678'];
function userColor(name: string) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return USER_PALETTE[h % USER_PALETTE.length];
}
const AGENT_GRAD: Record<string, string> = {
'Корректор': 'linear-gradient(135deg,#20c997,#12b886)',
'Фактчекер': 'linear-gradient(135deg,#4c6ef5,#7048e8)',
};
function agentGrad(role: string) { return AGENT_GRAD[role] ?? 'linear-gradient(135deg,#868e96,#495057)'; }
/** heatmap: число ревизий → фон/текст ячейки календаря. */
function heat(n: number, dark: boolean) {
if (n === 0) return { bg: 'transparent', fg: dark ? '#5c5f66' : '#adb5bd' };
if (n <= 2) return { bg: dark ? 'rgba(34,139,230,.20)' : '#e7f0ff', fg: dark ? '#74c0fc' : '#1971c2' };
if (n <= 4) return { bg: dark ? 'rgba(34,139,230,.45)' : '#a5c8ff', fg: dark ? '#dbeafe' : '#0b3d91' };
return { bg: '#4c8dff', fg: '#ffffff' };
}
/* ─────────────────────────── Строка ревизии ─────────────────────────── */
function RevisionRow({ rev, selected, onSelect }: { rev: Revision; selected: boolean; onSelect: () => void }) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
const a = rev.author;
const isAgent = a.kind === 'agent';
return (
<Group
gap={8} wrap="nowrap" h={28} px="12px 14px" m="1px 6px"
onClick={onSelect}
style={{
cursor: 'pointer', borderRadius: 7,
background: selected ? (dark ? 'rgba(34,139,230,.14)' : '#eef4ff')
: isAgent ? (dark ? 'rgba(112,72,232,.06)' : '#fbfaff') : undefined,
}}
>
{/* аватар/глиф */}
{isAgent ? (
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, background: agentGrad(a.role), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
{a.role[0]}
</Box>
) : (
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: '50%', background: userColor(a.name), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
{a.name[0].toUpperCase()}
</Box>
)}
{/* время */}
<Text fz={12.5} fw={rev.isCurrent ? 600 : 500} c={rev.isCurrent ? undefined : 'dimmed'} style={{ flex: 'none', minWidth: 58 }}>
{rev.atLabel}
</Text>
{/* автор + via */}
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0, alignItems: 'baseline', overflow: 'hidden' }}>
<Text fz={12} fw={isAgent ? 600 : 400} c={isAgent ? undefined : 'dimmed'} truncate style={{ flex: 'none', maxWidth: 100 }}>
{isAgent ? a.role : a.name}
</Text>
{isAgent && <Text fz={11} c="dimmed" truncate>· via {a.triggeredBy}</Text>}
</Group>
{/* бейдж — только SAVED */}
{rev.saved && <Badge size="sm" radius="sm" variant="light" color="blue">SAVED</Badge>}
</Group>
);
}
/* ─────────────────────────── Мини-календарь ─────────────────────────── */
function MiniCalendar({ cal }: { cal: NonNullable<PageHistoryModalProps['calendar']> }) {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
return (
<Box p="10px 12px 8px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Group gap={6} mb={6}>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onPrevMonth}></ActionIcon>
<Text fz={12} fw={600}>{cal.monthLabel}</Text>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onNextMonth}></ActionIcon>
<Box style={{ flex: 1 }} />
<Button variant="subtle" size="compact-xs" onClick={cal.onToday}>Today</Button>
</Group>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, marginBottom: 2 }}>
{cal.weekdays.map((w) => (
<Text key={w} ta="center" fz={8.5} fw={600} c="dimmed">{w}</Text>
))}
</Box>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2 }}>
{cal.days.map((d, i) => {
const h = heat(d.count, dark);
return (
<Box
key={i} onClick={() => cal.onPickDay(d)}
style={{
position: 'relative', height: 26, borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: d.inMonth ? h.bg : 'transparent',
boxShadow: d.selected ? `inset 0 0 0 2px ${dark ? '#f1f3f5' : '#1a1b1e'}` : undefined,
}}
>
<Text fz={10.5} fw={d.selected ? 700 : 500} style={{ color: d.inMonth ? h.fg : (dark ? '#3a3d42' : '#dee2e6') }}>
{d.label}
</Text>
</Box>
);
})}
</Box>
{/* легенда heatmap */}
<Group gap={6} mt={8} align="center">
<Text fz={9.5} c="dimmed">fewer</Text>
{['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => (
<Box key={c} style={{ width: 14, height: 10, borderRadius: 2, background: c }} />
))}
<Text fz={9.5} c="dimmed">more revisions</Text>
</Group>
</Box>
);
}
/* ─────────────────────────── Окно ─────────────────────────── */
export function PageHistoryModal(props: PageHistoryModalProps) {
const {
opened, onClose, revisions, selectedId, onSelect, renderVersion,
highlightChanges, onToggleHighlight, changeNav, onlySaved, onToggleOnlySaved,
onRestore, calendar,
} = props;
const [restoring, setRestoring] = useState(false);
const selected = revisions.find((r) => r.id === selectedId) ?? null;
const isEmpty = revisions.length <= 1;
// группировка по dayGroup, с фильтром Only saved
const groups = useMemo(() => {
const list = onlySaved ? revisions.filter((r) => r.saved) : revisions;
const map: { head: string; items: Revision[] }[] = [];
for (const r of list) {
let g = map.find((x) => x.head === r.dayGroup);
if (!g) { g = { head: r.dayGroup, items: [] }; map.push(g); }
g.items.push(r);
}
return map;
}, [revisions, onlySaved]);
const restore = useCallback(async () => {
if (!selected || selected.isCurrent) return;
setRestoring(true);
try { await onRestore(selected); } finally { setRestoring(false); }
}, [selected, onRestore]);
return (
<Modal
opened={opened} onClose={onClose} withCloseButton={false}
size="80rem" radius="lg" padding={0}
styles={{ body: { height: '80vh', maxHeight: 760, display: 'flex', flexDirection: 'column' } }}
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}
>
{/* ── single-row toolbar ── */}
<Group gap={14} h={60} px={16} wrap="nowrap" style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Text fz={16} fw={600}>Page history</Text>
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
<Stack gap={1} style={{ minWidth: 0 }}>
<Text fz={12} fw={600} truncate>{selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'}</Text>
<Text fz={10.5} c="dimmed">selected version</Text>
</Stack>
<Box style={{ flex: 1 }} />
{/* diff cluster */}
<Group gap={2} p={3} wrap="nowrap" style={(t) => ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}>
<Button variant="white" size="compact-sm" onClick={() => onToggleHighlight(!highlightChanges)}
leftSection={<Switch checked={highlightChanges} onChange={() => {}} size="xs" tabIndex={-1} styles={{ track: { cursor: 'pointer' } }} />}
styles={{ root: { boxShadow: '0 1px 2px rgba(0,0,0,.08)' } }}>
Highlight changes
</Button>
{changeNav && (
<>
<Text fz={12} fw={600} c="dimmed" px={6}>{changeNav.index} / {changeNav.total}</Text>
<Box style={{ display: 'flex' }}>
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onPrev} style={{ width: 26 }}></ActionIcon>
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onNext} style={{ width: 26 }}></ActionIcon>
</Box>
</>
)}
</Group>
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
<Button color="blue" onClick={restore} loading={restoring} disabled={!selected || selected.isCurrent}>
Restore this version
</Button>
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}></ActionIcon>
</Group>
{/* ── body ── */}
<Box style={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* left: nav panel */}
<Stack gap={0} style={(t) => ({ flex: 'none', width: 280, minHeight: 0, borderRight: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`, background: t.colorScheme === 'dark' ? t.colors.dark[7] : '#fcfcfd' })}>
<Group h={44} px={16} style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Switch checked={onlySaved} onChange={(e) => onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} />
</Group>
{calendar && <MiniCalendar cal={calendar} />}
<ScrollArea style={{ flex: 1 }}>
{groups.map((g) => (
<Box key={g.head}>
<Text px={16} pt={9} pb={5} fz={10.5} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.05em', position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
{g.head}
</Text>
{g.items.map((r) => (
<RevisionRow key={r.id} rev={r} selected={r.id === selectedId} onSelect={() => onSelect(r.id)} />
))}
</Box>
))}
</ScrollArea>
</Stack>
{/* right: rendered version */}
<ScrollArea style={{ flex: 1, minWidth: 0 }} bg="var(--mantine-color-default-hover)">
{isEmpty ? (
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
<Text fw={600} fz={14}>No earlier versions</Text>
<Text fz={12.5} c="dimmed" ta="center">У страницы пока одна версия сравнивать не с чем.</Text>
</Stack>
) : (
<Box p="26px 0" maw={660} mx="auto">
{renderVersion(selected)}
</Box>
)}
</ScrollArea>
</Box>
</Modal>
);
}
export default PageHistoryModal;
@@ -346,6 +346,9 @@ roles:
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
appendix at the end of the document or clearly separated from the report
body.
□ Once the report is complete, call save_page_version to pin the finished
document as a restorable named version (a checkpoint the reader can
return to). A repeat call after no further edits is a harmless no-op.
Be honest about gaps. If you couldn't find something, say so — don't disguise
a guess as a fact.
autoStart: false
@@ -442,6 +445,7 @@ roles:
- **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin).
- **Don't evaluate the participants** and don't comment on the quality of the discussion.
- The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks.
- **Pin the result.** Once the notes are complete on the page, call save_page_version to save them as a restorable named version (a checkpoint). Calling it again after no further edits is a harmless no-op.
## Style example (excerpt)
@@ -345,6 +345,9 @@ roles:
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
an appendix at the end of the document or clearly separated from the
report body.
□ Once the report is complete, call save_page_version to pin the finished
document as a restorable named version (a checkpoint the reader can
return to). A repeat call after no further edits is a harmless no-op.
Be honest about gaps. If you couldn't find something, say so — don't disguise
a guess as a fact.
autoStart: false
@@ -441,6 +444,7 @@ roles:
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
- **Не оценивай участников** и не комментируй качество обсуждения.
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
- **Зафиксируй результат.** Когда конспект готов на странице, вызови save_page_version, чтобы сохранить его как именованную версию (точку восстановления). Повторный вызов без новых правок — безвредный no-op.
## Пример стиля (фрагмент)
+2 -2
View File
@@ -33,6 +33,6 @@ bundles:
- en
roles:
- slug: researcher
version: 9
version: 10
- slug: call-summarizer
version: 1
version: 2
@@ -1,4 +1,7 @@
{
"1 year": "1 year",
"30 days": "30 days",
"90 days": "90 days",
"A new version is available": "A new version is available",
"Account": "Account",
"Active": "Active",
@@ -10,11 +13,16 @@
"Add space members": "Add space members",
"Add to favorites": "Add to favorites",
"Admin": "Admin",
"API key created": "API key created",
"API key revoked": "API key revoked",
"API keys across the workspace.": "API keys across the workspace.",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.",
"Author": "Author",
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
"Can create and edit pages in space.": "Can create and edit pages in space.",
"Can edit": "Can edit",
@@ -33,11 +41,14 @@
"Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
"Create": "Create",
"Create API key": "Create API key",
"Create group": "Create group",
"Create page": "Create page",
"Create space": "Create space",
"Create workspace": "Create workspace",
"Critical": "Critical",
"Current password": "Current password",
"Dark": "Dark",
"Date": "Date",
@@ -55,7 +66,14 @@
"e.g Sales": "e.g Sales",
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"e.g. CI deploy token": "e.g. CI deploy token",
"Edit": "Edit",
"Expiring soon": "Expiring soon",
"Failed to create API key": "Failed to create API key",
"Failed to load API keys.": "Failed to load API keys.",
"Failed to revoke API key": "Failed to revoke API key",
"Never used": "Never used",
"No API keys yet": "No API keys yet",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
@@ -108,6 +126,7 @@
"Link copied": "Link copied",
"Login": "Login",
"Logout": "Logout",
"Major": "Major",
"Manage Group": "Manage Group",
"Manage members": "Manage members",
"member": "member",
@@ -134,6 +153,8 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"Revoke API key": "Revoke API key",
"Revoke {{name}}": "Revoke {{name}}",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -189,6 +210,9 @@
"Template": "Template",
"Templates": "Templates",
"Theme": "Theme",
"This key expires within 30 days": "This key expires within 30 days",
"This key has expired": "This key has expired",
"This key never expires": "This key never expires",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
@@ -196,6 +220,7 @@
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
"User": "User",
"Within the last hour": "Within the last hour",
"Workspace": "Workspace",
"Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings",
@@ -426,9 +451,12 @@
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
"Write...": "Write...",
"Column count": "Column count",
"Your personal API keys.": "Your personal API keys.",
"{{count}} Columns": "{{count}} Columns",
"{{count}} command available_one": "1 command available",
"{{count}} command available_other": "{{count}} commands available",
"{{count}} edits": "{{count}} edits",
"{{count}} major": "{{count}} major",
"{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results available",
"{{count}} result found_one": "{{count}} result found",
@@ -1440,6 +1468,7 @@
"agent: {{value}}": "agent: {{value}}",
"Work": "Work",
"Agent": "Agent",
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
"≈ {{hours}}h": "≈ {{hours}}h",
"≈ {{minutes}}m": "≈ {{minutes}}m",
@@ -1,4 +1,7 @@
{
"1 year": "1 год",
"30 days": "30 дней",
"90 days": "90 дней",
"A new version is available": "Доступна новая версия",
"Account": "Аккаунт",
"Active": "Активный",
@@ -10,11 +13,16 @@
"Add space members": "Добавить участников пространства",
"Add to favorites": "Добавить в избранное",
"Admin": "Администратор",
"API key created": "API ключ создан",
"API key revoked": "API ключ отозван",
"API keys across the workspace.": "API ключи всего рабочего пространства.",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Вы уверены, что хотите отозвать «{{name}}»? Любой клиент, использующий этот ключ, немедленно потеряет доступ. Это действие нельзя отменить.",
"Author": "Автор",
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
"Can edit": "Может изменять",
@@ -33,11 +41,14 @@
"Confirm": "Подтвердить",
"Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку",
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Скопируйте API ключ сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.",
"Create": "Создать",
"Create API key": "Создать API ключ",
"Create group": "Создать группу",
"Create page": "Создать страницу",
"Create space": "Создать пространство",
"Create workspace": "Создать рабочую область",
"Critical": "Критично",
"Current password": "Текущий пароль",
"Dark": "Темная",
"Date": "Дата",
@@ -46,6 +57,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
"Description": "Описание",
"Details": "Подробности",
"Done": "Готово",
"e.g ACME": "например, ACME",
"e.g ACME Inc": "например, ACME Inc",
"e.g Developers": "например, Developers",
@@ -55,7 +67,15 @@
"e.g Sales": "например, Sales",
"e.g Space for product team": "например, Пространство для команды продукта",
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
"e.g. CI deploy token": "например, токен деплоя CI",
"Edit": "Редактировать",
"Expiring soon": "Скоро истекает",
"Failed to create API key": "Не удалось создать API ключ",
"Failed to load API keys.": "Не удалось загрузить API ключи.",
"Failed to revoke API key": "Не удалось отозвать API ключ",
"Name is required": "Имя обязательно",
"Never used": "Не использовался",
"No API keys yet": "Пока нет API ключей",
"Read": "Чтение",
"Edit group": "Редактировать группу",
"Email": "Электронная почта",
@@ -108,6 +128,7 @@
"Link copied": "Ссылка скопирована",
"Login": "Войти",
"Logout": "Выйти",
"Major": "Существенно",
"Manage Group": "Управление группой",
"Manage members": "Управление участниками",
"member": "участник",
@@ -134,6 +155,8 @@
"page": "страница",
"Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы",
"Revoke API key": "Отозвать API ключ",
"Revoke {{name}}": "Отозвать {{name}}",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
@@ -189,6 +212,9 @@
"Template": "Шаблон",
"Templates": "Шаблоны",
"Theme": "Тема",
"This key expires within 30 days": "Этот ключ истекает в течение 30 дней",
"This key has expired": "Этот ключ истек",
"This key never expires": "Этот ключ никогда не истекает",
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
"Toggle full page width": "Переключить полную ширину страницы",
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
@@ -196,6 +222,7 @@
"Untitled": "Без названия",
"Updated successfully": "Успешно обновлено",
"User": "Пользователь",
"Within the last hour": "За последний час",
"Workspace": "Рабочее пространство",
"Workspace Name": "Название рабочего пространства",
"Workspace settings": "Настройки рабочего пространства",
@@ -432,9 +459,12 @@
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд",
"Write...": "Напишите...",
"Column count": "Количество столбцов",
"Your personal API keys.": "Ваши личные API ключи.",
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
"{{count}} command available_one": "Доступна 1 команда",
"{{count}} command available_other": "Доступно {{count}} команд",
"{{count}} edits": "{count, plural, one{# правка} few{# правки} many{# правок} other{# правки}}",
"{{count}} major": "{count, plural, one{# существенная} few{# существенных} many{# существенных} other{# существенных}}",
"{{count}} result available_one": "Доступен 1 результат",
"{{count}} result available_other": "Доступно {{count}} результатов",
"{{count}} result found_one": "Найден {{count}} результат",
@@ -1455,6 +1485,7 @@
"agent: {{value}}": "агент: {{value}}",
"Work": "Работа",
"Agent": "Агент",
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
"≈ {{hours}}h": "≈ {{hours}} ч",
"≈ {{minutes}}m": "≈ {{minutes}} мин",
+7
View File
@@ -44,6 +44,12 @@ const AccountSettings = lazy(
const AccountPreferences = lazy(
() => import("@/pages/settings/account/account-preferences.tsx"),
);
// #506 — lazy leaf (own chunk): the API-keys management page is route-split so
// its code (Mantine table/modals + the create/revoke flow) stays out of the
// entry bundle (post-#342 bundle discipline).
const AccountApiKeys = lazy(
() => import("@/pages/settings/account/account-api-keys.tsx"),
);
const WorkspaceSettings = lazy(
() => import("@/pages/settings/workspace/workspace-settings"),
);
@@ -105,6 +111,7 @@ export default function App() {
path={"account/preferences"}
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<AccountApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
@@ -10,6 +10,7 @@ import {
IconBrush,
IconWorld,
IconSparkles,
IconKey,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -46,6 +47,11 @@ const groupedData: DataGroup[] = [
icon: IconBrush,
path: "/settings/account/preferences",
},
{
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
},
],
},
{
@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
render,
screen,
fireEvent,
waitFor,
within,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider, createStore } from "jotai";
import { UserRole } from "@/lib/types";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { IApiKey } from "@/features/api-key/types/api-key.types";
// Mock the service layer so no real HTTP is attempted; every test drives the
// component through these three functions.
vi.mock("@/features/api-key/services/api-key-service", () => ({
getApiKeys: vi.fn(),
createApiKey: vi.fn(),
revokeApiKey: vi.fn(),
}));
import {
getApiKeys,
createApiKey,
revokeApiKey,
} from "@/features/api-key/services/api-key-service";
import ApiKeysManager from "./api-keys-manager";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const ISO_SOON = new Date(Date.now() + 10 * 864e5).toISOString();
const ISO_FAR = new Date(Date.now() + 200 * 864e5).toISOString();
const ISO_EXPIRED = new Date(Date.now() - 3 * 864e5).toISOString();
// Dump the storage stub via the Web Storage API — its data lives in a closure
// (see vitest.setup.ts), so JSON.stringify(localStorage) would be vacuous.
function storageDump(): string {
let out = "";
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i) as string;
out += `${k}=${localStorage.getItem(k)};`;
}
return out;
}
function makeKey(overrides: Partial<IApiKey> = {}): IApiKey {
return {
id: "key-1",
name: "CI token",
expiresAt: ISO_FAR,
lastUsedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null },
...overrides,
};
}
function renderManager(role: UserRole) {
const store = createStore();
store.set(currentUserAtom, {
user: { id: "me", role } as never,
workspace: {} as never,
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const utils = render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineProvider>
<ModalsProvider>
<ApiKeysManager />
</ModalsProvider>
</MantineProvider>
</QueryClientProvider>
</Provider>,
);
return { store, queryClient, ...utils };
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe("ApiKeysManager — list rendering", () => {
it("renders an explicit expiry date and highlights a <30-day key (acceptance #3)", async () => {
vi.mocked(getApiKeys).mockResolvedValue([
makeKey({ id: "k-soon", name: "Soon key", expiresAt: ISO_SOON }),
makeKey({ id: "k-far", name: "Far key", expiresAt: ISO_FAR }),
]);
renderManager(UserRole.MEMBER);
await screen.findByText("Soon key");
// Explicit dates, not "in N days": the year is rendered verbatim.
const soonYear = new Date(ISO_SOON).getFullYear().toString();
expect(screen.getAllByText(new RegExp(soonYear)).length).toBeGreaterThan(0);
// Exactly one key is inside the 30-day warning window.
expect(screen.getAllByText("Expiring soon")).toHaveLength(1);
});
it('shows "Expired" (not "Expiring soon") for an already-expired key', async () => {
vi.mocked(getApiKeys).mockResolvedValue([
makeKey({ id: "k-dead", name: "Dead key", expiresAt: ISO_EXPIRED }),
]);
renderManager(UserRole.MEMBER);
await screen.findByText("Dead key");
// A past expiry is labelled "Expired", never the forward-looking badge.
expect(screen.getByText("Expired")).toBeDefined();
expect(screen.queryByText("Expiring soon")).toBeNull();
});
it('shows "Never" for an unlimited key and no highlight', async () => {
vi.mocked(getApiKeys).mockResolvedValue([
makeKey({ id: "k-forever", name: "Forever", expiresAt: null }),
]);
renderManager(UserRole.MEMBER);
await screen.findByText("Forever");
expect(screen.getByText("Never")).toBeDefined();
expect(screen.queryByText("Expiring soon")).toBeNull();
});
it("empty list shows the empty state", async () => {
vi.mocked(getApiKeys).mockResolvedValue([]);
renderManager(UserRole.MEMBER);
expect(await screen.findByText("No API keys yet")).toBeDefined();
});
});
describe("ApiKeysManager — admin vs member view (acceptance #6)", () => {
it("a member does NOT see the author column", async () => {
vi.mocked(getApiKeys).mockResolvedValue([
makeKey({ creator: { id: "me", name: "Me", email: "m@x.io", avatarUrl: null } }),
]);
renderManager(UserRole.MEMBER);
await screen.findByText("CI token");
// Author header absent + creator name not rendered (no author column).
expect(screen.queryByText("Author")).toBeNull();
expect(screen.queryByText("Me")).toBeNull();
});
it("an admin sees the author column with the creator's name", async () => {
vi.mocked(getApiKeys).mockResolvedValue([
makeKey({ creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null } }),
]);
renderManager(UserRole.ADMIN);
await screen.findByText("CI token");
expect(screen.getByText("Author")).toBeDefined();
expect(screen.getByText("Alice")).toBeDefined();
});
});
describe("ApiKeysManager — revoke (acceptance #4)", () => {
it("revoke removes the row from the list", async () => {
vi.mocked(getApiKeys)
.mockResolvedValueOnce([
makeKey({ id: "k1", name: "Doomed" }),
makeKey({ id: "k2", name: "Survivor" }),
])
// After revoke, invalidation refetches the reduced list.
.mockResolvedValue([makeKey({ id: "k2", name: "Survivor" })]);
vi.mocked(revokeApiKey).mockResolvedValue();
renderManager(UserRole.MEMBER);
await screen.findByText("Doomed");
fireEvent.click(screen.getByLabelText("Revoke Doomed"));
// Confirm modal → click the destructive confirm button.
const confirm = await screen.findByRole("button", { name: "Revoke" });
fireEvent.click(confirm);
await waitFor(() =>
expect(screen.queryByText("Doomed")).toBeNull(),
);
expect(screen.getByText("Survivor")).toBeDefined();
expect(revokeApiKey).toHaveBeenCalledWith("k1");
});
});
describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
it("shows the token once, then discards it from the UI, localStorage and query cache", async () => {
const SECRET = "gm_secret-token-value-xyz";
vi.mocked(getApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({
token: SECRET,
apiKey: {
id: "new-1",
name: "My key",
expiresAt: ISO_FAR,
createdAt: new Date().toISOString(),
},
});
const { queryClient } = renderManager(UserRole.MEMBER);
await screen.findByText("No API keys yet");
// Open create modal (use the header CTA), fill the name, submit.
fireEvent.click(
screen.getAllByRole("button", { name: "Create API key" })[0],
);
const nameInput = await screen.findByLabelText(/Name/);
fireEvent.change(nameInput, { target: { value: "My key" } });
fireEvent.click(screen.getByRole("button", { name: "Create" }));
// Token is shown exactly once in the show-once modal.
const tokenEl = await screen.findByTestId("api-key-token");
expect(tokenEl.textContent).toBe(SECRET);
// Close the modal → token discarded from the DOM.
fireEvent.click(screen.getByRole("button", { name: "Done" }));
await waitFor(() =>
expect(screen.queryByTestId("api-key-token")).toBeNull(),
);
// Acceptance #2: the secret is nowhere in localStorage or the react-query
// caches (query cache never carried it; the mutation copy was reset()).
// Non-vacuous: currentUser IS in storage, so the dump is exercised.
const dump = storageDump();
expect(dump).toContain("currentUser");
expect(dump).not.toContain(SECRET);
const cacheDump = JSON.stringify(
queryClient.getQueryCache().getAll().map((q) => q.state.data),
);
expect(cacheDump).not.toContain(SECRET);
const mutationDump = JSON.stringify(
queryClient.getMutationCache().getAll().map((m) => m.state.data),
);
expect(mutationDump).not.toContain(SECRET);
});
});
@@ -0,0 +1,264 @@
import { useState } from "react";
import {
ActionIcon,
Badge,
Button,
Center,
Group,
Loader,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { IconAlertTriangle, IconKey, IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
import { timeAgo } from "@/lib/time";
import {
useApiKeysQuery,
useCreateApiKeyMutation,
useRevokeApiKeyMutation,
} from "@/features/api-key/queries/api-key-query";
import {
IApiKey,
ICreateApiKeyResponse,
} from "@/features/api-key/types/api-key.types";
import {
isExpired,
isExpiringSoon,
lastUsedBucket,
} from "@/features/api-key/utils";
import { CreateApiKeyModal } from "./create-api-key-modal";
import { ShowTokenModal } from "./show-token-modal";
export default function ApiKeysManager() {
const { t } = useTranslation();
const locale = useDateFnsLocale();
// Manage-on-API === Owner/Admin server-side, so the admin (workspace-wide)
// list + "author" column mirror exactly the roles the server serves the
// whole-workspace response to.
const { isAdmin } = useUserRole();
const { data: keys, isLoading, isError } = useApiKeysQuery();
const createMutation = useCreateApiKeyMutation();
const revokeMutation = useRevokeApiKeyMutation();
const [createOpened, setCreateOpened] = useState(false);
// SECURITY: the show-once token lives ONLY here, in this component's local
// state. It is never written to localStorage, the query cache or a log. Closing
// the modal sets it back to null (handleCloseToken) — discarded forever.
const [createdKey, setCreatedKey] = useState<ICreateApiKeyResponse | null>(
null,
);
const handleCreate = async (values: {
name: string;
expiresAt: string | null;
}): Promise<boolean> => {
try {
const res = await createMutation.mutateAsync(values);
setCreateOpened(false);
// Move the token into local state, then immediately purge react-query's
// own copy of the mutation result so the secret does not linger in the
// mutation cache.
setCreatedKey(res);
createMutation.reset();
return true;
} catch {
notifications.show({
message: t("Failed to create API key"),
color: "red",
});
return false;
}
};
const handleCloseToken = () => {
// Token discarded — the only copy the UI ever held is dropped here.
setCreatedKey(null);
};
const openRevokeModal = (key: IApiKey) =>
modals.openConfirmModal({
title: t("Revoke API key"),
centered: true,
children: (
<Text size="sm">
{t(
'Are you sure you want to revoke "{{name}}"? Any client using this key will immediately lose access. This cannot be undone.',
{ name: key.name },
)}
</Text>
),
labels: { confirm: t("Revoke"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => revokeMutation.mutate(key.id),
});
const formatDate = (iso: string) =>
formatLocalized(new Date(iso), "MMM dd, yyyy", "PP", locale);
const renderLastUsed = (lastUsedAt: string | null) => {
switch (lastUsedBucket(lastUsedAt)) {
case "never":
return t("Never used");
case "recent":
return t("Within the last hour");
case "stale":
// Coarse relative time — last_used_at is throttled to ~1h server-side,
// so we don't promise finer precision.
return timeAgo(new Date(lastUsedAt as string));
}
};
if (isLoading) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
const rows = (keys ?? []).map((key) => {
// Mutually exclusive: an already-expired key is labelled "Expired" (a past
// expiry) rather than the forward-looking "Expiring soon". isExpiringSoon
// also matches past expiries, so gate "soon" on !expired.
const expired = isExpired(key.expiresAt);
const soon = !expired && isExpiringSoon(key.expiresAt);
return (
<Table.Tr key={key.id}>
<Table.Td>
<Text fw={500}>{key.name}</Text>
</Table.Td>
<Table.Td>{formatDate(key.createdAt)}</Table.Td>
<Table.Td>
{key.expiresAt ? (
<Group gap={6} wrap="nowrap">
<Text size="sm">{formatDate(key.expiresAt)}</Text>
{expired && (
<Tooltip label={t("This key has expired")} withArrow>
<Badge
color="red"
variant="light"
size="sm"
leftSection={<IconAlertTriangle size={12} />}
>
{t("Expired")}
</Badge>
</Tooltip>
)}
{soon && (
<Tooltip
label={t("This key expires within 30 days")}
withArrow
>
<Badge
color="orange"
variant="light"
size="sm"
leftSection={<IconAlertTriangle size={12} />}
>
{t("Expiring soon")}
</Badge>
</Tooltip>
)}
</Group>
) : (
<Text size="sm" c="dimmed">
{t("Never")}
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{renderLastUsed(key.lastUsedAt)}
</Text>
</Table.Td>
{isAdmin && (
<Table.Td>
<Text size="sm">
{key.creator?.name ?? key.creator?.email ?? t("Unknown")}
</Text>
</Table.Td>
)}
<Table.Td style={{ textAlign: "right" }}>
<Tooltip label={t("Revoke")} withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke {{name}}", { name: key.name })}
onClick={() => openRevokeModal(key)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
);
});
return (
<>
<Group justify="space-between" mb="md">
<Text size="sm" c="dimmed">
{isAdmin
? t("API keys across the workspace.")
: t("Your personal API keys.")}
</Text>
<Button
leftSection={<IconKey size={16} />}
onClick={() => setCreateOpened(true)}
>
{t("Create API key")}
</Button>
</Group>
{isError ? (
<Text c="red" size="sm">
{t("Failed to load API keys.")}
</Text>
) : rows.length === 0 ? (
<Stack align="center" gap="xs" py="xl">
<IconKey size={32} opacity={0.4} />
<Text c="dimmed">{t("No API keys yet")}</Text>
<Button
variant="light"
leftSection={<IconKey size={16} />}
onClick={() => setCreateOpened(true)}
>
{t("Create API key")}
</Button>
</Stack>
) : (
<Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
{isAdmin && <Table.Th>{t("Author")}</Table.Th>}
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<CreateApiKeyModal
opened={createOpened}
onClose={() => setCreateOpened(false)}
onSubmit={handleCreate}
loading={createMutation.isPending}
/>
<ShowTokenModal created={createdKey} onClose={handleCloseToken} />
</>
);
}
@@ -0,0 +1,104 @@
import { Button, Group, Modal, Select, Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useTranslation } from "react-i18next";
import {
ApiKeyLifetime,
DEFAULT_LIFETIME,
lifetimeToExpiresAt,
} from "@/features/api-key/utils";
interface Props {
opened: boolean;
onClose: () => void;
// Resolves the create request, returning true on success. The parent owns the
// mutation (and the token it returns); this modal only collects the name +
// lifetime. On failure (false) the form state is kept so the user can retry.
onSubmit: (values: {
name: string;
expiresAt: string | null;
}) => Promise<boolean>;
loading?: boolean;
}
interface FormValues {
name: string;
lifetime: ApiKeyLifetime;
}
export function CreateApiKeyModal({
opened,
onClose,
onSubmit,
loading,
}: Props) {
const { t } = useTranslation();
const form = useForm<FormValues>({
initialValues: {
name: "",
lifetime: DEFAULT_LIFETIME,
},
validate: {
name: (value) =>
value.trim().length === 0 ? t("Name is required") : null,
},
});
const lifetimeOptions: { value: ApiKeyLifetime; label: string }[] = [
{ value: "30d", label: t("30 days") },
{ value: "90d", label: t("90 days") },
{ value: "1y", label: t("1 year") },
{ value: "never", label: t("No expiration") },
];
const handleSubmit = form.onSubmit(async (values) => {
const ok = await onSubmit({
name: values.name.trim(),
expiresAt: lifetimeToExpiresAt(values.lifetime),
});
// Reset only after a successful submit so a failed create keeps the form
// state (the parent surfaces the error via a notification).
if (ok) form.reset();
});
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API key")}
centered
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label={t("Name")}
placeholder={t("e.g. CI deploy token")}
data-autofocus
withAsterisk
{...form.getInputProps("name")}
/>
<Select
label={t("Expiration")}
data={lifetimeOptions}
allowDeselect={false}
checkIconPosition="right"
{...form.getInputProps("lifetime")}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={handleClose} type="button">
{t("Cancel")}
</Button>
<Button type="submit" loading={loading}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,107 @@
import {
Alert,
Button,
Code,
Group,
Modal,
Stack,
Text,
} from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { CopyButton } from "@/components/common/copy-button";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
import { ICreateApiKeyResponse } from "@/features/api-key/types/api-key.types";
interface Props {
// The freshly-created key incl. its token. Owned by the parent; this modal
// only renders it and never copies it into its own persistent state.
created: ICreateApiKeyResponse | null;
// Closing MUST discard the token in the parent (set the `created` prop back to
// null) — the token is shown exactly once.
onClose: () => void;
}
export function ShowTokenModal({ created, onClose }: Props) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const expiresAt = created?.apiKey.expiresAt ?? null;
return (
<Modal
opened={created !== null}
onClose={onClose}
title={t("API key created")}
centered
// No dismiss-on-outside-click: the token is irretrievable, so closing is a
// deliberate act (the user confirms they have saved it).
closeOnClickOutside={false}
>
{created && (
<Stack gap="sm">
<Alert
color="orange"
icon={<IconAlertTriangle size={18} />}
variant="light"
>
{t(
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
)}
</Alert>
<div>
<Text size="sm" c="dimmed" mb={4}>
{t("Token")}
</Text>
<Group gap="xs" wrap="nowrap" align="flex-start">
<Code
block
data-testid="api-key-token"
style={{ flex: 1, wordBreak: "break-all" }}
>
{created.token}
</Code>
<CopyButton value={created.token}>
{({ copied, copy }) => (
<Button
variant="light"
size="xs"
color={copied ? "teal" : "blue"}
leftSection={
copied ? (
<IconCheck size={16} />
) : (
<IconCopy size={16} />
)
}
onClick={copy}
>
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>
</Group>
</div>
<Text size="sm" c="dimmed">
{expiresAt
? t("Expires {{date}}", {
date: formatLocalized(
new Date(expiresAt),
"MMM dd, yyyy",
"PP",
locale,
),
})
: t("This key never expires")}
</Text>
<Group justify="flex-end" mt="xs">
<Button onClick={onClose}>{t("Done")}</Button>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -0,0 +1,66 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
createApiKey,
getApiKeys,
revokeApiKey,
} from "@/features/api-key/services/api-key-service";
import {
IApiKey,
ICreateApiKey,
ICreateApiKeyResponse,
} from "@/features/api-key/types/api-key.types";
export const API_KEYS_QUERY_KEY = ["api-keys"];
export function useApiKeysQuery(): UseQueryResult<IApiKey[], Error> {
return useQuery({
queryKey: API_KEYS_QUERY_KEY,
queryFn: () => getApiKeys(),
});
}
/**
* Create mutation.
*
* SECURITY: the response contains the token exactly once. This hook deliberately
* does NOT stash it anywhere — the caller reads it from `mutateAsync`'s resolved
* value, moves it into the show-once modal's local state, then calls
* `mutation.reset()` to purge react-query's own copy immediately. `gcTime: 0`
* is a second belt so nothing lingers in the mutation cache after the observer
* unmounts. The list is invalidated here (the list carries no token).
*/
export function useCreateApiKeyMutation() {
const queryClient = useQueryClient();
return useMutation<ICreateApiKeyResponse, Error, ICreateApiKey>({
mutationFn: (data) => createApiKey(data),
gcTime: 0,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
},
});
}
export function useRevokeApiKeyMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => revokeApiKey(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
notifications.show({ message: t("API key revoked") });
},
onError: () => {
notifications.show({
message: t("Failed to revoke API key"),
color: "red",
});
},
});
}
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the api-client (axios instance). vi.mock replaces the whole module, so
// the real response interceptor — which returns `response.data`, i.e. the
// server envelope { data, success, status } — is bypassed. Our mocked post/get
// therefore resolve to that post-interceptor envelope shape directly, and the
// service under test reads `.data` off it to unwrap the inner payload. This is
// the one bug-prone line the component tests (which fully mock the service)
// never exercise.
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
vi.mock("@/lib/api-client", () => ({
default: { post, get },
}));
import {
createApiKey,
getApiKeys,
} from "@/features/api-key/services/api-key-service";
beforeEach(() => {
vi.clearAllMocks();
});
describe("api-key-service response-contract unwrap", () => {
it("createApiKey unwraps the envelope to { token, apiKey }", async () => {
const payload = {
token: "gm_secret-abc",
apiKey: {
id: "key-1",
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
createdAt: "2026-07-11T12:00:00.000Z",
},
};
// The server envelope as the interceptor hands it to the service.
post.mockResolvedValue({ data: payload, success: true, status: 200 });
const result = await createApiKey({
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
});
// The inner payload only — not the { data, success, status } wrapper.
expect(result).toEqual(payload);
expect(result.token).toBe("gm_secret-abc");
expect(result.apiKey).toEqual(payload.apiKey);
expect(post).toHaveBeenCalledWith("/api-keys/create", {
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
});
});
it("getApiKeys unwraps the envelope to the array of rows", async () => {
const rows = [
{
id: "key-1",
name: "CI token",
expiresAt: null,
lastUsedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
},
{
id: "key-2",
name: "Deploy token",
expiresAt: "2027-01-01T00:00:00.000Z",
lastUsedAt: null,
createdAt: "2026-02-01T00:00:00.000Z",
},
];
post.mockResolvedValue({ data: rows, success: true, status: 200 });
const result = await getApiKeys();
expect(result).toEqual(rows);
expect(result).toHaveLength(2);
expect(post).toHaveBeenCalledWith("/api-keys/list");
});
});
@@ -0,0 +1,30 @@
import api from "@/lib/api-client";
import {
IApiKey,
ICreateApiKey,
ICreateApiKeyResponse,
} from "@/features/api-key/types/api-key.types";
// Mint a new key. The response carries the token ONCE — the caller must move it
// straight into the show-once modal's local state and never cache it. See
// queries/api-key-query.ts (gcTime: 0 + query invalidation) and
// components/api-keys-manager.tsx `handleCreate` (createMutation.reset() right
// after reading the token) for the reset()-after-read pattern.
export async function createApiKey(
data: ICreateApiKey,
): Promise<ICreateApiKeyResponse> {
const res = await api.post<ICreateApiKeyResponse>("/api-keys/create", data);
return res.data as ICreateApiKeyResponse;
}
// List the caller's keys (or, for an admin, every key in the workspace with
// creator attribution). Never returns token material.
export async function getApiKeys(): Promise<IApiKey[]> {
const res = await api.post<IApiKey[]>("/api-keys/list");
return res.data as IApiKey[];
}
// Revocation is server-side immediate; the caller drops the row on success.
export async function revokeApiKey(id: string): Promise<void> {
await api.post("/api-keys/revoke", { id });
}
@@ -0,0 +1,48 @@
// Compact creator attribution embedded in the admin (workspace-wide) list. A
// normal member's list only ever contains their own keys, so the field is
// present but redundant; the author column is only rendered for admins.
export interface IApiKeyCreator {
id: string;
name: string;
email: string;
avatarUrl: string | null;
}
// A single api-key row as returned by `POST /api/api-keys/list`. Note: the list
// NEVER carries token material — only metadata.
export interface IApiKey {
id: string;
name: string;
// ISO string, or null for an unlimited ("never expires") key.
expiresAt: string | null;
// ISO string, or null if the key was never used. Throttled to ~1h server-side
// (#501), so the UI must not promise sub-hour precision.
lastUsedAt: string | null;
createdAt: string;
creator?: IApiKeyCreator | null;
}
// Payload for `POST /api/api-keys/create`. `expiresAt`: an ISO date string for a
// bounded lifetime, or null for an unlimited key. (undefined would let the
// server apply its 1-year default, but the form always sends an explicit value.)
export interface ICreateApiKey {
name: string;
expiresAt: string | null;
}
// The metadata half of the create response. The token itself is carried
// separately (see ICreateApiKeyResponse) and is shown exactly once.
export interface ICreatedApiKey {
id: string;
name: string;
expiresAt: string | null;
createdAt: string;
}
// Response of `POST /api/api-keys/create`. `token` is the ONLY time the secret
// is ever returned — it must live only in the show-once modal's local state and
// must never be cached, persisted or logged.
export interface ICreateApiKeyResponse {
token: string;
apiKey: ICreatedApiKey;
}
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_LIFETIME,
EXPIRY_WARNING_DAYS,
isExpired,
isExpiringSoon,
lastUsedBucket,
lifetimeToExpiresAt,
} from "./utils";
const NOW = new Date("2026-07-11T12:00:00.000Z");
const daysFromNow = (n: number) =>
new Date(NOW.getTime() + n * 24 * 60 * 60 * 1000).toISOString();
describe("lifetimeToExpiresAt", () => {
it("default lifetime is 1 year (acceptance #7)", () => {
expect(DEFAULT_LIFETIME).toBe("1y");
const iso = lifetimeToExpiresAt("1y", NOW);
expect(iso).toBe("2027-07-11T12:00:00.000Z");
});
it('"never" sends null (acceptance #7)', () => {
expect(lifetimeToExpiresAt("never", NOW)).toBeNull();
});
it("30d / 90d map to the exact future instant", () => {
expect(lifetimeToExpiresAt("30d", NOW)).toBe(daysFromNow(30));
expect(lifetimeToExpiresAt("90d", NOW)).toBe(daysFromNow(90));
});
});
describe("isExpiringSoon (acceptance #3 highlight)", () => {
it("an unlimited key is never 'soon'", () => {
expect(isExpiringSoon(null, NOW)).toBe(false);
});
it("highlights a key expiring within the 30-day window", () => {
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(true);
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
});
it("does not highlight a key well outside the window", () => {
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS + 1), NOW)).toBe(
false,
);
expect(isExpiringSoon(daysFromNow(200), NOW)).toBe(false);
});
it("an already-expired key is highlighted", () => {
expect(isExpiringSoon(daysFromNow(-3), NOW)).toBe(true);
});
});
describe("isExpired (past vs future expiry)", () => {
it("an unlimited key is never expired", () => {
expect(isExpired(null, NOW)).toBe(false);
});
it("a past expiry is expired", () => {
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
expect(isExpired(daysFromNow(-1), NOW)).toBe(true);
});
it("a future expiry is not expired (even within the warning window)", () => {
expect(isExpired(daysFromNow(1), NOW)).toBe(false);
expect(isExpired(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(false);
expect(isExpired(daysFromNow(200), NOW)).toBe(false);
});
it("an expiry exactly at 'now' counts as expired (boundary is inclusive)", () => {
expect(isExpired(NOW.toISOString(), NOW)).toBe(true);
});
it("is mutually distinguishable from isExpiringSoon: expired vs soon-but-future", () => {
// A key 3 days in the past: expired, and (by design) also matches
// isExpiringSoon — the UI resolves this by checking isExpired first.
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
// A key 10 days in the future: NOT expired, but expiring soon.
expect(isExpired(daysFromNow(10), NOW)).toBe(false);
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
});
});
describe("lastUsedBucket (within-the-last-hour semantics)", () => {
const minutesAgo = (n: number) =>
new Date(NOW.getTime() - n * 60 * 1000).toISOString();
it("null last-used is 'never'", () => {
expect(lastUsedBucket(null, NOW)).toBe("never");
});
it("under an hour is 'recent' (no sub-hour precision promised)", () => {
expect(lastUsedBucket(minutesAgo(5), NOW)).toBe("recent");
expect(lastUsedBucket(minutesAgo(59), NOW)).toBe("recent");
});
it("over an hour is 'stale'", () => {
expect(lastUsedBucket(minutesAgo(90), NOW)).toBe("stale");
});
});
+76
View File
@@ -0,0 +1,76 @@
import { addDays, addYears, differenceInMinutes } from "date-fns";
// The single early-warning window (#501/#506): keys whose expiry is closer than
// this are visually highlighted in the list. Also used to classify a key as
// already-expired (a negative "days until" is < 30 too).
export const EXPIRY_WARNING_DAYS = 30;
// last_used_at is throttled to ~1h server-side, so anything under an hour is
// shown as the coarse "within the last hour" rather than a false-precise
// "5 minutes ago".
export const LAST_USED_THROTTLE_MINUTES = 60;
export type ApiKeyLifetime = "30d" | "90d" | "1y" | "never";
export const DEFAULT_LIFETIME: ApiKeyLifetime = "1y";
// Maps a lifetime choice to the `expiresAt` payload sent to the server: an ISO
// string for a bounded lifetime, or null for an explicit unlimited key.
export function lifetimeToExpiresAt(
lifetime: ApiKeyLifetime,
now: Date = new Date(),
): string | null {
switch (lifetime) {
case "30d":
return addDays(now, 30).toISOString();
case "90d":
return addDays(now, 90).toISOString();
case "1y":
return addYears(now, 1).toISOString();
case "never":
return null;
}
}
// True when a bounded key's expiry is already in the past (or exactly now). An
// unlimited key (null) is never expired. This is distinct from "expiring soon":
// the two states are mutually exclusive at the call site (see api-keys-manager),
// so an already-expired key is labelled "Expired", not "Expiring soon".
export function isExpired(
expiresAt: string | null,
now: Date = new Date(),
): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) return false;
return expiry <= now.getTime();
}
// True when a bounded key expires within the warning window (or is already
// expired). An unlimited key (null) is never "expiring soon". Callers that need
// to distinguish an already-past expiry should check isExpired() first, as this
// predicate deliberately also covers the already-expired case.
export function isExpiringSoon(
expiresAt: string | null,
now: Date = new Date(),
): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) return false;
const msLeft = expiry - now.getTime();
return msLeft < EXPIRY_WARNING_DAYS * 24 * 60 * 60 * 1000;
}
// Classifies last-used recency so the caller can pick the honest label without
// promising sub-hour precision. Returns "never", "recent" (< 1h) or "stale".
export function lastUsedBucket(
lastUsedAt: string | null,
now: Date = new Date(),
): "never" | "recent" | "stale" {
if (!lastUsedAt) return "never";
const used = new Date(lastUsedAt);
if (Number.isNaN(used.getTime())) return "never";
return differenceInMinutes(now, used) < LAST_USED_THROTTLE_MINUTES
? "recent"
: "stale";
}
@@ -0,0 +1,284 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { createInstance } from "i18next";
import { initReactI18next, I18nextProvider } from "react-i18next";
import { IComment } from "@/features/comment/types/comment.types";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// The suggestion mutations reach react-query/network — stub them so the card
// renders in isolation. We assert the Apply/Dismiss gating and that the click
// hands the {commentId, pageId} pair to the existing mutation unchanged.
const applyMutateAsync = vi.fn();
const dismissMutateAsync = vi.fn();
vi.mock("@/features/comment/queries/comment-query", () => ({
useApplySuggestionMutation: () => ({
mutateAsync: applyMutateAsync,
isPending: false,
}),
useDismissSuggestionMutation: () => ({
mutateAsync: dismissMutateAsync,
isPending: false,
}),
}));
// CommentContentView -> mention-view -> page-query/share-query pull in the app
// entry (createRoot) as a side effect; stub the queries so the card renders in
// isolation.
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useSharePageQuery: () => ({ data: undefined }),
}));
// space-query.ts -> main.tsx (createRoot) is a module side effect reached via the
// mention view; stub it so importing the card is side-effect free.
vi.mock("@/features/space/queries/space-query.ts", () => ({
useSpaceQuery: () => ({ data: undefined }),
useGetSpaceBySlugQuery: () => ({ data: undefined }),
}));
import AgentEditCard, { RunHeader } from "./agent-edit-card";
const body = (text: string) =>
JSON.stringify({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
const edit = (over?: Partial<IComment>): IComment =>
({
id: "c-1",
content: body("[Существенно] tighten the wording"),
creatorId: "user-1",
pageId: "page-1",
workspaceId: "ws-1",
createdAt: new Date(),
createdSource: "agent",
aiChatId: "chat-1",
agent: { name: "Corrector", emoji: "✏️", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
creator: { id: "user-1", name: "Corrector", avatarUrl: null } as any,
selection: "old wording here",
suggestedText: "new wording here",
...over,
}) as IComment;
function renderCard(
comment: IComment,
canEdit = true,
canComment = true,
userSpaceRole?: string,
) {
return render(
<MantineProvider>
<AgentEditCard
comment={comment}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</MantineProvider>,
);
}
describe("AgentEditCard — suggested edit diff + Apply (#315)", () => {
it("renders the было→стало diff and an Apply button when canEdit, not applied/resolved", () => {
const { container } = renderCard(edit(), true);
// Both diff lines are present (old struck-through, new added).
expect(container.textContent).toContain("old wording here");
expect(container.textContent).toContain("new wording here");
// Diff line signs (aria-hidden) present for a replacement.
expect(container.textContent).toContain("−");
expect(container.textContent).toContain("+");
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
});
it("hides Apply when canEdit is false (still shows the diff)", () => {
const { container } = renderCard(edit(), false);
expect(container.textContent).toContain("new wording here");
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides Apply once the thread is resolved", () => {
renderCard(edit({ resolvedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides Apply once suggestionAppliedAt is set", () => {
renderCard(edit({ suggestionAppliedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("shows the Applied badge for an applied suggestion, not a pending one (F1)", () => {
// Pending: no Applied badge.
const { unmount } = renderCard(edit(), true);
expect(screen.queryByText("Applied")).toBeNull();
unmount();
// Applied (kept alive by replies -> resolved, #329): the badge is restored.
renderCard(edit({ suggestionAppliedAt: new Date() }), true);
expect(screen.getByText("Applied")).toBeDefined();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("calls the apply mutation with {commentId, pageId} on click", () => {
applyMutateAsync.mockClear();
renderCard(edit(), true);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(applyMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
it("a pure insertion (empty selection) hides the removed line", () => {
const { container } = renderCard(
edit({ selection: "", suggestedText: "added text" }),
true,
);
// No "−" del sign — nothing was removed.
expect(container.textContent).not.toContain("−");
expect(container.textContent).toContain("+");
});
it("a pure deletion (empty suggestedText) hides the added line", () => {
const { container } = renderCard(
edit({ selection: "removed text", suggestedText: "" }),
true,
);
expect(container.textContent).not.toContain("+");
expect(container.textContent).toContain("−");
});
});
describe("AgentEditCard — Dismiss gate (#329/#338)", () => {
it("shows Dismiss alongside Apply for an admin who can edit/comment", () => {
renderCard(edit(), true, true, "admin");
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
renderCard(edit(), false, true, "admin");
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("hides Dismiss for a non-owner non-admin (mirrors server 403, #338 F5)", () => {
renderCard(edit(), false, true, "member");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("hides Dismiss when the viewer cannot comment", () => {
renderCard(edit(), false, false, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("calls the dismiss mutation with {commentId, pageId} on click", () => {
dismissMutateAsync.mockClear();
renderCard(edit(), true, true, "admin");
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(dismissMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
});
describe("AgentEditCard — provenance", () => {
it("renders the agent avatar stack (provenance) for the edit author (#300)", () => {
renderCard(edit(), true);
// The agent role name is shown by the provenance stack.
expect(screen.getAllByText("Corrector").length).toBeGreaterThan(0);
});
// The owner-or-admin gate uses the currentUser atom; clear localStorage so a
// previous test's seed never leaks into the non-owner assertions above.
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
});
// The RunHeader's "N edits · M major" is built with interpolated t() keys; the
// default (uninitialized) react-i18next t returns the key verbatim WITHOUT
// interpolating, so a numeric assertion needs a real, initialised i18n instance.
// This isolated instance carries just the two count keys with escapeValue off so
// "{{count}}" is substituted and the rendered numbers are assertable.
const headerI18n = createInstance();
headerI18n.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
resources: {
en: {
translation: {
"{{count}} edits": "{{count}} edits",
"{{count}} major": "{{count}} major",
},
},
},
interpolation: { escapeValue: false },
});
const runComment = (id: string, tag: string): IComment =>
edit({
id,
content: body(`${tag} some rationale`),
});
function renderRunHeader(comments: IComment[]) {
return render(
<I18nextProvider i18n={headerI18n}>
<MantineProvider>
<RunHeader comments={comments} />
</MantineProvider>
</I18nextProvider>,
);
}
describe("RunHeader — agent-run series header (F3)", () => {
// 5 edits: 2 critical + 1 major + 1 minor + 1 unknown(verdict) => 3 "major".
const series = () => [
runComment("e1", "[Критично]"),
runComment("e2", "[Критично]"),
runComment("e3", "[Существенно]"),
runComment("e4", "[Незначительно]"),
runComment("e5", "[Неверно]"),
];
it("shows the total edit count", () => {
renderRunHeader(series());
expect(screen.getByText(/5 edits/)).toBeDefined();
});
it("counts ONLY major+critical as major (not minor/unknown)", () => {
renderRunHeader(series());
// 2 critical + 1 major = 3; minor and the [Неверно] verdict are excluded.
expect(screen.getByText(/3 major/)).toBeDefined();
// Non-vacuous: the wrong tally (counting all 5, or 4) must NOT appear.
expect(screen.queryByText(/5 major/)).toBeNull();
expect(screen.queryByText(/4 major/)).toBeNull();
});
it("omits the major segment entirely when there are no significant edits", () => {
renderRunHeader([
runComment("e1", "[Незначительно]"),
runComment("e2", "[Неверно]"),
]);
expect(screen.getByText(/2 edits/)).toBeDefined();
expect(screen.queryByText(/major/)).toBeNull();
});
it("renders the provenance line (agent role name)", () => {
renderRunHeader(series());
expect(screen.getAllByText("Corrector").length).toBeGreaterThan(0);
});
it("renders NO 'Accept all' control (rejected by product)", () => {
renderRunHeader(series());
expect(screen.queryByText(/accept all/i)).toBeNull();
expect(
screen.queryByRole("button", { name: /accept all/i }),
).toBeNull();
});
});
@@ -0,0 +1,424 @@
import React, { useMemo } from "react";
import {
Badge,
Box,
Button,
Group,
Stack,
Text,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import CommentContentView from "@/features/comment/components/comment-content-view";
import { IComment } from "@/features/comment/types/comment.types";
import {
canShowApply,
canShowDismiss,
computeSuggestionDiff,
Segment,
} from "@/features/comment/utils/suggestion";
import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
import {
isSignificant,
parseSeverity,
Severity,
} from "@/features/comment/utils/severity";
import {
useApplySuggestionMutation,
useDismissSuggestionMutation,
} from "@/features/comment/queries/comment-query";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTimeAgo } from "@/hooks/use-time-ago";
// Scroll the document to this comment's inline mark and flash it — the same
// anchor navigation the old panel used (handleCommentClick). Used both by a card
// click and by an explicit "Go to text" affordance.
function scrollToCommentMark(commentId: string) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${commentId}"]`,
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("comment-highlight");
setTimeout(() => el.classList.remove("comment-highlight"), 3000);
}
}
// Make otherwise-invisible edited whitespace legible inside a highlighted diff
// fragment (a pure-space insertion/deletion would otherwise look like nothing
// changed).
function visibleWhitespace(s: string): string {
return s.replace(/ /g, "␣").replace(/\t/g, "⇥");
}
// One line of the intraline diff. `kind==="del"` is the old ("было") line drawn
// red with a strike-through; `kind==="ins"` is the new ("стало") line drawn
// green. Only the `changed` segments carry the emphasised mark background — the
// common segments read as plain context (git/GitHub-style, #331).
function DiffLine({
segments,
kind,
}: {
segments: Segment[];
kind: "del" | "ins";
}) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const isDel = kind === "del";
const sign = isDel ? "−" : "+";
const signColor = isDel
? theme.colors.red[dark ? 5 : 6]
: theme.colors.green[dark ? 5 : 6];
const baseColor = isDel
? dark
? theme.colors.gray[5]
: theme.colors.gray[6]
: dark
? theme.colors.gray[1]
: theme.colors.dark[9];
const markBg = isDel
? dark
? "rgba(224,49,49,.22)"
: "#ffe3e3"
: dark
? "rgba(47,158,68,.22)"
: "#d3f9d8";
const markFg = isDel
? dark
? theme.colors.red[3]
: theme.colors.red[8]
: dark
? theme.colors.green[3]
: theme.colors.green[9];
return (
<Group gap={7} wrap="nowrap" align="flex-start">
<Text
ff="monospace"
fw={600}
fz={12}
c={signColor}
w={11}
ta="center"
aria-hidden
style={{ flex: "none", lineHeight: 1.5 }}
>
{sign}
</Text>
<Text fz={13.5} c={baseColor} style={{ lineHeight: 1.45 }}>
{segments.map((seg, i) =>
seg.changed ? (
<Box
key={i}
component="mark"
fw={600}
style={{
background: markBg,
color: markFg,
borderRadius: 2,
// Snug intra-word highlight: a hair of horizontal padding keeps
// the mark background slightly wider than the glyph, canceled by
// an equal negative margin so neighbouring letters are NOT pushed
// apart. The old px={3} padding made single-letter edits (е→ё,
// х→е) look like they had spaces around the changed character.
padding: "0 1px",
margin: "0 -1px",
textDecoration: isDel ? "line-through" : "none",
}}
>
{visibleWhitespace(seg.text)}
</Box>
) : (
<Box
key={i}
component="span"
style={{ textDecoration: isDel ? "line-through" : "none" }}
>
{seg.text}
</Box>
),
)}
</Text>
</Group>
);
}
// The "было → стало" block. A pure insertion (empty `before`) hides the old
// line; a pure deletion (empty `after`) hides the new line.
function DiffBlock({ before, after }: { before: Segment[]; after: Segment[] }) {
const pureInsert = before.length === 0;
const pureDelete = after.length === 0;
return (
<Stack gap={1}>
{!pureInsert && <DiffLine segments={before} kind="del" />}
{!pureDelete && <DiffLine segments={after} kind="ins" />}
</Stack>
);
}
const SEV_DOT: Record<Severity, { color: string; shade: number }> = {
critical: { color: "red", shade: 6 },
major: { color: "orange", shade: 6 },
minor: { color: "gray", shade: 5 },
// A neutral, deliberately faint dot — NOT the minor grey — so an untagged or
// verdict-only edit never reads as a graded severity.
unknown: { color: "gray", shade: 3 },
};
function SeverityDot({ severity }: { severity: Severity }) {
return (
<Box
w={8}
h={8}
aria-hidden
style={(t) => ({
flex: "none",
borderRadius: "50%",
background: t.colors[SEV_DOT[severity].color][SEV_DOT[severity].shade],
})}
/>
);
}
interface AgentEditCardProps {
comment: IComment;
canComment: boolean;
canEdit?: boolean;
userSpaceRole?: string;
// Whether to render the per-card agent avatar + timestamp. A card inside a
// collapsed run omits it (the RunHeader carries the one provenance line);
// a standalone card shows it (#300 provenance must be visible on the card).
showProvenance?: boolean;
}
// Type A — the diff-first agent suggested-edit card. It carries NO TipTap editor
// (a perf win vs. the old row, #340); the body renders through the static
// CommentContentView. Apply/Dismiss reuse the existing mutations and gates
// verbatim — the reskin changes presentation only, not the 409/404/400 toast
// semantics or the authz gating.
function AgentEditCard({
comment,
canComment,
canEdit,
userSpaceRole,
showProvenance = true,
}: AgentEditCardProps) {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const applySuggestionMutation = useApplySuggestionMutation();
const dismissSuggestionMutation = useDismissSuggestionMutation();
const createdAtAgo = useTimeAgo(comment.createdAt);
const diff = useMemo(
() =>
computeSuggestionDiff(comment.selection ?? "", comment.suggestedText ?? ""),
[comment.selection, comment.suggestedText],
);
const severity = useMemo(
() => parseSeverity(commentContentToText(comment.content)),
[comment.content],
);
// Owner-or-space-admin gate (#338), mirrored from the old row so we never
// render a Dismiss the server would 403.
const isOwnerOrAdmin =
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
const isApplied = comment.suggestionAppliedAt != null;
const showApply = canShowApply(comment, canEdit);
const showDismiss = canShowDismiss(comment, canComment, isOwnerOrAdmin);
const pending =
applySuggestionMutation.isPending || dismissSuggestionMutation.isPending;
const handleApply = async () => {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors (incl. 409 "text changed") surface via the mutation's onError.
console.error("Failed to apply suggestion:", error);
}
};
const handleDismiss = async () => {
try {
await dismissSuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Idempotent 404 is reconciled to success in the mutation's onError.
console.error("Failed to dismiss suggestion:", error);
}
};
const sevLabel =
severity === "critical"
? t("Critical")
: severity === "major"
? t("Major")
: null;
return (
<Box
p="10px 12px"
role="button"
tabIndex={0}
aria-label={t("Jump to comment selection")}
onClick={() => scrollToCommentMark(comment.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
scrollToCommentMark(comment.id);
}
}}
style={{ cursor: "pointer" }}
>
<Stack gap={8}>
{showProvenance && comment.agent && (
<Group
gap={8}
wrap="nowrap"
justify="space-between"
onClick={(e) => e.stopPropagation()}
>
<AgentAvatarStack
agent={comment.agent}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
/>
<Text size="xs" c="dimmed" style={{ flex: "none" }}>
{createdAtAgo}
</Text>
</Group>
)}
<DiffBlock before={diff.old} after={diff.new} />
{/* Agent rationale — rendered through the static ProseMirror view, not
restructured into a flat string. */}
<Box fz="xs" c="dimmed">
<CommentContentView content={comment.content} />
</Box>
<Group gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<SeverityDot severity={severity} />
{sevLabel && (
<Text
fz={10}
fw={600}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: ".06em", flex: 1 }}
>
{sevLabel}
</Text>
)}
<Box style={{ flex: 1 }} />
{/* Applied state (#315): a suggestion that was applied but kept alive by
its replies (so #329 resolved instead of hard-deleting it) still
shows it was applied — the badge the pre-redesign card carried. A
childless applied suggestion is gone from the list entirely, so this
only renders in the Resolved tab. */}
{isApplied && (
<Badge
size="sm"
color="green"
variant="light"
aria-label={t("Applied")}
>
{t("Applied")}
</Badge>
)}
{showDismiss && (
<Button
size="compact-sm"
variant="default"
color="gray"
loading={dismissSuggestionMutation.isPending}
disabled={pending}
onClick={handleDismiss}
>
{t("Dismiss")}
</Button>
)}
{showApply && (
<Button
size="compact-sm"
color="green"
loading={applySuggestionMutation.isPending}
disabled={pending}
onClick={handleApply}
>
{t("Apply")}
</Button>
)}
</Group>
</Stack>
</Box>
);
}
// Series header for a collapsed agent run: ONE provenance line for N edits,
// with a "N edits · M major" tally. There is intentionally NO "Accept all"
// button / progress / Stop (rejected by product) and NO "K applied" counter
// (uncomputable after the #329 hard-delete). Purely visual.
export function RunHeader({ comments }: { comments: IComment[] }) {
const { t } = useTranslation();
const head = comments[0];
const createdAtAgo = useTimeAgo(head?.createdAt);
const majors = useMemo(
() =>
comments.filter((c) =>
isSignificant(parseSeverity(commentContentToText(c.content))),
).length,
[comments],
);
if (!head) return null;
return (
<Box
p="11px 13px"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Group gap={10} wrap="nowrap" justify="space-between">
{head.agent ? (
<AgentAvatarStack
agent={head.agent}
launcher={head.launcher}
aiChatId={head.aiChatId}
/>
) : (
<Text size="sm" fw={600}>
{head.creator?.name}
</Text>
)}
<Text size="xs" c="dimmed" style={{ flex: "none" }}>
{createdAtAgo}
</Text>
</Group>
<Text fz={11.5} c="dimmed" mt={4}>
{t("{{count}} edits", { count: comments.length })}
{majors > 0 && (
<>
{" · "}
<Text span c="orange.7" fw={600} inherit>
{t("{{count}} major", { count: majors })}
</Text>
</>
)}
</Text>
</Box>
);
}
export default React.memo(AgentEditCard);
@@ -156,125 +156,6 @@ describe("CommentListItem — agent avatar stack", () => {
// only guards the insertion gate (agent → stack, user → no stack).
});
describe("CommentListItem — suggested edit (#315)", () => {
const suggestion = (over?: Partial<IComment>): IComment =>
baseComment({
selection: "old wording here",
suggestedText: "new wording here",
...over,
});
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
const { container } = renderItem(suggestion(), true);
// Old text appears as the selection quote (a single unsplit Text node).
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
// The new line is now rendered as per-fragment spans (intraline diff, #331),
// so it is no longer a single text node — assert the concatenated content.
expect(container.textContent).toContain("new wording here");
// Apply button is present.
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
// No Applied badge yet.
expect(screen.queryByText("Applied")).toBeNull();
});
it("hides the Apply button when canEdit is false", () => {
const { container } = renderItem(suggestion(), false);
// Diff still renders (as per-fragment spans, #331)...
expect(container.textContent).toContain("new wording here");
// ...but no Apply button.
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
expect(screen.getByText("Applied")).toBeDefined();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides the Apply button once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("calls the apply mutation when the Apply button is clicked", () => {
applyMutateAsync.mockClear();
renderItem(suggestion(), true);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(applyMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
it("does not render the diff block for a reply (child) comment", () => {
renderItem(
suggestion({ parentCommentId: "c-0" }),
true,
);
expect(screen.queryByText("new wording here")).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
});
describe("CommentListItem — dismiss suggestion (#329)", () => {
const suggestion = (over?: Partial<IComment>): IComment =>
baseComment({
selection: "old wording here",
suggestedText: "new wording here",
...over,
});
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
// regardless of who authored the comment; the tests below use it as the lever
// since the currentUser atom is unseeded (null) in this harness.
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
renderItem(suggestion(), true, true, "admin");
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
renderItem(suggestion(), false, true, "admin");
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("hides Dismiss when the viewer cannot comment", () => {
renderItem(suggestion(), false, false, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
// canComment=true but NOT a space admin and NOT the comment owner (the
// currentUser atom is null while the comment is authored by user-1), so the
// server would 403 a dismiss — the button must not be shown at all.
renderItem(suggestion(), false, true, "member");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("hides Dismiss once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("hides Dismiss (shows the Applied badge) once applied", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
expect(screen.getByText("Applied")).toBeDefined();
});
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
dismissMutateAsync.mockClear();
renderItem(suggestion(), true, true, "admin");
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(dismissMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
});
describe("canShowApply predicate", () => {
const c = (over?: Partial<IComment>): IComment =>
({ suggestedText: "x", ...over }) as IComment;
@@ -1,6 +1,6 @@
import { Group, Text, Box, Badge, Button } from "@mantine/core";
import { Group, Text, Box } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import React, { useMemo, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { useTimeAgo } from "@/hooks/use-time-ago";
@@ -12,18 +12,11 @@ import CommentMenu from "@/features/comment/components/comment-menu";
import ResolveComment from "@/features/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
useApplySuggestionMutation,
useDeleteCommentMutation,
useDismissSuggestionMutation,
useResolveCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import {
canShowApply,
canShowDismiss,
computeSuggestionDiff,
} from "@/features/comment/utils/suggestion";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
@@ -32,13 +25,21 @@ interface CommentListItemProps {
comment: IComment;
pageId: string;
canComment: boolean;
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
// allowed to comment cannot apply edits).
// Real page-edit permission (page.permissions.canEdit). Kept on the props for
// parity with the container's wiring even though the thread row itself no
// longer renders the suggestion Apply button (that moved to AgentEditCard).
canEdit?: boolean;
userSpaceRole?: string;
}
// Type B — the thread ROW. Renders a single human OR agent-without-edit comment
// in the redesigned visual: provenance avatar, author + timeago, hover-revealed
// resolve + edit/delete menu, the anchored selection quote, and the body through
// the static CommentContentView (or the inline TipTap editor while editing). It
// is used for both a top-level thread comment and, recursively, each reply row.
// ALL wiring (update/delete/resolve mutations, owner/admin gate, anchor nav) is
// the same logic the old row carried — only the presentation changed, and the
// agent suggested-edit block was lifted out into AgentEditCard.
function CommentListItem({
comment,
pageId,
@@ -55,29 +56,20 @@ function CommentListItem({
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const applySuggestionMutation = useApplySuggestionMutation();
const dismissSuggestionMutation = useDismissSuggestionMutation();
const [currentUser] = useAtom(currentUserAtom);
const createdAtAgo = useTimeAgo(comment.createdAt);
// Intraline "before -> after" diff (#331) for a suggested edit: only the
// fragments that actually changed get emphasised inside the red/green block,
// instead of striking through / greening the whole line. Memoised on the
// (selection, suggestedText) pair so it recomputes only when they change.
const suggestionDiff = useMemo(
() =>
comment.suggestedText != null
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
: null,
[comment.selection, comment.suggestedText],
);
// `canEdit`/`pageId` are threaded through for wiring parity with the container;
// the thread row does not itself gate on them (Apply lives on AgentEditCard).
void canEdit;
void pageId;
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
// render an action the server will 403.
// Owner-or-space-admin gate (#338): mirrors the server authz for the comment
// menu (edit/delete), so we never render an action the server will 403.
const isOwnerOrAdmin =
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
const isAgent = comment.createdSource === "agent" && !!comment.agent;
async function handleUpdateComment() {
try {
@@ -121,34 +113,9 @@ function CommentListItem({
}
}
async function handleApplySuggestion() {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors surface via the mutation's onError notification (incl. 409).
console.error("Failed to apply suggestion:", error);
}
}
async function handleDismissSuggestion() {
try {
await dismissSuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Idempotent races are reconciled to success in the mutation's onError;
// anything else surfaces there as a notification.
console.error("Failed to dismiss suggestion:", error);
}
}
function handleCommentClick(comment: IComment) {
function handleCommentClick(target: IComment) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
`.comment-mark[data-comment-id="${target.id}"]`,
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -169,10 +136,10 @@ function CommentListItem({
return (
<Box ref={ref} pb={6}>
<Group gap="xs">
{comment.createdSource === "agent" && comment.agent ? (
<Group gap="xs" wrap="nowrap" align="flex-start">
{isAgent ? (
<AgentAvatarStack
agent={comment.agent}
agent={comment.agent!}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
showName={false}
@@ -185,13 +152,13 @@ function CommentListItem({
/>
)}
<div style={{ flex: 1 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
{comment.createdSource === "agent" && comment.agent ? (
{isAgent ? (
<>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{comment.agent.name}
{comment.agent!.name}
</Text>
{comment.launcher && (
<>
@@ -262,87 +229,6 @@ function CommentListItem({
</Box>
)}
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
carrying a suggestion. Old text struck-through/red, new text green. */}
{!comment.parentCommentId && comment.suggestedText && (
<Box className={classes.suggestionBlock}>
{comment.selection && (
// Old line: read as removed as a whole (line-through/red); only the
// changed fragments carry the extra intraline emphasis.
<Text size="xs" className={classes.suggestionOld}>
{suggestionDiff?.old.map((segment, index) => (
<span
key={index}
className={segment.changed ? classes.suggestionChanged : undefined}
>
{segment.text}
</span>
))}
</Text>
)}
<Text size="xs" className={classes.suggestionNew}>
{suggestionDiff?.new.map((segment, index) => (
<span
key={index}
className={segment.changed ? classes.suggestionChanged : undefined}
>
{segment.text}
</span>
))}
</Text>
{comment.suggestionAppliedAt ? (
<Badge
size="sm"
color="green"
variant="light"
mt={6}
aria-label={t("Applied")}
>
{t("Applied")}
</Badge>
) : (
(canShowApply(comment, canEdit) ||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
<Group gap="xs" mt={6}>
{canShowApply(comment, canEdit) && (
<Button
size="compact-xs"
variant="light"
color="green"
onClick={handleApplySuggestion}
loading={applySuggestionMutation.isPending}
disabled={
applySuggestionMutation.isPending ||
dismissSuggestionMutation.isPending
}
>
{t("Apply")}
</Button>
)}
{/* Dismiss ("Не применять", #329): removes the suggestion
without changing the page text. Gated on canComment. */}
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={handleDismissSuggestion}
loading={dismissSuggestionMutation.isPending}
disabled={
applySuggestionMutation.isPending ||
dismissSuggestionMutation.isPending
}
>
{t("Dismiss")}
</Button>
)}
</Group>
)
)}
</Box>
)}
{!isEditing ? (
<CommentContentView content={comment.content} />
) : (
@@ -350,7 +236,9 @@ function CommentListItem({
<CommentEditor
defaultContent={comment.content}
editable={true}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
onUpdate={(newContent: any) => {
editContentRef.current = newContent;
}}
onSave={handleUpdateComment}
autofocus={true}
/>
@@ -5,7 +5,7 @@ import {
Center,
Divider,
Group,
Paper,
Box,
Stack,
Tabs,
Badge,
@@ -14,6 +14,9 @@ import {
Tooltip,
} from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import AgentEditCard, {
RunHeader,
} from "@/features/comment/components/agent-edit-card";
import {
useCommentsQuery,
useCreateCommentMutation,
@@ -22,6 +25,10 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import {
groupAgentRuns,
CommentRenderUnit,
} from "@/features/comment/utils/group-agent-runs";
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
@@ -69,6 +76,32 @@ export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
);
}
// The redesigned card shell: a rounded, bordered surface on the panel body. Both
// a thread card and an agent-run group live inside one of these.
function PanelCard({
children,
...rest
}: {
children: React.ReactNode;
[key: string]: unknown;
}) {
return (
<Box
m="8px 10px"
p={0}
style={{
background: "var(--mantine-color-body)",
border: "1px solid var(--mantine-color-default-border)",
borderRadius: 10,
overflow: "hidden",
}}
{...rest}
>
{children}
</Box>
);
}
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
@@ -90,6 +123,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
const userSpaceRole = space?.membership?.role;
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
if (!comments?.items) {
@@ -113,6 +148,17 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
};
}, [comments]);
// Collapse each tab's top-level list into render units (a lone comment or a
// collapsed agent run). Purely visual — the underlying data is untouched.
const activeUnits = useMemo(
() => groupAgentRuns(activeComments),
[activeComments],
);
const resolvedUnits = useMemo(
() => groupAgentRuns(resolvedComments),
[resolvedComments],
);
// Index replies by their parent once, instead of an O(n^2) filter per thread.
// The map ref changes on any comments update, so MemoizedChildComments re-runs
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
@@ -168,56 +214,104 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
[createCommentAsync, page?.id],
);
const renderComments = useCallback(
(comment: IComment) => (
<Paper
shadow="sm"
radius="md"
p="xs"
mb="xs"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
// The full subtree for ONE top-level comment: its head card (thread row or
// agent edit card), its nested replies, and a lazily-mounted reply editor.
// Shared by a standalone card and a card inside an agent-run group so the
// reply threading / lazy editor (#340) is wired identically in both.
const renderCommentSubtree = useCallback(
(comment: IComment, isEdit: boolean, showProvenance: boolean) => (
<>
{isEdit ? (
<AgentEditCard
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
userSpaceRole={userSpaceRole}
showProvenance={showProvenance}
/>
) : (
<Box p="xs">
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</Box>
)}
<Box px="xs">
<MemoizedChildComments
childrenByParent={childrenByParent}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
userSpaceRole={userSpaceRole}
/>
</div>
</Box>
{!comment.resolvedAt && canComment && (
<>
<Box px="xs" pb="xs">
<Divider my={2} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
/>
</>
</Box>
)}
</Paper>
</>
),
[
childrenByParent,
handleAddReply,
page?.id,
space?.membership?.role,
userSpaceRole,
canComment,
canEdit,
],
);
// Render one collapsed unit: a standalone card (thread or lone edit) or an
// agent-run group (one RunHeader over N stacked edit cards).
const renderUnit = useCallback(
(unit: CommentRenderUnit) => {
if (unit.kind === "single") {
const c = unit.comment;
const isEdit =
c.createdSource === "agent" &&
c.suggestedText != null &&
!c.parentCommentId;
return (
<PanelCard key={c.id} data-comment-id={c.id}>
{renderCommentSubtree(c, isEdit, true)}
</PanelCard>
);
}
// A collapsed agent run: one header, then each edit card (provenance
// suppressed on the cards — the header carries the single provenance line).
return (
<PanelCard key={unit.key}>
<RunHeader comments={unit.comments} />
{unit.comments.map((c) => (
<Box
key={c.id}
data-comment-id={c.id}
style={{
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
{renderCommentSubtree(c, true, false)}
</Box>
))}
</PanelCard>
);
},
[renderCommentSubtree],
);
if (isCommentsLoading) {
return <></>;
}
@@ -226,8 +320,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
return <div>{t("Error loading comments.")}</div>;
}
const totalComments = activeComments.length + resolvedComments.length;
const pageCommentInput = canComment ? (
<PageCommentInput
onSave={handleAddPageComment}
@@ -328,7 +420,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
</Stack>
</Center>
) : (
activeComments.map(renderComments)
activeUnits.map(renderUnit)
)}
</Tabs.Panel>
@@ -347,7 +439,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
</Stack>
</Center>
) : (
resolvedComments.map(renderComments)
resolvedUnits.map(renderUnit)
)}
</Tabs.Panel>
</div>
@@ -21,53 +21,6 @@
box-sizing: border-box;
}
/* Suggested-edit (#315) "было → стало" diff block. */
.suggestionBlock {
margin-top: 8px;
margin-left: 6px;
padding: 6px;
border-radius: var(--mantine-radius-sm);
border: 1px solid var(--mantine-color-default-border);
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.suggestionOld {
text-decoration: line-through;
color: var(--mantine-color-red-7);
background: var(--mantine-color-red-light);
border-radius: 2px;
padding: 1px 3px;
}
.suggestionNew {
color: var(--mantine-color-green-9);
background: var(--mantine-color-green-light);
border-radius: 2px;
padding: 1px 3px;
margin-top: 4px;
}
/* Intraline diff (#331): the fragment that actually changed within the
red "before" / green "after" block. It inherits the surrounding red/green
framing and adds a stronger tint plus bold weight so the eye lands on the
changed letters/words (git/GitHub-style) rather than the whole line. The
container's line-through (old) / green (new) still marks the full line. */
.suggestionChanged {
/* Stronger tint of the surrounding red/green so the changed fragment pops
within the block. `currentColor` follows the parent's red (old) or green
(new) text colour. No `text-decoration` here on purpose: the old block's
inherited line-through must survive on the changed letters too. */
background: color-mix(in srgb, currentColor 22%, transparent);
border-radius: 2px;
font-weight: 700;
}
.commentEditor {
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import { groupAgentRuns, runKey, GROUP_MIN } from "./group-agent-runs";
import { IComment } from "@/features/comment/types/comment.types";
const editComment = (id: string, over?: Partial<IComment>): IComment =>
({
id,
createdSource: "agent",
aiChatId: "chat-1",
agent: { name: "Corrector" },
suggestedText: "new text",
parentCommentId: null,
...over,
}) as unknown as IComment;
const human = (id: string): IComment =>
({ id, createdSource: "user", parentCommentId: null }) as unknown as IComment;
describe("runKey", () => {
it("keys a groupable agent edit on aiChatId + agent.name", () => {
expect(runKey(editComment("a"))).toBe("chat-1:Corrector");
});
it("is null for an external MCP agent (aiChatId null)", () => {
expect(runKey(editComment("a", { aiChatId: null }))).toBeNull();
});
it("is null for a non-edit agent comment (no suggestedText)", () => {
expect(runKey(editComment("a", { suggestedText: null }))).toBeNull();
});
it("is null for a reply (has parentCommentId)", () => {
expect(runKey(editComment("a", { parentCommentId: "p" }))).toBeNull();
});
it("is null for a human comment", () => {
expect(runKey(human("a"))).toBeNull();
});
});
describe("groupAgentRuns", () => {
it("collapses >= GROUP_MIN same chat+role edits into one run at the first position", () => {
const units = groupAgentRuns([
editComment("e1"),
editComment("e2"),
editComment("e3"),
]);
expect(units).toHaveLength(1);
expect(units[0].kind).toBe("run");
if (units[0].kind === "run") {
expect(units[0].key).toBe("chat-1:Corrector");
expect(units[0].comments.map((c) => c.id)).toEqual(["e1", "e2", "e3"]);
}
expect(GROUP_MIN).toBe(2);
});
it("renders a lone edit as a single (below the threshold)", () => {
const units = groupAgentRuns([editComment("e1")]);
expect(units).toHaveLength(1);
expect(units[0].kind).toBe("single");
});
it("never groups external MCP edits (aiChatId null) — each is a single", () => {
const units = groupAgentRuns([
editComment("m1", { aiChatId: null }),
editComment("m2", { aiChatId: null }),
]);
expect(units).toHaveLength(2);
expect(units.every((u) => u.kind === "single")).toBe(true);
});
it("does not collapse two different roles sharing one chat", () => {
const units = groupAgentRuns([
editComment("a", { agent: { name: "Corrector" } as any }),
editComment("b", { agent: { name: "FactChecker" } as any }),
]);
// Each key has count 1 -> both remain singles.
expect(units).toHaveLength(2);
expect(units.every((u) => u.kind === "single")).toBe(true);
});
it("preserves order and keeps human threads as singles interleaved with a run", () => {
const units = groupAgentRuns([
human("h1"),
editComment("e1"),
editComment("e2"),
human("h2"),
]);
// h1 single, then the run (emitted at e1's position, e2 absorbed), then h2.
expect(units.map((u) => u.kind)).toEqual(["single", "run", "single"]);
if (units[1].kind === "run") {
expect(units[1].comments.map((c) => c.id)).toEqual(["e1", "e2"]);
}
});
});
@@ -0,0 +1,76 @@
import { IComment } from "@/features/comment/types/comment.types";
// A visual-only series threshold: this many agent suggested-edits sharing one
// run key collapse under a single RunHeader. A group of one renders as a plain
// standalone card (no header). Policy constant — no env override.
export const GROUP_MIN = 2;
// One rendered unit of the top-level comment list: either a collapsed agent-run
// group (>= GROUP_MIN suggested-edits from the same chat+role) or a single
// comment (a human thread, an agent thread without an edit, or a lone edit).
export interface AgentRunGroup {
kind: "run";
key: string;
comments: IComment[];
}
export interface SingleUnit {
kind: "single";
comment: IComment;
}
export type CommentRenderUnit = AgentRunGroup | SingleUnit;
// The grouping key of a top-level agent suggested-edit, or null when the comment
// is not a groupable edit. The key pins BOTH the AI chat and the acting role
// (`aiChatId + ":" + agent.name`) so two roles running in the same chat do not
// collapse under one header. A comment with `aiChatId == null` (an external MCP
// agent) has no chat to group by — it is deliberately never groupable and always
// renders as a single card (a time-bucketed synthetic run would split/merge runs
// arbitrarily and choke on ISO `createdAt`). PURE.
export function runKey(c: IComment): string | null {
if (
c.createdSource === "agent" &&
c.suggestedText != null &&
!c.parentCommentId &&
c.aiChatId != null &&
c.agent?.name
) {
return `${c.aiChatId}:${c.agent.name}`;
}
return null;
}
// Collapse an ORDERED list of top-level comments into render units, preserving
// list order: a run is emitted in place of its FIRST member (later members are
// absorbed), everything else stays a single unit. Grouping is computed over the
// snapshot handed in; a streamed page / WS update may later promote a single
// edit into a run as more members arrive — that is acceptable (purely visual).
// PURE — no React/DOM, no mutation of the input.
export function groupAgentRuns(comments: IComment[]): CommentRenderUnit[] {
// First pass: tally groupable edits per key so we know which keys clear
// GROUP_MIN before we start emitting.
const counts = new Map<string, number>();
for (const c of comments) {
const key = runKey(c);
if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
}
const emitted = new Set<string>();
const units: CommentRenderUnit[] = [];
for (const c of comments) {
const key = runKey(c);
if (key && (counts.get(key) ?? 0) >= GROUP_MIN) {
// Emit the whole run once, at the position of its first member; skip the
// absorbed later members.
if (emitted.has(key)) continue;
emitted.add(key);
units.push({
kind: "run",
key,
comments: comments.filter((x) => runKey(x) === key),
});
} else {
units.push({ kind: "single", comment: c });
}
}
return units;
}
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { parseSeverity, isSignificant } from "./severity";
describe("parseSeverity — exact importance dictionary", () => {
it("maps the Russian importance tags", () => {
expect(parseSeverity("[Критично] fix now")).toBe("critical");
expect(parseSeverity("[Существенно] tighten")).toBe("major");
expect(parseSeverity("[Незначительно] nit")).toBe("minor");
});
it("maps the English equivalents and is case-insensitive", () => {
expect(parseSeverity("[Critical] x")).toBe("critical");
expect(parseSeverity("[MAJOR] x")).toBe("major");
expect(parseSeverity("[minor] x")).toBe("minor");
expect(parseSeverity("[критично] x")).toBe("critical");
});
it("treats fact-checker verdicts as unknown, NOT a severity", () => {
expect(parseSeverity("[Неверно] this is wrong")).toBe("unknown");
expect(parseSeverity("[Не проверено] can't verify")).toBe("unknown");
expect(parseSeverity("[Непроверяемо] x")).toBe("unknown");
expect(parseSeverity("[Это мнение] x")).toBe("unknown");
});
it("treats a typo'd / unrecognised tag as unknown (never falls back to minor)", () => {
expect(parseSeverity("[Существенное] typo ending")).toBe("unknown");
expect(parseSeverity("[whatever] x")).toBe("unknown");
});
it("returns unknown for no tag or empty input", () => {
expect(parseSeverity("plain body, no tag")).toBe("unknown");
expect(parseSeverity("")).toBe("unknown");
});
it("finds the recognised importance tag even after a leading verdict tag", () => {
expect(parseSeverity("[Неверно] [Существенно] body")).toBe("major");
});
});
describe("isSignificant", () => {
it("counts only major and critical", () => {
expect(isSignificant("critical")).toBe(true);
expect(isSignificant("major")).toBe(true);
expect(isSignificant("minor")).toBe(false);
expect(isSignificant("unknown")).toBe(false);
});
});
@@ -0,0 +1,44 @@
// Severity of an agent suggested-edit, parsed CLIENT-SIDE from the importance
// tag the editorial role prompts emit at the head of the comment body
// (`[Критично]` / `[Существенно]` / `[Незначительно]`, or their English
// equivalents). It is a DISPLAY-ONLY signal: it drives the coloured dot on the
// edit card and the "M major" counter in a run header. It never gates an action.
//
// `unknown` is a first-class value, NOT a synonym for `minor`: any bracketed
// text that is not in the exact dictionary — a Fact-checker verdict
// (`[Неверно]`, `[Не проверено]`, `[Непроверяемо]`, `[Это мнение]`), a typo
// (`[Существенное]`), or the absence of a tag — resolves to `unknown` and draws
// a neutral dot. Passing an unrecognised tag off as `minor` would mis-label it.
export type Severity = "critical" | "major" | "minor" | "unknown";
// EXACT (case-insensitive) dictionary. Only these tokens map to a real severity;
// everything else falls through to `unknown` on purpose.
const SEVERITY_TAGS: Record<string, Severity> = {
критично: "critical",
существенно: "major",
незначительно: "minor",
critical: "critical",
major: "major",
minor: "minor",
};
// Parse the first RECOGNISED importance tag out of the flattened comment text
// (use `commentContentToText` to obtain it). Scans every `[...]` token so a tag
// that trails a verdict still counts; the first exact-dictionary hit wins. When
// no token matches the dictionary the result is `unknown`. PURE — no React/DOM.
export function parseSeverity(text: string): Severity {
if (!text) return "unknown";
const matches = text.matchAll(/\[([^\]]+)\]/g);
for (const m of matches) {
const tag = m[1].trim().toLowerCase();
const sev = SEVERITY_TAGS[tag];
if (sev) return sev;
}
return "unknown";
}
// Whether a severity counts toward the "M major" tally in a run header: only the
// genuinely significant ones (major + critical). `minor` and `unknown` do not.
export function isSignificant(severity: Severity): boolean {
return severity === "major" || severity === "critical";
}
@@ -92,13 +92,23 @@
wrapper, never in the document model) and is rendered inline at the start of
the first content line via ::before. This keeps text and wrapped lines flush
to the left margin — no hanging indent — while the editable contentDOM stays
the FIRST DOM child (#146). */
the FIRST DOM child (#146).
The rules below target `p:first-child`, NOT `> :first-child`: tiptap-react
wraps the NodeViewContent children in an extra block-level div
(`[data-node-view-content-react]`, style="white-space: inherit"), so
`.definitionContent`'s first child is that wrapper, not the paragraph. An
inline ::before on the block wrapper (whose child is a block <p>) drops onto
its own line above the text — the "+1 line" regression. `p:first-child`
reaches the real first paragraph so the number stays inline, and it works
whether or not the wrapper is present (definition content is `paragraph+`,
so there are no nested paragraphs to mis-match). */
.definitionContent {
flex: 1 1 auto;
min-width: 0;
}
.definitionContent > :first-child::before {
.definitionContent p:first-child::before {
content: var(--footnote-number, "?") ". ";
color: var(--mantine-color-dimmed);
font-variant-numeric: tabular-nums;
@@ -108,12 +118,13 @@
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
Drop the outer margins so the definition sits tight to the heading above and
the ::before number aligns with the top of the row — same approach used for
callouts in core.css. */
.definitionContent > :first-child {
callouts in core.css. Target the paragraphs directly (through the
tiptap-react content wrapper), same reasoning as the ::before rule above. */
.definitionContent p:first-child {
margin-top: 0;
}
.definitionContent > :last-child {
.definitionContent p:last-child {
margin-bottom: 0;
}
@@ -0,0 +1,168 @@
import { describe, it, expect } from "vitest";
import {
buildRows,
formatBlockTooltip,
summaryLabels,
toBlocks,
toTimelineDay,
EMPTY_RUN_COLLAPSE,
} from "./work-time-adapter";
import { IPageWorkTime, IPerDay } from "./work-time.types";
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
const DAY_MS = 24 * HOUR;
// Fake translator: renders the key with {{tokens}} substituted, so the tests
// assert the mapping/branching without depending on the i18n catalogue.
const t = (key: string, opts?: Record<string, unknown>) =>
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
// A day midnight anchored at a fixed UTC instant (tz handled server-side; we
// only lay out fractions of the day the server already bucketed).
const DAY0 = Date.UTC(2026, 5, 29, 0, 0, 0); // Mon 29 Jun 2026 00:00 UTC
function day(over: Partial<IPerDay> & { day: number }): IPerDay {
return {
dayISO: new Date(over.day).toISOString().slice(0, 10),
activeMs: 0,
agentMs: 0,
windows: [],
...over,
};
}
const config: IPageWorkTime["config"] = {
tGap: 15 * MIN,
agentTGap: 15 * MIN,
pIn: 0,
pOut: 0,
pSingle: 30 * 1000,
excludeGit: false,
dedupRoundMs: 1000,
};
function payload(over: Partial<IPageWorkTime>): IPageWorkTime {
return {
workMs: 0,
agentOnlyMs: 0,
perDay: [],
config,
tz: "UTC",
...over,
};
}
describe("toBlocks", () => {
it("maps work+agent windows to time-of-day segments (hour fractions)", () => {
const d = day({
day: DAY0,
activeMs: 90 * MIN,
windows: [
{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" },
{ start: DAY0 + 22 * HOUR, end: DAY0 + 23 * HOUR, class: "agent_only" },
],
});
const blocks = toBlocks(d);
expect(blocks).toHaveLength(2);
expect(blocks[0]).toMatchObject({ start: 9, end: 10.5, kind: "work" });
expect(blocks[1]).toMatchObject({ start: 22, end: 23, kind: "agent" });
// real epoch preserved for the DST-safe tooltip
expect(blocks[0].startEpoch).toBe(DAY0 + 9 * HOUR);
});
it("clamps out-of-day fractions to [0,24]", () => {
const d = day({
day: DAY0,
windows: [{ start: DAY0 - HOUR, end: DAY0 + 25 * HOUR, class: "work" }],
});
const [b] = toBlocks(d);
expect(b.start).toBe(0);
expect(b.end).toBe(24);
});
});
describe("toTimelineDay", () => {
it("draws the now-line only on today's row", () => {
const today = day({ day: DAY0, activeMs: HOUR });
const noon = DAY0 + 12 * HOUR;
const asToday = toTimelineDay(today, t, noon);
expect(asToday.isToday).toBe(true);
expect(asToday.nowFraction).toBeCloseTo(0.5, 5);
const past = day({ day: DAY0 - DAY_MS, activeMs: HOUR });
const asPast = toTimelineDay(past, t, noon);
expect(asPast.isToday).toBe(false);
expect(asPast.nowFraction).toBeUndefined();
});
it("labels an empty day and totals it as —", () => {
const empty = day({ day: DAY0 });
const d = toTimelineDay(empty, t, DAY0 + 12 * HOUR);
expect(d.isEmpty).toBe(true);
expect(d.totalLabel).toBe("—");
});
});
describe("buildRows (empty-run collapsing)", () => {
it("collapses a run of >= EMPTY_RUN_COLLAPSE empty days in place", () => {
const perDay: IPerDay[] = [
day({ day: DAY0, activeMs: HOUR }),
...Array.from({ length: EMPTY_RUN_COLLAPSE }, (_, i) =>
day({ day: DAY0 + (i + 1) * DAY_MS }),
),
day({ day: DAY0 + (EMPTY_RUN_COLLAPSE + 1) * DAY_MS, activeMs: HOUR }),
];
const rows = buildRows(perDay, t, 0);
expect(rows.map((r) => r.type)).toEqual(["day", "gap", "day"]);
const gap = rows[1];
expect(gap.type === "gap" && gap.count).toBe(EMPTY_RUN_COLLAPSE);
});
it("keeps a short empty run as individual dimmed day rows", () => {
const perDay: IPerDay[] = [
day({ day: DAY0, activeMs: HOUR }),
day({ day: DAY0 + DAY_MS }),
day({ day: DAY0 + 2 * DAY_MS, activeMs: HOUR }),
];
const rows = buildRows(perDay, t, 0);
expect(rows.map((r) => r.type)).toEqual(["day", "day", "day"]);
});
});
describe("summaryLabels", () => {
it("derives totalLabel/agentLabel from ms via the shared formatter", () => {
const { total, agent } = summaryLabels(
payload({ workMs: 4 * HOUR + 27 * MIN, agentOnlyMs: 80 * MIN }),
t,
);
expect(total).toBe("≈ 4h 25m");
expect(agent).toBe("≈ 1h 20m");
});
it("agent-only page: agent estimate fills the main slot, no secondary line", () => {
const { total, agent } = summaryLabels(
payload({ workMs: 0, agentOnlyMs: 80 * MIN }),
t,
);
expect(total).toBe("agent: ≈ 1h 20m");
expect(agent).toBeUndefined();
});
it("human-only page: no agent line", () => {
const { agent } = summaryLabels(payload({ workMs: HOUR, agentOnlyMs: 0 }), t);
expect(agent).toBeUndefined();
});
});
describe("formatBlockTooltip", () => {
it("formats start–end from the real epoch in the data tz + duration", () => {
const d = day({
day: DAY0,
windows: [{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" }],
});
const [b] = toBlocks(d);
const label = formatBlockTooltip(b, "UTC", "en-US", t);
expect(label).toBe("09:00 – 10:30 · 1h 30m");
});
});
@@ -0,0 +1,168 @@
// #566 — adapter: real IPageWorkTime → the render-ready shape the redesigned
// time-of-day timeline consumes. The visual prototype (NewDesign/TimeWorkedModal)
// was written against invented props (`DaySummary[]`, pre-made `totalLabel`); the
// real payload is IPageWorkTime. Everything below is pure so the mapping (windows
// → time-of-day blocks, ms → labels, the "now" boundary, empty-run collapsing) is
// unit-testable without React. No re-bucketing: the server already grouped the
// windows by the request timezone — we only lay out the windows it returned.
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
import { formatDayTotal, formatHeadline } from "./format-work-time";
type Translate = (key: string, opts?: Record<string, unknown>) => string;
export 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) — preserved from the original punch-card.
export const EMPTY_RUN_COLLAPSE = 8;
export interface TimelineBlock {
/** Hour fraction 0..24 within the day — drives left/width positioning. */
start: number;
end: number;
kind: "work" | "agent";
/** Real epoch ms, kept for a DST-safe tooltip (formatted in the data tz). */
startEpoch: number;
endEpoch: number;
}
export interface TimelineDay {
key: string;
label: string;
totalLabel: string;
blocks: TimelineBlock[];
isEmpty: boolean;
/** Today lives only in the last active bucket; drives the "now" boundary. */
isToday: boolean;
/** 0..1 position of "now" within today's track (undefined when not today). */
nowFraction?: number;
}
export type TimelineRow =
| { type: "day"; day: TimelineDay }
| { type: "gap"; count: number };
/** Weekday-day-month heading in the browser locale (matches the server tz
* bucketing, since usePageWorkTime requests buckets in the viewer tz). */
export function dayHeading(day: number): string {
return new Date(day).toLocaleDateString(undefined, {
weekday: "short",
day: "numeric",
month: "short",
});
}
/** Map one day's absolute windows into time-of-day blocks. `class` work|agent_only
* → kind work|agent; positions are the in-day hour fraction (epoch kept for the
* tooltip). Fractions are clamped to [0,24] to match the original punch-card. */
export function toBlocks(day: IPerDay): TimelineBlock[] {
return day.windows.map((w: IDayWindow) => {
const start = clampHour((w.start - day.day) / (DAY_MS / 24));
const end = clampHour((w.end - day.day) / (DAY_MS / 24));
return {
start,
end,
kind: w.class === "work" ? "work" : "agent",
startEpoch: w.start,
endEpoch: w.end,
};
});
}
function clampHour(h: number): number {
return Math.max(0, Math.min(24, h));
}
/** Build a single render-ready day. `now` is injected for testability; the
* "now" boundary is drawn only when `now` falls inside this bucket's calendar
* day (so it appears on today's row and only when today has edits). */
export function toTimelineDay(
day: IPerDay,
t: Translate,
now: number,
): TimelineDay {
const isToday = now >= day.day && now < day.day + DAY_MS;
return {
key: day.dayISO,
label: dayHeading(day.day),
totalLabel: formatDayTotal(day.activeMs, t),
blocks: toBlocks(day),
isEmpty: day.activeMs === 0 && day.agentMs === 0,
isToday,
nowFraction: isToday ? (now - day.day) / DAY_MS : undefined,
};
}
/** Collapse long edit-free runs (≥ EMPTY_RUN_COLLAPSE) into an in-place "gap"
* row; short runs stay as (dimmed, "—") day rows. Preserved from the original
* punch-card so a page edited over months does not render hundreds of rows. */
export function buildRows(
perDay: IPerDay[],
t: Translate,
now: number,
): TimelineRow[] {
const rows: TimelineRow[] = [];
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: toTimelineDay(d, t, now) });
}
}
emptyRun = [];
};
for (const d of perDay) {
if (d.activeMs === 0 && d.agentMs === 0) {
emptyRun.push(d);
} else {
flush();
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
}
}
flush();
return rows;
}
/** The big summary slot. Fail-safe for an agent-only page (#395/#551): since
* formatHeadline(0) === "", never leave the 22px slot empty — put the agent
* estimate in the main slot, and only show the secondary `agent:` line when
* BOTH a human and an agent estimate exist. */
export function summaryLabels(
data: IPageWorkTime,
t: Translate,
): { total: string; agent?: string } {
const total =
data.workMs > 0
? formatHeadline(data.workMs, t)
: t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) });
const agent =
data.workMs > 0 && data.agentOnlyMs > 0
? formatHeadline(data.agentOnlyMs, t)
: undefined;
return { total, agent };
}
/** Block hover label "start – end · duration". Times come from the REAL epoch
* rendered in the data tz (NOT the 24h fraction) so a DST-transition day does
* not skew the shown clock time. Duration reuses formatDayTotal (always > 0
* here, so never "—"). */
export function formatBlockTooltip(
block: TimelineBlock,
tz: string,
locale: string,
t: Translate,
): string {
const fmt = new Intl.DateTimeFormat(locale || undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: tz,
});
return t("{{start}} – {{end}} · {{duration}}", {
start: fmt.format(block.startEpoch),
end: fmt.format(block.endEpoch),
duration: formatDayTotal(block.endEpoch - block.startEpoch, t),
});
}
@@ -1,97 +1,86 @@
import { Group, Stack, Text } from "@mantine/core";
// #566 — redesigned "Time worked" body: daily time-of-day timelines. Each day is
// a 24h track showing WHEN the work happened — a sticky 00/06/12/18/24 hour axis,
// shaded night hours, per-block hover tooltip ("start – end · duration"), and a
// "now" boundary on today's row. Presentation ported from NewDesign/TimeWorkedModal;
// all data comes through the pure adapter over the real IPageWorkTime (zero backend
// change). Positioning math, empty-run collapsing and the formatters are reused.
import { Box, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
import { IPageWorkTime } from "./work-time.types";
import { formatGapMinutes } from "./format-work-time";
import {
formatDayTotal,
formatGapMinutes,
formatHeadline,
} from "./format-work-time";
buildRows,
formatBlockTooltip,
summaryLabels,
TimelineBlock,
TimelineDay,
} from "./work-time-adapter";
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;
// Minimum visible width so a very short session neither vanishes nor fakes dense
// work (§6.2); kept from the original punch-card.
const MIN_BLOCK_WIDTH_PCT = 0.6;
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 ActivityBlock({
block,
tz,
locale,
}: {
block: TimelineBlock;
tz: string;
locale: string;
}) {
const { t } = useTranslation();
const left = (block.start / 24) * 100;
const width = Math.max(((block.end - block.start) / 24) * 100, MIN_BLOCK_WIDTH_PCT);
const cls = [
classes.window,
block.kind === "work" ? classes.windowWork : classes.windowAgent,
].join(" ");
return (
<Tooltip
label={formatBlockTooltip(block, tz, locale, t)}
withArrow
openDelay={120}
fz={11}
>
<div className={cls} style={{ left: `${left}%`, width: `${width}%` }} />
</Tooltip>
);
}
function DayTrack({
day,
pSingle,
tz,
locale,
}: {
day: IPerDay;
pSingle: number;
day: TimelineDay;
tz: string;
locale: string;
}) {
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}%` }}
/>
<span className={classes.dayLabel}>{day.label}</span>
<div className={`${classes.track} ${day.isEmpty ? classes.trackEmpty : ""}`}>
{[25, 50, 75].map((p) => (
<div key={p} className={classes.hourTick} style={{ left: `${p}%` }} />
))}
{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))}%`,
}}
/>
);
})}
{day.blocks.map((b, i) => (
<ActivityBlock key={i} block={b} tz={tz} locale={locale} />
))}
{day.isToday && day.nowFraction != null && (
<div
className={classes.nowLine}
style={{ left: `${day.nowFraction * 100}%` }}
/>
)}
</div>
<span className={classes.daySum}>
{formatDayTotal(day.activeMs, t)}
<span
className={classes.daySum}
data-empty={day.totalLabel === "—" ? true : undefined}
>
{day.totalLabel}
</span>
</div>
);
@@ -102,8 +91,16 @@ interface Props {
}
export default function WorkTimePunchCard({ data }: Props) {
const { t } = useTranslation();
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
const { t, i18n } = useTranslation();
const locale = i18n.language;
const now = Date.now();
const rows = useMemo(
() => buildRows(data.perDay, t, now),
// `now` intentionally re-read on each open; excluded so the memo tracks data.
// eslint-disable-next-line react-hooks/exhaustive-deps
[data.perDay, t],
);
const { total, agent } = summaryLabels(data, t);
const gapMin = formatGapMinutes(data.config.tGap);
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
@@ -116,17 +113,19 @@ export default function WorkTimePunchCard({ data }: Props) {
return (
<Stack gap="xs">
<Group gap="lg">
<Text size="sm" fw={500}>
{formatHeadline(data.workMs, t)}
{/* summary */}
<Group align="baseline" gap="md">
<Text fz={22} fw={700}>
{total}
</Text>
{data.agentOnlyMs > 0 && (
{agent && (
<Text size="xs" c="dimmed">
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
{t("agent: {{value}}", { value: agent })}
</Text>
)}
</Group>
{/* legend */}
<Group gap="md">
<Text size="xs" c="dimmed">
<span
@@ -144,21 +143,47 @@ export default function WorkTimePunchCard({ data }: Props) {
</Text>
</Group>
<div>
{/* sticky hour axis */}
<div className={`${classes.row} ${classes.axisRow}`}>
<span />
<div className={classes.axis}>
{[
["0%", "00", "start"],
["25%", "06", "center"],
["50%", "12", "center"],
["75%", "18", "center"],
["100%", "24", "end"],
].map(([l, label, align]) => (
<span
key={label}
className={classes.axisTick}
data-align={align}
style={{ left: l }}
>
{label}
</span>
))}
</div>
<span />
</div>
{/* day rows */}
<ScrollArea.Autosize mah="60vh" type="hover">
{rows.map((row, i) =>
row.type === "day" ? (
<DayTrack
key={row.day.dayISO}
key={row.day.key}
day={row.day}
pSingle={data.config.pSingle}
tz={data.tz}
locale={locale}
/>
) : (
<div key={`gap-${i}`} className={classes.gapRow}>
<Box key={`gap-${i}`} className={classes.gapRow}>
{t("× {{count}} days without edits", { count: row.count })}
</div>
</Box>
),
)}
</div>
</ScrollArea.Autosize>
<Text size="xs" c="dimmed" mt="xs">
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
@@ -60,7 +60,7 @@ export default function WorkTimeStat({ pageId }: Props) {
opened={opened}
onClose={close}
title={t("Time worked on this article")}
size="lg"
size="46rem"
>
<WorkTimePunchCard data={data} />
</Modal>
@@ -1,5 +1,7 @@
/* #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. */
/* #566 — time-of-day timeline. Custom CSS segments on a fixed 24-hour track
(position = offset-in-day / 24h, width = duration / 24h), no chart library.
Night hours (0–6, 21–24) are shaded so the eye reads morning-vs-evening; a
sticky hour axis labels 00/06/12/18/24. Theme-aware via Mantine tokens. */
.row {
display: grid;
@@ -16,17 +18,29 @@
}
.track {
--wt-night: rgba(90, 100, 130, 0.16);
--wt-day: rgba(90, 100, 130, 0.03);
position: relative;
height: 16px;
border-radius: 4px;
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
height: 20px;
border-radius: 5px;
overflow: hidden;
/* base fill + night shading: 0–6h and 21–24h (87.5%) darker than daytime */
background:
linear-gradient(
90deg,
var(--wt-night) 0 25%,
var(--wt-day) 25% 87.5%,
var(--wt-night) 87.5% 100%
),
light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
/* Faint hour grid so the eye can read "morning vs evening". */
/* Short (< EMPTY_RUN_COLLAPSE) edit-free days: dimmed, empty track. */
.trackEmpty {
opacity: 0.5;
}
/* Quarter-day grid divisions (06/12/18) inside the track. */
.hourTick {
position: absolute;
top: 0;
@@ -40,10 +54,11 @@
.window {
position: absolute;
top: 2px;
bottom: 2px;
top: 3px;
height: 14px;
border-radius: 3px;
min-width: 3px;
cursor: default;
}
.windowWork {
@@ -54,18 +69,57 @@
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;
/* The "now" boundary on today's row. */
.nowLine {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--mantine-color-red-6);
}
.daySum {
font-size: var(--mantine-font-size-xs);
font-weight: 500;
text-align: right;
white-space: nowrap;
}
.daySum[data-empty] {
color: var(--mantine-color-dimmed);
font-weight: 400;
}
/* Sticky hour axis header aligned to the track column. */
.axisRow {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--mantine-color-body);
padding-bottom: 2px;
}
.axis {
position: relative;
height: 14px;
}
.axisTick {
position: absolute;
top: 0;
font-size: 10px;
font-weight: 500;
color: var(--mantine-color-dimmed);
}
.axisTick[data-align="center"] {
transform: translateX(-50%);
}
.axisTick[data-align="end"] {
transform: translateX(-100%);
}
.gapRow {
padding: 6px 0 6px 108px;
font-size: var(--mantine-font-size-xs);
@@ -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);
@@ -36,6 +36,7 @@ const STATIC_ROUTES = new Set<string>([
'/setup/register',
'/settings/account/profile',
'/settings/account/preferences',
'/settings/account/api-keys',
'/settings/workspace',
'/settings/ai',
'/settings/members',
@@ -0,0 +1,22 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import ApiKeysManager from "@/features/api-key/components/api-keys-manager";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
export default function AccountApiKeys() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
<ApiKeysManager />
</>
);
}
@@ -14,9 +14,11 @@
*/
/*
* Push the top-anchored toast containers below the top chrome (fixed 45px
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
* Anchor the top toast containers to the very top edge of the viewport (a small
* 8px gap), above the header/search chrome, per product request. The toast
* (z-index 10000) therefore renders over the header/toolbar (both z-index 99)
* for the few seconds it is visible — intentional, since it is the top-most,
* in-the-line-of-sight surface.
*
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
* containers simultaneously (`position` only routes toasts into one via the
@@ -34,7 +36,7 @@
* (the :where() contributes 0), regardless of stylesheet order.
*/
.mantine-Notifications-root[data-position^='top'] {
top: 96px;
top: 8px;
}
[data-mantine-color-scheme='light'] .mantine-Notification-root {
+12
View File
@@ -63,3 +63,15 @@ vi.stubGlobal("matchMedia", (query: string) => ({
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
// Mantine's ScrollArea (used by Table.ScrollContainer, ScrollArea, etc.) reads
// `ResizeObserver` in a layout effect on mount, which jsdom does not implement.
// A no-op stub lets any test rendering those components mount cleanly.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
@@ -736,6 +736,53 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
});
// #370 F2 — an effectively-empty page is a REACHABLE no-op (agent calls
// save_page_version on a blank page): the version tx short-circuits with
// nothing to pin. The handler MUST still broadcast a TERMINAL reply
// (version.skipped, reason:'empty') so the client resolves at once instead of
// waiting out its 20s ack timeout and misreporting a healthy server as
// unreachable. MUTATION: drop the `else if (skipped)` broadcast → no terminal
// reply is sent → this reddens.
it('empty page → no version written, broadcasts a terminal version.skipped(empty)', async () => {
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: emptyDoc,
});
await emitSave(document, 'agent');
// Nothing pinned.
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
// But a terminal reply WAS sent so the client never times out. The flush
// (onStoreDocument) emits its own `page.updated`; the version.skipped is the
// LAST broadcast (dropping the skip branch leaves page.updated last → reds).
const calls = (document as any).broadcastStateless.mock.calls;
const msg = JSON.parse(calls[calls.length - 1][0]);
expect(msg).toEqual({ type: 'version.skipped', reason: 'empty' });
});
// #370 F2 — the page row is gone (deleted / never persisted). Same rule: a
// terminal reply MUST be sent (version.skipped, reason:'page-not-found') so the
// client surfaces a truthful "not found" immediately rather than a health
// timeout. onStoreDocument's own `!page` guard returns early without throwing,
// so the handler reaches the version tx and its `!page` skip branch.
it('page not found → broadcasts a terminal version.skipped(page-not-found)', async () => {
const document = ydocFor(doc('GONE'));
pageRepo.findById.mockResolvedValue(null);
await emitSave(document, 'agent');
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
const msg = JSON.parse(
(document as any).broadcastStateless.mock.calls[0][0],
);
expect(msg).toEqual({ type: 'version.skipped', reason: 'page-not-found' });
});
});
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
@@ -68,6 +68,19 @@ export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
*/
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
/**
* #370 F2 — wire format of the server→client REPLY to a save-version signal, sent
* over the same stateless channel. `version.saved` means a version was created or
* promoted; `version.skipped` is a TERMINAL "nothing was pinned" reply for the two
* reachable no-op cases (an effectively-empty page, or the page row is gone) so the
* client resolves at once instead of waiting out its ack timeout and misreporting a
* healthy server as unreachable. EXACTLY ONE of these is broadcast per handled
* save. The MCP client duplicates these literals (it cannot import server code) —
* keep the two in sync (see packages/mcp/src/lib/collaboration.ts).
*/
export const VERSION_SAVED_MESSAGE_TYPE = 'version.saved';
export const VERSION_SKIPPED_MESSAGE_TYPE = 'version.skipped';
/**
* #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
@@ -643,6 +656,10 @@ export class PersistenceExtension implements Extension {
let result:
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
| undefined;
// #370 F2 — set when there is nothing to version (empty page / page gone), so
// the tail broadcasts a terminal `version.skipped` instead of staying silent
// and forcing the client to time out. Mutually exclusive with `result`.
let skipped: 'empty' | 'page-not-found' | 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
@@ -668,11 +685,20 @@ export class PersistenceExtension implements Extension {
includeContent: true,
trx,
});
if (!page) return;
if (!page) {
// The page row is gone (deleted/never persisted). Nothing to version —
// record it so the tail sends a terminal skip reply (#370 F2).
skipped = 'page-not-found';
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;
// first-history guard); there is nothing intentional to pin. Record the
// skip so the client gets a terminal reply rather than a timeout (#370 F2).
if (isEmptyParagraphDoc(page.content as any)) {
skipped = 'empty';
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
page.id,
@@ -747,15 +773,26 @@ export class PersistenceExtension implements Extension {
}
this.idleBurstStart.delete(documentName);
// EXACTLY ONE terminal reply per handled save (#370 F2): a real save/promote
// broadcasts `version.saved`; the two no-op early returns (empty page / page
// gone) broadcast `version.skipped` so the client never waits out its ack
// timeout. A genuine failure threw above and rejected before reaching here.
if (result) {
document.broadcastStateless(
JSON.stringify({
type: 'version.saved',
type: VERSION_SAVED_MESSAGE_TYPE,
historyId: result.historyId,
kind: result.kind,
alreadySaved: result.alreadySaved,
}),
);
} else if (skipped) {
document.broadcastStateless(
JSON.stringify({
type: VERSION_SKIPPED_MESSAGE_TYPE,
reason: skipped,
}),
);
}
}
@@ -370,10 +370,12 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
);
});
// #490 reactive branch: a provider CONTEXT-OVERFLOW 400 in onError is classified,
// records a distinguishable cause, and stamps metadata.replayOverflow so the NEXT
// turn's budgeter trims aggressively (the recovery that un-bricks the chat).
it('#490: a context-overflow 400 stamps replayOverflow on the finalized row', async () => {
// #490/#520 reactive branch: a provider CONTEXT-OVERFLOW 400 in onError is
// classified, records a distinguishable cause, and stamps the consecutive-overflow
// COUNTER (metadata.replayOverflowCount) so the NEXT turn's budgeter trims with
// escalating aggression (the recovery that un-bricks the chat). This is a fresh
// chat (empty history -> prior streak 0), so the first overflow stamps count 1.
it('#490/#520: a context-overflow 400 stamps replayOverflowCount=1 on the finalized row', async () => {
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined as never);
@@ -397,11 +399,14 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
metadata: Record<string, unknown>;
};
expect(patch.status).toBe('error');
expect(patch.metadata.replayOverflow).toBe(true);
// First overflow on a fresh chat -> k = prior(0) + 1 = 1.
expect(patch.metadata.replayOverflowCount).toBe(1);
// The legacy boolean is no longer written (the counter supersedes it).
expect('replayOverflow' in patch.metadata).toBe(false);
expect(patch.metadata.error).toContain('контекстное окно');
});
it('#490: a non-overflow error does NOT stamp replayOverflow', async () => {
it('#490/#520: a non-overflow error does NOT stamp the overflow counter', async () => {
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined as never);
@@ -412,6 +417,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
status: string;
metadata: Record<string, unknown>;
};
expect('replayOverflowCount' in patch.metadata).toBe(false);
expect('replayOverflow' in patch.metadata).toBe(false);
});
});
@@ -30,13 +30,16 @@ import {
STEP_LIMIT_NO_ANSWER_MARKER,
OUTPUT_DEGENERATION_ERROR,
lastAssistantContextTokens,
lastAssistantReplayOverflow,
lastAssistantReplayOverflowCount,
seedActivatedTools,
} from './ai-chat.service';
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
import { buildSystemPrompt } from './ai-chat.prompt';
import type { McpClientsService } from './external-mcp/mcp-clients.service';
import { resolveEffectiveReplayThreshold } from './history-budget';
import {
resolveEffectiveReplayThreshold,
REPLAY_MIN_FLOOR_TOKENS,
} from './history-budget';
/**
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
@@ -554,49 +557,129 @@ describe('seedActivatedTools', () => {
});
});
describe('lastAssistantReplayOverflow', () => {
describe('lastAssistantReplayOverflowCount', () => {
const row = (
role: string,
metadata: Record<string, unknown> | null,
): AiChatMessage => ({ role, metadata }) as unknown as AiChatMessage;
it('is true only when the LAST assistant turn overflowed', () => {
it('reads the consecutive-overflow count from the LAST assistant turn', () => {
expect(
lastAssistantReplayOverflow([
row('assistant', { replayOverflow: true }),
lastAssistantReplayOverflowCount([
row('assistant', { replayOverflowCount: 3 }),
row('user', null),
]),
).toBe(true);
// A recovered (later, non-overflow) assistant turn clears it.
).toBe(3);
// A recovered (later, non-overflow) assistant turn resets it to 0 — the read
// stops at the most recent assistant row, which carries no count.
expect(
lastAssistantReplayOverflow([
row('assistant', { replayOverflow: true }),
lastAssistantReplayOverflowCount([
row('assistant', { replayOverflowCount: 3 }),
row('user', null),
row('assistant', { contextTokens: 5 }),
]),
).toBe(false);
expect(lastAssistantReplayOverflow([])).toBe(false);
).toBe(0);
expect(lastAssistantReplayOverflowCount([])).toBe(0);
});
// #490 reactive recovery: a prior turn stamped `replayOverflow` must make the
// NEXT turn's effective budget the AGGRESSIVE 0.5x cut — that harder trim is
// what un-bricks a chat that just 400'd on the context window. This exercises
// the exact wiring the service uses: read the stamp, then scale the threshold.
it('#490: a prior replayOverflow drives the next turn to the 0.5x aggressive budget', () => {
// BACK-COMPAT (#520): an in-flight row written by the pre-#520 boolean stamp
// (`replayOverflow: true`, no count) reads as k=1 — the old single 0.5× behavior —
// so a chat mid-recovery across the deploy does not regress.
it('#520 back-compat: a legacy boolean replayOverflow reads as k=1', () => {
expect(
lastAssistantReplayOverflowCount([
row('assistant', { replayOverflow: true }),
row('user', null),
]),
).toBe(1);
// A legacy row with the flag absent/false is k=0.
expect(
lastAssistantReplayOverflowCount([row('assistant', { contextTokens: 5 })]),
).toBe(0);
});
// A corrupt/negative persisted count never yields a negative k.
it('clamps a corrupt negative count to 0', () => {
expect(
lastAssistantReplayOverflowCount([
row('assistant', { replayOverflowCount: -4 }),
]),
).toBe(0);
});
// #490/#520 reactive recovery: the prior consecutive-overflow count `k` drives
// the next turn's effective budget to an ESCALATING cut (0.5**k) — each further
// consecutive 400 tightens it, which is what un-bricks a chat that keeps
// overflowing. This exercises the exact wiring the service uses: read the count,
// then scale the threshold.
it('#490/#520: the prior count drives the next turn to the escalating aggressive budget', () => {
const history = [
row('assistant', { replayOverflow: true }),
row('assistant', { replayOverflowCount: 1 }),
row('user', null),
];
const priorOverflowed = lastAssistantReplayOverflow(history);
expect(priorOverflowed).toBe(true);
// Base budget 100k -> aggressive recovery halves it to 50k this turn.
expect(resolveEffectiveReplayThreshold(100_000, priorOverflowed)).toBe(50_000);
const k = lastAssistantReplayOverflowCount(history);
expect(k).toBe(1);
// Base budget 100k -> first-overflow recovery halves it to 50k this turn.
expect(resolveEffectiveReplayThreshold(100_000, k)).toBe(50_000);
// A second consecutive overflow (k=2) quarters it.
expect(resolveEffectiveReplayThreshold(100_000, 2)).toBe(25_000);
// Odd base floors, not rounds.
expect(resolveEffectiveReplayThreshold(99_999, true)).toBe(49_999);
// No prior overflow -> the base budget is used verbatim (no aggressive cut).
expect(resolveEffectiveReplayThreshold(100_000, false)).toBe(100_000);
expect(resolveEffectiveReplayThreshold(99_999, 1)).toBe(49_999);
// No prior overflow (k=0) -> the base budget is used verbatim (no cut).
expect(resolveEffectiveReplayThreshold(100_000, 0)).toBe(100_000);
// An explicit off-switch (null) is never overridden, even on recovery.
expect(resolveEffectiveReplayThreshold(null, true)).toBeNull();
expect(resolveEffectiveReplayThreshold(null, 3)).toBeNull();
});
// #520 escalation table + convergence: the cut deepens each consecutive overflow
// and is CLAMPED at the floor so it converges (un-bricks even against a small
// real window), instead of the old fixed single 0.5× that stuck at 50k forever.
it('#520: escalates and converges to the floor, un-bricking a small real window', () => {
const base = 100_000;
expect(resolveEffectiveReplayThreshold(base, 0)).toBe(base);
expect(resolveEffectiveReplayThreshold(base, 1)).toBe(50_000);
expect(resolveEffectiveReplayThreshold(base, 2)).toBe(25_000);
expect(resolveEffectiveReplayThreshold(base, 3)).toBe(12_500);
// Residual-brick regression (#520): with the flat-default base (100k) and a real
// model window of ~40k, the OLD fixed 0.5× stuck at 50k forever (> 40k -> 400s
// again, never recovers). The escalating cut drops BELOW 40k after enough
// consecutive overflows -> the history finally fits -> the chat un-bricks.
const realWindow = 40_000;
// k=1 (50k) still exceeds the window — the old behavior's terminal state.
expect(resolveEffectiveReplayThreshold(base, 1)).toBeGreaterThan(realWindow);
// But escalation converges under the window within a couple more turns.
const converged = [2, 3, 4, 5].some(
(k) => (resolveEffectiveReplayThreshold(base, k) as number) < realWindow,
);
expect(converged).toBe(true);
// Convergence is bounded BELOW by the floor: a large k never trims below it.
for (const k of [4, 8, 20, 100]) {
expect(resolveEffectiveReplayThreshold(base, k)).toBe(REPLAY_MIN_FLOOR_TOKENS);
expect(
resolveEffectiveReplayThreshold(base, k) as number,
).toBeGreaterThanOrEqual(REPLAY_MIN_FLOOR_TOKENS);
}
});
// The floor never RAISES a legitimately small configured budget above itself —
// that would re-overflow the very window it was configured for. Under #520 Option B
// recovery MAY cut it BELOW itself (down to floor(0.5×base) = the old 0.5× cut).
it('#520: never inflates a small configured budget above itself (may cut below)', () => {
const small = 5_000; // below the floor
const floor = Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(small * 0.5)); // 2500
expect(resolveEffectiveReplayThreshold(small, 0)).toBe(small);
// Even under escalation the effective threshold never exceeds the base, and never
// drops below the absolute floor.
for (const k of [1, 2, 3, 10]) {
const t = resolveEffectiveReplayThreshold(small, k) as number;
expect(t).toBeLessThanOrEqual(small);
expect(t).toBeGreaterThanOrEqual(floor);
}
// Option B: on overflow it DOES cut below the configured budget (old floor==budget
// behavior would keep this at `small`).
expect(resolveEffectiveReplayThreshold(small, 1)).toBe(floor);
});
});
@@ -930,21 +1013,50 @@ describe('flushAssistant', () => {
expect(flushed.metadata.error).toBe('boom');
});
// #490 observability: the replay budgeter's decision is stamped on the turn.
it('records replayTrimmedToTokens + replayOverflow when provided', () => {
// #490/#520 observability: the replay budgeter's decision is stamped on the turn,
// now including the consecutive-overflow COUNTER (#520) the next turn escalates on.
it('records replayTrimmedToTokens + replayOverflowCount when provided', () => {
const f = flushAssistant([], '', 'error', {
error: 'ctx',
replayTrimmedToTokens: 42_000,
replayOverflow: true,
replayOverflowCount: 2,
});
expect(f.metadata.replayTrimmedToTokens).toBe(42_000);
expect(f.metadata.replayOverflow).toBe(true);
expect(f.metadata.replayOverflowCount).toBe(2);
});
it('omits the replay metadata when not provided', () => {
const f = flushAssistant([], '', 'completed', { finishReason: 'stop' });
expect('replayTrimmedToTokens' in f.metadata).toBe(false);
expect('replayOverflow' in f.metadata).toBe(false);
expect('replayOverflowCount' in f.metadata).toBe(false);
expect('replayBelowConfiguredBudget' in f.metadata).toBe(false);
});
// #520 Option B observability: the turn is marked when recovery replayed BELOW the
// admin-configured budget, so the UI/telemetry can surface "config too large".
it('stamps replayBelowConfiguredBudget when recovery cut below the configured budget', () => {
const f = flushAssistant([], '', 'error', {
error: 'ctx',
replayOverflowCount: 2,
replayBelowConfiguredBudget: true,
});
// MUTATION SENTINEL: dropping the metadata stamp in flushAssistant reddens this.
expect(f.metadata.replayBelowConfiguredBudget).toBe(true);
});
it('omits replayBelowConfiguredBudget on a normal in-budget replay', () => {
// false/omitted must NOT leave the flag on the turn (only the below-budget case).
const off = flushAssistant([], '', 'completed', {
replayBelowConfiguredBudget: false,
});
expect('replayBelowConfiguredBudget' in off.metadata).toBe(false);
});
// A clean finalize (no overflow -> count 0/omitted) leaves NO counter, which the
// next turn reads as k=0 — the reset that ends a recovery streak.
it('omits replayOverflowCount for a zero/absent count (reset semantics)', () => {
const zero = flushAssistant([], '', 'completed', { replayOverflowCount: 0 });
expect('replayOverflowCount' in zero.metadata).toBe(false);
});
// #274 observability: the page-change diff the agent saw this turn is persisted
+96 -30
View File
@@ -141,9 +141,10 @@ const OUTPUT_DEGENERATION_ERROR =
// Prefix recorded on the assistant row when the provider rejected the turn for
// CONTEXT OVERFLOW (#490): the replayed history exceeded the model's window. The
// row is ALSO stamped `metadata.replayOverflow` so the NEXT turn's budgeter trims
// aggressively (the reactive recovery — the overflowing turn had no usage signal
// to trigger preventive trimming, so the classified 400 is what un-bricks it).
// row is ALSO stamped `metadata.replayOverflowCount` (the consecutive-overflow
// counter, #520) so the NEXT turn's budgeter trims with escalating aggression (the
// reactive recovery — the overflowing turn had no usage signal to trigger
// preventive trimming, so the classified 400 is what un-bricks it).
export const CONTEXT_OVERFLOW_ERROR_PREFIX =
'Диалог превысил контекстное окно модели; история будет агрессивно ' +
'сокращена на следующем ходу.';
@@ -1228,27 +1229,52 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
}
// Last turn's provider-reported context size (authoritative when present).
const priorContextTokens = lastAssistantContextTokens(oldHistory);
// Reactive recovery (#490): if the LAST turn was rejected for context
// overflow (stamped by onError), trim AGGRESSIVELY this turn — the
// overflowing turn produced no usage signal, so a normal-threshold trim may
// not shrink enough to fit. This is what un-bricks a chat that just 400'd.
const priorOverflowed = lastAssistantReplayOverflow(oldHistory);
// Reactive recovery (#490/#520): `k` = how many CONSECUTIVE preceding turns
// were rejected for context overflow (stamped by onError). Each consecutive
// overflow trims MORE aggressively (resolveEffectiveReplayThreshold scales the
// budget by 0.5**k, clamped at the floor) so recovery ESCALATES until the
// history fits — the overflowing turn produced no usage signal, so a single
// fixed cut may not shrink enough when the real model window is small. This is
// what un-bricks a chat that keeps 400'ing on the context window.
const priorOverflowCount = lastAssistantReplayOverflowCount(oldHistory);
const effectiveThreshold = resolveEffectiveReplayThreshold(
replayBudget.thresholdTokens,
priorOverflowed,
priorOverflowCount,
);
if (priorOverflowed) {
this.logger.warn(
`AI chat (chat ${chatId}): previous turn hit context overflow; ` +
`applying aggressive replay budget (${effectiveThreshold} tokens).`,
);
// #520 Option B: recovery MAY cut the replay budget BELOW the admin-configured
// window when the provider keeps proving non-fit (the anti-brick invariant of
// the reactive branch beats a config that a 400 has disproved). Surface it so
// the admin sees their window is factually too large, and mark the turn so the
// UI/telemetry can show it. Only true when actually below (not on in-budget
// escalation edge cases); k>0 implies below for any budget >= 2 tokens.
const replayBelowConfiguredBudget =
replayBudget.thresholdTokens != null &&
effectiveThreshold != null &&
effectiveThreshold < replayBudget.thresholdTokens;
if (priorOverflowCount > 0) {
if (replayBelowConfiguredBudget) {
this.logger.warn(
`AI chat (chat ${chatId}): configured replay budget ` +
`(${replayBudget.thresholdTokens} tokens) does not fit the model — ` +
`replaying BELOW it at ${effectiveThreshold} tokens after ` +
`${priorOverflowCount} consecutive context overflow(s) ` +
`(escalation level ${priorOverflowCount}). Lower chatContextWindow ` +
`to match the model's real context window.`,
);
} else {
this.logger.warn(
`AI chat (chat ${chatId}): ${priorOverflowCount} consecutive context ` +
`overflow(s); applying escalated aggressive replay budget ` +
`(${effectiveThreshold} tokens).`,
);
}
}
const preTrim = trimHistoryForReplay(
messages,
effectiveThreshold,
// A prior OVERFLOW means the provider count is stale/absent — force the
// char-estimate path by ignoring priorContextTokens on recovery.
priorOverflowed ? undefined : priorContextTokens,
priorOverflowCount > 0 ? undefined : priorContextTokens,
);
messages = preTrim.messages;
// Observability (#490): record the budgeter's decision on the turn so the UI
@@ -1903,6 +1929,10 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
pageChanged,
partsCache,
replayTrimmedToTokens,
// #520 Option B: mark the turn when recovery replayed BELOW the
// configured budget (only when it actually did).
replayBelowConfiguredBudget:
replayBelowConfiguredBudget || undefined,
}),
);
// #184/#487: the RUN is finalized ALWAYS (never gated on the message).
@@ -1981,7 +2011,16 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
pageChanged,
partsCache,
replayTrimmedToTokens,
replayOverflow: overflow || undefined,
// #520: escalate the consecutive-overflow counter so the NEXT turn
// trims MORE aggressively (0.5**k). k grows by 1 each consecutive
// overflow; a clean finalize omits the field, resetting it to 0.
replayOverflowCount: overflow
? priorOverflowCount + 1
: undefined,
// #520 Option B: mark the turn when THIS replay was already below the
// configured budget (only when it actually was).
replayBelowConfiguredBudget:
replayBelowConfiguredBudget || undefined,
}),
);
// #184: settle the RUN as failed, carrying the provider/transport cause.
@@ -2413,22 +2452,39 @@ export function seedActivatedTools(
}
/**
* Whether the most recent assistant turn was rejected for CONTEXT OVERFLOW
* (#490): its row carries `metadata.replayOverflow` (stamped by the stream's
* onError). The next turn's budgeter reads this to trim aggressively the
* reactive recovery. Only the LAST assistant turn matters (an older overflow was
* already recovered), so we stop at the first assistant row scanning backwards.
* How many CONSECUTIVE recent turns were rejected for CONTEXT OVERFLOW (#490/#520):
* `k`, read from the most recent assistant row's `metadata.replayOverflowCount`
* (stamped by the stream's onError, incremented each consecutive overflow and reset
* to 0 on any clean finalize). The next turn's budgeter feeds this to
* {@link resolveEffectiveReplayThreshold} to trim with ESCALATING aggression the
* reactive recovery. Only the LAST assistant turn matters (its count already carries
* the consecutive streak; an older overflow followed by a clean turn was recovered),
* so we stop at the first assistant row scanning backwards.
*
* BACK-COMPAT: a row written by the pre-#520 boolean stamp (`replayOverflow: true`,
* no count) is read as k=1 the old single 0.5× behavior so in-flight chats do
* not regress across the deploy.
*/
export function lastAssistantReplayOverflow(
export function lastAssistantReplayOverflowCount(
history: ReadonlyArray<AiChatMessage>,
): boolean {
): number {
for (let i = history.length - 1; i >= 0; i--) {
const row = history[i];
if (row.role !== 'assistant') continue;
const meta = (row.metadata ?? {}) as { replayOverflow?: unknown };
return meta.replayOverflow === true;
const meta = (row.metadata ?? {}) as {
replayOverflowCount?: unknown;
replayOverflow?: unknown;
};
if (typeof meta.replayOverflowCount === 'number') {
// Guard against a corrupt/negative persisted value.
return meta.replayOverflowCount > 0
? Math.floor(meta.replayOverflowCount)
: 0;
}
// Back-compat: legacy boolean stamp -> one overflow (0.5× cut).
return meta.replayOverflow === true ? 1 : 0;
}
return false;
return 0;
}
/** The last message with role 'user' from a useChat payload, if any. */
@@ -3084,9 +3140,16 @@ export function flushAssistant(
// the (estimated) token size it trimmed to — the UI can show "replay truncated
// at N tokens". Omitted when nothing was trimmed.
replayTrimmedToTokens?: number;
// #490 reactive branch: set when the provider rejected this turn for context
// overflow. Stamped into metadata so the NEXT turn's budgeter trims aggressively.
replayOverflow?: boolean;
// #490/#520 reactive branch: the consecutive context-overflow count for THIS
// turn (prior streak + 1) when the provider rejected it for context overflow.
// Stamped into metadata so the NEXT turn's budgeter trims with escalating
// aggression (0.5**k). Omitted (undefined) on a clean turn, which resets k to 0.
replayOverflowCount?: number;
// #520 Option B observability: true when recovery replayed this turn's history
// BELOW the admin-configured budget (the configured window did not fit and the
// anti-brick invariant cut past it). Omitted on a normal in-budget replay so the
// flag marks ONLY the "config is factually too large" case.
replayBelowConfiguredBudget?: boolean;
},
): AssistantFlush {
const finished = capturedSteps ?? [];
@@ -3139,7 +3202,10 @@ export function flushAssistant(
metadata.maxContextTokens = extra.maxContextTokens;
if (extra?.replayTrimmedToTokens)
metadata.replayTrimmedToTokens = extra.replayTrimmedToTokens;
if (extra?.replayOverflow) metadata.replayOverflow = true;
if (extra?.replayOverflowCount && extra.replayOverflowCount > 0)
metadata.replayOverflowCount = extra.replayOverflowCount;
if (extra?.replayBelowConfiguredBudget)
metadata.replayBelowConfiguredBudget = true;
if (extra?.error) metadata.error = extra.error;
// Persist the page-change diff the agent saw this turn (#274 observability),
// so history / the Markdown export can show what the user changed. Only when
@@ -1,6 +1,11 @@
import { randomBytes } from 'crypto';
import { Client } from 'pg';
import { flushAssistant, serializeSteps } from './ai-chat.service';
import {
flushAssistant,
serializeSteps,
lastAssistantReplayOverflowCount,
} from './ai-chat.service';
import type { AiChatMessage } from '@docmost/db/types/entity.types';
/**
* #490 write-volume regression an OBSERVABLE-PROPERTY test on a LIVE Postgres,
@@ -207,3 +212,111 @@ describe('#490 write-volume on a live Postgres (pg_current_wal_lsn delta)', () =
expect(v2).toBeLessThan(v1 * 0.75);
}, 120_000);
});
/**
* #520 reactive-recovery COUNTER lifecycle on a LIVE Postgres proves the
* consecutive-overflow count survives a real jsonb metadata round-trip (the persist
* path), not just an in-memory object. flushAssistant BUILDS the row metadata, we
* WRITE it to a jsonb column, READ it back, then reconstruct the assistant row and
* run lastAssistantReplayOverflowCount over it exactly the read the next turn does.
*
* The lifecycle proven end-to-end through pg:
* - consecutive overflows INCREMENT k (1 -> 2 -> 3);
* - a CLEAN finalize omits the field, which the reader treats as a RESET to 0;
* - a legacy boolean row (`replayOverflow: true`) reads back as k=1 (back-compat).
*/
describe('#520 overflow-counter lifecycle on a live Postgres (jsonb round-trip)', () => {
let client: Client | undefined;
let available = false;
beforeAll(async () => {
try {
client = new Client(CONN);
await client.connect();
await client.query('SELECT 1');
available = true;
} catch {
available = false;
client = undefined;
}
});
afterAll(async () => {
await client?.end().catch(() => undefined);
});
// Round-trip an arbitrary metadata object through a real jsonb column and read it
// back as the reconstructed assistant row the next turn would load.
async function roundTrip(
c: Client,
metadata: unknown,
): Promise<AiChatMessage> {
await c.query('UPDATE _wal_counter SET metadata=$1 WHERE id=1', [
JSON.stringify(metadata),
]);
const back = (await c.query('SELECT metadata FROM _wal_counter WHERE id=1'))
.rows[0].metadata as Record<string, unknown>;
return { role: 'assistant', metadata: back } as unknown as AiChatMessage;
}
it('increments across consecutive overflows, resets on a clean turn, and honors the legacy boolean', async () => {
if (!available || !client) {
console.warn('SKIP: gitmost-test-pg not reachable; skipping counter test.');
return;
}
const c = client;
await c.query('DROP TABLE IF EXISTS _wal_counter');
await c.query('CREATE TABLE _wal_counter(id int primary key, metadata jsonb)');
await c.query("INSERT INTO _wal_counter VALUES (1, '{}'::jsonb)");
// Turn 1 overflow: prior streak 0 -> stamp k=1 (as the service does: prior+1).
let prior = lastAssistantReplayOverflowCount([]); // fresh chat
expect(prior).toBe(0);
let row = await roundTrip(
c,
flushAssistant([], '', 'error', {
error: 'ctx',
replayOverflowCount: prior + 1,
}).metadata,
);
prior = lastAssistantReplayOverflowCount([row]);
expect(prior).toBe(1);
// Turn 2 overflow: prior 1 -> stamp k=2.
row = await roundTrip(
c,
flushAssistant([], '', 'error', {
error: 'ctx',
replayOverflowCount: prior + 1,
}).metadata,
);
prior = lastAssistantReplayOverflowCount([row]);
expect(prior).toBe(2);
// Turn 3 overflow: prior 2 -> stamp k=3.
row = await roundTrip(
c,
flushAssistant([], '', 'error', {
error: 'ctx',
replayOverflowCount: prior + 1,
}).metadata,
);
prior = lastAssistantReplayOverflowCount([row]);
expect(prior).toBe(3);
// Turn 4 CLEAN finalize: no overflow -> the field is omitted -> reset to 0.
row = await roundTrip(
c,
flushAssistant([], 'all good', 'completed', { finishReason: 'stop' })
.metadata,
);
expect('replayOverflowCount' in (row.metadata as object)).toBe(false);
expect(lastAssistantReplayOverflowCount([row])).toBe(0);
// Back-compat: a row persisted by the pre-#520 boolean stamp reads back as k=1.
row = await roundTrip(c, { replayOverflow: true });
expect(lastAssistantReplayOverflowCount([row])).toBe(1);
await c.query('DROP TABLE IF EXISTS _wal_counter');
}, 60_000);
});
@@ -32,6 +32,14 @@ describe('EmbeddingIndexerService.reindexWorkspace fail-fast', () => {
const pageEmbeddingRepo = {};
const aiService = {
getEmbeddingModel: jest.fn().mockResolvedValue('some-model'),
// #530: reindexWorkspace's pre-check now resolves the provider (workspace
// or global). Resolve it so the batch control flow under test proceeds.
resolveEmbeddingProvider: jest.fn().mockResolvedValue({
model: 'some-model',
queryPrefix: '',
docPrefix: '',
fingerprint: 'fp-test',
}),
};
// Progress is a best-effort cosmetic store; mock its async methods so the
// batch control flow can be tested without Redis.
@@ -108,6 +116,14 @@ describe('EmbeddingIndexerService.reindexWorkspace progress', () => {
const pageEmbeddingRepo = {};
const aiService = {
getEmbeddingModel: jest.fn().mockResolvedValue('some-model'),
// #530: reindexWorkspace's pre-check now resolves the provider (workspace
// or global). Resolve it so the batch control flow under test proceeds.
resolveEmbeddingProvider: jest.fn().mockResolvedValue({
model: 'some-model',
queryPrefix: '',
docPrefix: '',
fingerprint: 'fp-test',
}),
};
const reindexProgress = {
start: jest.fn().mockResolvedValue(undefined),
@@ -174,7 +190,7 @@ describe('EmbeddingIndexerService.reindexWorkspace progress', () => {
const { service, aiService, reindexProgress } = makeService();
// Embeddings not configured: reindexWorkspace returns early WITHOUT starting
// a fresh record, but the finally must still clear the enqueue-time seed.
aiService.getEmbeddingModel = jest
aiService.resolveEmbeddingProvider = jest
.fn()
.mockRejectedValue(new AiEmbeddingNotConfiguredException());
@@ -187,3 +203,87 @@ describe('EmbeddingIndexerService.reindexWorkspace progress', () => {
expect(reindexProgress.clear).toHaveBeenCalledWith(WORKSPACE_ID);
});
});
/**
* #530 PR-1: reindexPage must (a) prepend the provider's DOC prefix to each chunk
* BEFORE embedding (so stored vectors live in the same prefixed space as a
* prefixed query), and (b) stamp the ACTIVE fingerprint on every inserted row (so
* search only fuses same-generation vectors). Uses lightweight mocks; the tx is
* stubbed to run its callback inline.
*/
describe('EmbeddingIndexerService.reindexPage doc-prefix + fingerprint (#530)', () => {
const WORKSPACE_ID = 'ws-1';
const SPACE_ID = 'space-1';
const PAGE_ID = 'page-1';
function makeService(docPrefix: string) {
const pageRepo = {
findById: jest.fn().mockResolvedValue({
id: PAGE_ID,
workspaceId: WORKSPACE_ID,
spaceId: SPACE_ID,
title: 'Заголовок',
// No ProseMirror content -> the plain-text fallback path (single chunk).
content: null,
textContent: 'простой текст страницы',
deletedAt: null,
}),
};
const insertChunks = jest.fn().mockResolvedValue(undefined);
const pageEmbeddingRepo = {
deleteByPage: jest.fn().mockResolvedValue(undefined),
insertChunks,
};
const embedWithModel = jest.fn().mockResolvedValue([[0.1, 0.2, 0.3]]);
const aiService = {
resolveEmbeddingProvider: jest.fn().mockResolvedValue({
model: { modelId: 'e5-small' },
queryPrefix: 'query: ',
docPrefix,
fingerprint: 'fp-gen-1',
}),
embedWithModel,
};
const reindexProgress = {};
// Stub the tx so executeTx runs its callback inline against a fake trx.
const db = {
transaction: () => ({ execute: (cb: any) => cb({}) }),
};
const service = new EmbeddingIndexerService(
pageRepo as unknown as PageRepo,
pageEmbeddingRepo as unknown as PageEmbeddingRepo,
aiService as unknown as AiService,
reindexProgress as unknown as EmbeddingReindexProgressService,
db as unknown as KyselyDB,
);
return { service, embedWithModel, insertChunks };
}
it('prepends the doc prefix to each chunk and stamps the fingerprint on rows', async () => {
const { service, embedWithModel, insertChunks } = makeService('passage: ');
await service.reindexPage(PAGE_ID);
// Embedded values are DOC-prefixed; the model is the resolved provider model.
expect(embedWithModel).toHaveBeenCalledTimes(1);
const [modelArg, wsArg, valuesArg] = embedWithModel.mock.calls[0];
expect(modelArg).toEqual({ modelId: 'e5-small' });
expect(wsArg).toBe(WORKSPACE_ID);
expect(valuesArg).toEqual(['passage: простой текст страницы']);
// Inserted rows carry the active fingerprint and the ORIGINAL (un-prefixed)
// content (the prefix is an embedding-space artifact, not stored text).
expect(insertChunks).toHaveBeenCalledTimes(1);
const rows = insertChunks.mock.calls[0][0];
expect(rows).toHaveLength(1);
expect(rows[0].fingerprint).toBe('fp-gen-1');
expect(rows[0].content).toBe('простой текст страницы');
expect(rows[0].modelName).toBe('e5-small');
});
it('does not prefix when the provider has an empty doc prefix', async () => {
const { service, embedWithModel } = makeService('');
await service.reindexPage(PAGE_ID);
const [, , valuesArg] = embedWithModel.mock.calls[0];
expect(valuesArg).toEqual(['простой текст страницы']);
});
});
@@ -108,15 +108,24 @@ export class EmbeddingIndexerService {
return;
}
// Resolve embeddings config WITHOUT crashing the queue when unconfigured.
// Resolve the embeddings provider WITHOUT crashing the queue when
// unconfigured. #530: resolveEmbeddingProvider prefers the workspace provider
// and falls back to the GLOBAL env provider (TEI sidecar), and yields the
// doc-prefix + the active fingerprint stored per row so search filters by the
// active generation.
let modelName = 'unknown';
let provider: Awaited<
ReturnType<AiService['resolveEmbeddingProvider']>
>;
try {
const model = await this.aiService.getEmbeddingModel(workspaceId);
provider = await this.aiService.resolveEmbeddingProvider(workspaceId);
// Record the model id per row so a future migration can detect + re-index
// rows produced by a different model (see the migration header). The SDK
// type is `string | EmbeddingModel{V2,V3}`; model objects carry `modelId`.
modelName =
typeof model === 'string' ? model : (model.modelId ?? 'unknown');
typeof provider.model === 'string'
? provider.model
: (provider.model.modelId ?? 'unknown');
} catch (err) {
if (err instanceof AiEmbeddingNotConfiguredException) {
// No embeddings provider for this workspace: NO-OP (§6.7). The page can
@@ -145,8 +154,18 @@ export class EmbeddingIndexerService {
return;
}
// Embed all chunks in one batch.
const vectors = await this.aiService.embedTexts(workspaceId, chunks);
// #530: prepend the provider's DOC prefix to each chunk (e5-style
// "passage: "; empty for a non-e5 provider) so stored vectors live in the
// same prefixed space as a prefixed query, then embed with the RESOLVED
// provider model (which may be the global TEI sidecar, not a workspace one).
const prefixedChunks = provider.docPrefix
? chunks.map((c) => provider.docPrefix + c)
: chunks;
const vectors = await this.aiService.embedWithModel(
provider.model,
workspaceId,
prefixedChunks,
);
// The column is dimension-agnostic, so ANY model dimension is stored as-is.
// Defensive sanity check only: all chunks of ONE page come from the SAME
@@ -170,6 +189,7 @@ export class EmbeddingIndexerService {
vectors,
{ pageId, workspaceId, spaceId },
modelName,
provider.fingerprint,
);
// HARD replace in one transaction: delete then insert so search never
@@ -216,7 +236,9 @@ export class EmbeddingIndexerService {
// (seeded at enqueue time); the finally cleans that too.
try {
try {
await this.aiService.getEmbeddingModel(workspaceId);
// #530: resolve via the same path reindexPage uses (workspace provider,
// else the global TEI sidecar) so a global-only deployment is NOT skipped.
await this.aiService.resolveEmbeddingProvider(workspaceId);
} catch (err) {
if (err instanceof AiEmbeddingNotConfiguredException) {
this.logger.log(
@@ -348,6 +370,7 @@ export class EmbeddingIndexerService {
vectors: number[][],
ids: { pageId: string; workspaceId: string; spaceId: string },
modelName: string,
fingerprint: string | null,
): PageEmbeddingChunkRow[] {
const rows: PageEmbeddingChunkRow[] = [];
let cursor = 0;
@@ -370,6 +393,8 @@ export class EmbeddingIndexerService {
// Provenance for a future re-index sweep on model change.
modelName,
modelDimensions: embedding.length,
// #530: the active generation fingerprint this row belongs to.
fingerprint,
embedding,
});
}
@@ -1,14 +1,146 @@
import type { ModelMessage } from 'ai';
import {
resolveReplayBudget,
resolveEffectiveReplayThreshold,
isContextOverflowError,
estimateMessagesTokens,
trimHistoryForReplay,
REPLAY_BUDGET_DEFAULT_TOKENS,
REPLAY_BUDGET_WINDOW_FRACTION,
REPLAY_MIN_FLOOR_TOKENS,
REPLAY_TRUNCATION_MARKER,
REPLAY_TURN_COLLAPSED_MARKER,
} from './history-budget';
describe('resolveEffectiveReplayThreshold (#520 iterative escalation)', () => {
// The escalation table: each consecutive overflow (k) deepens the cut by 0.5×.
it('scales the base by 0.5**k, flooring (not rounding) fractional tokens', () => {
const base = 100_000;
expect(resolveEffectiveReplayThreshold(base, 0)).toBe(base); // k=0: unchanged
expect(resolveEffectiveReplayThreshold(base, 1)).toBe(50_000); // 0.5×
expect(resolveEffectiveReplayThreshold(base, 2)).toBe(25_000); // 0.25×
expect(resolveEffectiveReplayThreshold(base, 3)).toBe(12_500); // 0.125×
// Floors, not rounds.
expect(resolveEffectiveReplayThreshold(99_999, 1)).toBe(49_999);
});
it('passes a null base (trimming OFF) through unchanged for any k', () => {
for (const k of [0, 1, 2, 5, 100]) {
expect(resolveEffectiveReplayThreshold(null, k)).toBeNull();
}
});
// The crux of #520: convergence. A large k is clamped at REPLAY_MIN_FLOOR_TOKENS,
// so the escalation CONVERGES to a small-but-usable budget instead of trimming to
// zero — and, unlike the old fixed 0.5× that stuck at 50k, it drops far enough to
// fit a small real model window.
it('clamps a large k at the floor (converges, never below)', () => {
const base = 100_000;
for (const k of [4, 6, 10, 50, 200]) {
const t = resolveEffectiveReplayThreshold(base, k) as number;
expect(t).toBe(REPLAY_MIN_FLOOR_TOKENS);
expect(t).toBeGreaterThanOrEqual(REPLAY_MIN_FLOOR_TOKENS);
}
});
// Residual-brick regression (#520): flat-default base 100k, real window ~40k. The
// OLD fixed single 0.5× stuck at 50k > 40k forever (re-overflows every turn — the
// brick). The iterative cut drops BELOW 40k after a couple more consecutive
// overflows, so the history finally fits and the chat un-bricks.
it('un-bricks: escalation drops below a small real window the fixed 0.5× never could', () => {
const base = 100_000;
const realWindow = 40_000;
// The old terminal state: 0.5× = 50k, still above the window.
expect(resolveEffectiveReplayThreshold(base, 1)).toBeGreaterThan(realWindow);
// Escalation converges under the window.
const converged = [2, 3, 4, 5].some(
(k) => (resolveEffectiveReplayThreshold(base, k) as number) < realWindow,
);
expect(converged).toBe(true);
// MUTATION SENTINEL: reverting `** k` to `** 1` (fixed 0.5×) makes every k yield
// 50k, so `converged` above would be FALSE and this test reddens. Removing the
// floor reddens the clamp test instead.
});
// "Don't INFLATE above itself" is still an invariant: the floor never RAISES a
// legitimately small configured budget above itself (that would re-overflow the
// very window it was set for). Under #520 Option B the floor is min(FLOOR,
// floor(0.5×base)), so for a base BELOW the floor recovery MAY cut it further —
// down to floor(0.5×base), i.e. AT LEAST the old single 0.5× cut — but never above.
it('never inflates a small configured budget above itself (may cut below it, #520 Option B)', () => {
const small = 5_000; // below REPLAY_MIN_FLOOR_TOKENS
const floor = Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(small * 0.5)); // 2500
expect(resolveEffectiveReplayThreshold(small, 0)).toBe(small); // k=0 unchanged
for (const k of [1, 2, 3, 10]) {
const t = resolveEffectiveReplayThreshold(small, k) as number;
// Invariant: never RAISED above the configured budget.
expect(t).toBeLessThanOrEqual(small);
// Option B: never trimmed below the absolute floor (converges).
expect(t).toBeGreaterThanOrEqual(floor);
}
// The old single 0.5× cut is reached (and pinned) — recovery is never WORSE than
// before, and DOES cut below the configured budget on overflow (Option B). Under
// the old floor==budget behavior this would be `small` (5000), not `floor`.
expect(resolveEffectiveReplayThreshold(small, 1)).toBe(floor);
});
// #520 Option B: with a configured window (NOT the flat default), repeated overflow
// escalates the replay budget BELOW the configured budget, down to the absolute
// floor — the chat must not brick even when the configured window is too large for
// the model. A CLEAN turn (k back to 0) restores the full configured budget.
it('escalates below the configured budget down to the floor, and resets on a clean turn', () => {
// Configured context window 8000 -> budget = floor(0.7 × 8000) = 5600 (the code's
// window->budget ratio; compute it, do not hardcode).
const window = 8_000;
const budget = resolveReplayBudget(window).thresholdTokens as number;
expect(budget).toBe(Math.floor(REPLAY_BUDGET_WINDOW_FRACTION * window)); // 5600
// The absolute floor for this budget = min(FLOOR, floor(0.5 × budget)) = 2800
// (a "small window": 0.5×budget < REPLAY_MIN_FLOOR_TOKENS), which is BELOW the
// 5600 configured budget.
const floor = Math.min(
REPLAY_MIN_FLOOR_TOKENS,
Math.floor(budget * 0.5),
); // 2800
expect(floor).toBe(2_800);
expect(floor).toBeLessThan(budget); // below the configured budget
// k=0 -> full configured budget (no overflow yet).
expect(resolveEffectiveReplayThreshold(budget, 0)).toBe(budget);
// k=1 -> cut BELOW the configured budget to the 0.5× floor (2800). This is the
// MUTATION SENTINEL: with the OLD floor==configured-budget behavior this would be
// max(2800, 5600) = 5600 (never below budget), so the assertion reddens.
expect(resolveEffectiveReplayThreshold(budget, 1)).toBe(floor);
expect(resolveEffectiveReplayThreshold(budget, 1)).toBeLessThan(budget);
// k>=2 stays at the floor (converges, never below it).
for (const k of [2, 3, 5, 20]) {
expect(resolveEffectiveReplayThreshold(budget, k)).toBe(floor);
}
// A CLEAN turn resets k to 0 -> the full configured budget is restored (recovery
// is not sticky; it only bites while the provider keeps proving non-fit).
expect(resolveEffectiveReplayThreshold(budget, 0)).toBe(budget);
});
// #520 Option B, LARGE window: a big configured budget escalates in DISTINCT steps
// below itself, converging to the fixed absolute floor REPLAY_MIN_FLOOR_TOKENS.
it('a large configured budget escalates below itself down to REPLAY_MIN_FLOOR_TOKENS', () => {
const budget = resolveReplayBudget(200_000).thresholdTokens as number; // 140000
// The floor for a large window is the fixed REPLAY_MIN_FLOOR_TOKENS (8k), well
// BELOW the configured budget.
expect(
Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(budget * 0.5)),
).toBe(REPLAY_MIN_FLOOR_TOKENS);
const seq = [0, 1, 2, 3, 4, 5, 6].map(
(k) => resolveEffectiveReplayThreshold(budget, k) as number,
);
expect(seq).toEqual([140_000, 70_000, 35_000, 17_500, 8_750, 8_000, 8_000]);
// Every escalated step (k>=1) is below the configured budget; convergence floor.
for (const t of seq.slice(1)) expect(t).toBeLessThan(budget);
expect(seq[seq.length - 1]).toBe(REPLAY_MIN_FLOOR_TOKENS);
});
});
describe('resolveReplayBudget', () => {
it('uses floor(0.7 x window) for a configured window (no cap)', () => {
// 0.7 x 60k = 42k
+62 -11
View File
@@ -22,10 +22,35 @@ export const REPLAY_BUDGET_DEFAULT_TOKENS = 100_000;
/** Fraction of a configured context window used as the budget. */
export const REPLAY_BUDGET_WINDOW_FRACTION = 0.7;
/**
* Fraction of the normal budget used for the REACTIVE re-trim after a provider
* context-overflow 400 the preventive estimate under-counted, so cut harder.
* Per-step fraction of the normal budget applied on the REACTIVE re-trim after a
* provider context-overflow 400 the preventive estimate under-counted, so cut
* harder. This is now applied ITERATIVELY: with `k` consecutive overflow turns the
* budget is scaled by `fraction ** k` (k=1 -> 0.5×, k=2 -> 0.25×, ), so recovery
* ESCALATES turn over turn until the replayed history finally fits, instead of the
* old single fixed 0.5× cut that could never un-brick a chat whose real model
* window is smaller than 0.5 × the (unconfigured, flat-default) base budget (#520).
*/
export const REPLAY_AGGRESSIVE_FRACTION = 0.5;
/**
* Absolute lower bound (tokens) on the escalating reactive budget: the iterative
* cut is clamped here so it CONVERGES (a fixed floor, not an ever-shrinking value
* that would eventually trim everything). Rationale: below ~8k tokens a chat cannot
* carry meaningful recent context, and even a small real model window comfortably
* fits this much keep-recent-turns still applies on top, so a handful of recent
* turns survive.
*
* #520 Option B: for a LARGE configured window this floor sits BELOW the configured
* replay budget, and recovery is allowed to escalate PAST the configured budget down
* to it "the chat must not brick on overflow" is an INVARIANT of the reactive
* branch, and a configured window is a claim about model capacity, not a promise
* about replay size; once the provider proves non-fit with a 400, reality beats the
* config. This does NOT change the NORMAL path's upper bound (#510 Option A still
* respects the configured budget while it fits) it is survival once non-fit is
* proven. For a SMALL configured window the effective floor is instead the old
* single 0.5× cut (never worse than before, and never RAISED above the budget); see
* {@link resolveEffectiveReplayThreshold}.
*/
export const REPLAY_MIN_FLOOR_TOKENS = 8_000;
/**
* Turns (a user message + its assistant/tool replies) kept FULL at the tail,
* including the current one never trimmed. Older turns are compacted first.
@@ -85,22 +110,48 @@ export function resolveReplayBudget(rawContextWindow: unknown): ReplayBudget {
}
/**
* The effective replay threshold for THIS turn, given the base budget and whether
* the PREVIOUS turn hit a context-overflow 400 (the reactive-recovery signal,
* `metadata.replayOverflow`). On recovery the base budget is scaled down by
* {@link REPLAY_AGGRESSIVE_FRACTION}: the overflowing turn produced no usage
* signal, so the preventive estimate under-counted and a normal-threshold trim may
* not shrink enough to fit this harder cut is what un-bricks the chat.
* The effective replay threshold for THIS turn, given the base budget and `k` the
* number of CONSECUTIVE preceding turns that hit a context-overflow 400 (the
* reactive-recovery signal `metadata.replayOverflowCount`, read from the last
* assistant row). On recovery the base budget is scaled down ITERATIVELY by
* {@link REPLAY_AGGRESSIVE_FRACTION} ** k and clamped at {@link REPLAY_MIN_FLOOR_TOKENS}:
* - k=0 -> base unchanged (no overflow: nothing to recover from).
* - k=1 -> floor(0.5 × base); k=2 -> floor(0.25 × base); each further consecutive
* overflow tightens the cut, so recovery ESCALATES until the history fits.
* - the escalation is clamped at the floor so it CONVERGES this is what un-bricks
* a chat whose real model window is smaller than a single 0.5× cut of the base
* (e.g. an unconfigured window: flat-default base 100k, real window <50k) (#520).
*
* The overflowing turn produced no usage signal, so the preventive estimate
* under-counted and a normal-threshold (or single fixed 0.5×) trim may not shrink
* enough to fit; the escalating cut is what recovers such a chat.
*
* A `null` base budget (trimming OFF) is passed through unchanged: an explicit
* off-switch is never overridden by the recovery path.
*
* The absolute floor is `min(REPLAY_MIN_FLOOR_TOKENS, floor(0.5 × base))` (#520
* Option B):
* - LARGE window (floor(0.5 × base) >= REPLAY_MIN_FLOOR_TOKENS) -> the fixed
* REPLAY_MIN_FLOOR_TOKENS, which is BELOW the configured budget: escalation is
* ALLOWED to cut past the configured budget down to this absolute minimum, so a
* chat whose real model window is far smaller than the configured window still
* un-bricks (the invariant beats a config reality disproved with a 400).
* - SMALL window (floor(0.5 × base) < REPLAY_MIN_FLOOR_TOKENS) -> floor(0.5 ×
* base), i.e. AT LEAST the old single 0.5× cut: recovery is never WORSE than the
* pre-#520 behavior, and the floor is still never RAISED above the configured
* budget itself (that would re-overflow the very window it was set for).
*/
export function resolveEffectiveReplayThreshold(
thresholdTokens: number | null,
priorOverflowed: boolean,
k: number,
): number | null {
if (!priorOverflowed || thresholdTokens == null) return thresholdTokens;
return Math.floor(thresholdTokens * REPLAY_AGGRESSIVE_FRACTION);
if (thresholdTokens == null || k <= 0) return thresholdTokens;
const scaled = Math.floor(thresholdTokens * REPLAY_AGGRESSIVE_FRACTION ** k);
const floor = Math.min(
REPLAY_MIN_FLOOR_TOKENS,
Math.floor(thresholdTokens * REPLAY_AGGRESSIVE_FRACTION),
);
return Math.max(scaled, floor);
}
/**
@@ -106,6 +106,7 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.sharePage(s, true);
void client.unsharePage(s);
void client.restorePageVersion(s);
void client.savePageVersion(s);
void client.transformPage(s, s, { dryRun: true });
void client.stashPage(s);
// --- write (image / footnote), in-app since #410 ---
@@ -58,6 +58,7 @@ type DocmostClientMethod =
| 'sharePage'
| 'unsharePage'
| 'restorePageVersion'
| 'savePageVersion'
| 'transformPage'
| 'stashPage'
// --- write (image / footnote), in-app since #410 ---
@@ -32,10 +32,27 @@ export class SearchResultDto {
matchedTerms: string[];
}
// The paginated envelope (A5). `total` is the EXACT permission-filtered count of
// pages matching the positive lexical query (fail-closed). `hasMore` is true when
// more results exist WITHIN the fusion window; `truncatedAtCap` signals the match
// set exceeded CANDIDATE_CAP and the tail is unreachable by pagination.
// #530 Phase B — the semantic (vector) layer's per-request status.
// - `state`: 'full' when a provider resolved and the vector arm ran this
// request; 'off' otherwise (kill-switch, no provider, or a degrade).
// - `available`: whether the vector arm actually contributed this request.
// - `reason`: why the arm did NOT run — 'no-provider' (no embedding provider
// resolved) or 'degraded' (sidecar down / query embed timed out).
// - `indexed`/`total`: reserved for PR-2 coverage reporting (unused in PR-1).
export class SearchSemanticDto {
state: 'full' | 'off';
available: boolean;
reason?: 'no-provider' | 'degraded';
indexed?: number;
total?: number;
}
// The paginated envelope (A5). `total` is the permission-filtered count of the
// candidate UNION (lexical ∪ vector top-N, fail-closed) — a deliberate, #530
// documented change from Phase A's exact-lexical count (on a semantic degrade it
// falls back to exactly that lexical count). `hasMore` is true when more results
// exist WITHIN the fusion window; `truncatedAtCap` signals the match set exceeded
// CANDIDATE_CAP and the tail is unreachable by pagination.
export class SearchResponseDto {
items: SearchResultDto[];
total: number;
@@ -53,4 +70,7 @@ export class SearchResponseDto {
mode: 'or' | 'and';
match: string;
};
// Absent on the early-exit paths (empty/garbage query); present once search
// actually runs. Optional so those short-circuit responses stay valid.
semantic?: SearchSemanticDto;
}
@@ -1,8 +1,15 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { AiModule } from '../../integrations/ai/ai.module';
/**
* #530: AiModule supplies AiService (embedQuery / resolveEmbeddingProvider).
* PageEmbeddingRepo is provided by the @Global DatabaseModule, so it is injected
* into SearchService without an explicit import here.
*/
@Module({
imports: [AiModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
@@ -48,6 +48,10 @@ describe('SearchService.searchPage — scope-security early returns', () => {
shareRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
// #530 semantic path off for these query-mode unit tests: a stubbed
// embedQuery that throws makes the service degrade to the lexical path.
{ embedQuery: jest.fn().mockRejectedValue(new Error('no embed')) } as any,
{ vectorCandidateArm: jest.fn() } as any,
);
return { service, pageRepo, shareRepo, spaceMemberRepo, pagePermissionRepo };
}
@@ -11,6 +11,8 @@ describe('SearchService', () => {
{} as any, // shareRepo
{} as any, // spaceMemberRepo
{} as any, // pagePermissionRepo
{} as any, // aiService
{} as any, // pageEmbeddingRepo
);
expect(service).toBeDefined();
});
@@ -61,6 +63,8 @@ describe('SearchService.searchSuggestions — onlyTemplates filter', () => {
shareRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
{} as any, // aiService (searchSuggestions never embeds)
{} as any, // pageEmbeddingRepo
);
return { service, db, pageBuilder };
+247 -28
View File
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import {
SearchResponseDto,
SearchResultDto,
SearchSemanticDto,
} from './dto/search-response.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
@@ -11,6 +12,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
import { AiService } from '../../integrations/ai/ai.service';
import { AiEmbeddingNotConfiguredException } from '../../integrations/ai/ai-embedding-not-configured.exception';
import { isStatementTimeout } from '@docmost/db/utils';
import {
ParsedQuery,
ParsedTerm,
@@ -59,14 +64,42 @@ function defaultBooleanMode(): SearchBooleanMode {
return process.env.SEARCH_MODE === 'and' ? 'and' : 'or';
}
// #530 semantic env knobs. SEARCH_SEMANTIC=off is the kill-switch (hybrid is
// otherwise transparent — no per-request mode flag). SEARCH_VECTOR_WEIGHT tunes
// the vector RRF leg's contribution (default 1.0, i.e. equal to each lexical
// leg). SEARCH_VECTOR_CANDIDATES caps the vector top-N pulled into the union.
function semanticKillSwitchOff(): boolean {
return process.env.SEARCH_SEMANTIC === 'off';
}
function getVectorWeight(): number {
const raw = Number(process.env.SEARCH_VECTOR_WEIGHT);
return Number.isFinite(raw) && raw >= 0 ? raw : 1.0;
}
function getVectorCandidates(): number {
const raw = Number(process.env.SEARCH_VECTOR_CANDIDATES);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 50;
}
// Safety net for the brute-force vector scan (no ANN index — see
// vectorCandidateArm): a per-statement timeout bounding ONLY the fused vector
// query, so a pathological seq scan is cancelled (SQLSTATE 57014) and search
// degrades to lexical instead of hanging the request. Default 2000ms.
function getVectorStatementTimeoutMs(): number {
const raw = Number(process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 2000;
}
@Injectable()
export class SearchService {
private readonly logger = new Logger(SearchService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
private aiService: AiService,
private pageEmbeddingRepo: PageEmbeddingRepo,
) {}
// === #529 SQL fragment builders (parameterized AST, never string-concat) =====
@@ -292,33 +325,102 @@ export class SearchService {
const scopeSql = sql`(${sql.join(scopePreds, sql` AND `)})`;
const candidateSql = sql`(${scopeSql} AND ${this.candidatePredicate(parsed, titleOnly)})`;
// --- Semantic (vector) arm: embed the query, degrade gracefully. (#530) ----
// The try/catch wraps ONLY embedQuery + vector-arm construction. On ANY throw
// (TEI down / 800ms timeout / no provider) we omit the vector arm entirely and
// run the byte-identical Phase-A lexical path — never a 500 from the sidecar.
// The permission filter below stays OUTSIDE this try (a permission error must
// 500, never fail-open). Hybrid is transparent; SEARCH_SEMANTIC=off disables it.
let vectorArm: RawBuilder<unknown> | null = null;
let semanticAvailable = false;
let semanticReason: 'no-provider' | 'degraded' | undefined;
if (!semanticKillSwitchOff()) {
try {
const { vector, fingerprint } = await this.aiService.embedQuery(
opts.workspaceId,
rawQuery,
);
vectorArm = this.pageEmbeddingRepo.vectorCandidateArm({
queryEmbedding: vector,
dimensions: vector.length,
fingerprint,
scope: scopeSql,
limit: getVectorCandidates(),
// Filter page_embeddings by (immutable) workspace_id directly, so the
// composite index bites on its leading column and candidates are
// workspace-scoped at the embedding level (#530 review WARNING 2). Space
// scoping stays on the pages join only — page_embeddings.space_id can be
// STALE after a cross-space move, so filtering it here would drop a moved
// page's vector hit (re-review regression fix).
workspaceId: scope.workspaceId,
});
semanticAvailable = true;
} catch (err) {
if (err instanceof AiEmbeddingNotConfiguredException) {
// No embedding provider is the DEFAULT state of any deployment without
// the TEI sidecar — normal, not a problem. Log at DEBUG so it never
// floods WARN once per search request.
semanticReason = 'no-provider';
this.logger.debug(
`search.semantic.no-provider workspace=${opts.workspaceId}`,
);
} else {
// A provider IS configured but the embed call failed/timed out — a real
// sidecar problem worth a WARN. Structured, greppable event (fixed code)
// — no query text/secrets.
semanticReason = 'degraded';
this.logger.warn(
`search.semantic.degraded reason=degraded workspace=${opts.workspaceId}`,
);
}
}
}
// --- Ranked candidate ids (ALL matches, RRF order). -----------------------
// Two independent branches ranked SEPARATELY (FTS by ts_rank_cd, substring by
// tier) then fused with RRF: score = Σ 1/(k + rank_branch). Deterministic
// ORDER BY rrf DESC, id so pagination never dupes/skips (A4, acceptance #8).
const rankedRows = await sql<{ id: string }>`
WITH candidates AS (
SELECT pages.id AS id,
${this.ftsScoreExpr(parsed)} AS fts_score,
${this.subTierExpr(parsed, titleOnly)} AS sub_tier
FROM pages
WHERE ${candidateSql}
),
ranked AS (
SELECT id, fts_score, sub_tier,
row_number() OVER (ORDER BY fts_score DESC NULLS LAST, id) AS rn_fts,
row_number() OVER (ORDER BY sub_tier DESC NULLS LAST, id) AS rn_sub
FROM candidates
)
SELECT id
FROM ranked
ORDER BY
(CASE WHEN fts_score IS NOT NULL THEN 1.0/(${RRF_K} + rn_fts) ELSE 0 END)
+ (CASE WHEN sub_tier IS NOT NULL THEN 1.0/(${RRF_K} + rn_sub) ELSE 0 END) DESC,
id ASC
`.execute(this.db);
// Lexical branches ranked SEPARATELY (FTS by ts_rank_cd, substring by tier);
// when a query vector is available a third VECTOR branch is fused over the
// UNION of lexical + vector candidates. RRF: score = Σ w/(k + rank_branch).
// Deterministic ORDER BY rrf DESC, id so pagination never dupes/skips (A4,
// acceptance #8). `total` is thus the union size (lexical ∪ vector top-N),
// post-permission-filter — a documented change from Phase A's exact lexical
// count (#530). On degrade the query is byte-identical to Phase A.
//
// The fused query is bounded by a per-statement timeout (see runRankedQuery):
// if the brute-force vector scan is cancelled (SQLSTATE 57014) we degrade to
// the byte-identical lexical path — search returns lexical results, never
// hangs, never 500s. Only the timeout is caught here; any OTHER SQL error
// propagates. The permission filter below stays outside, unchanged.
let orderedIds: string[];
try {
orderedIds = await this.runRankedQuery(
candidateSql,
parsed,
titleOnly,
vectorArm,
);
} catch (err) {
if (vectorArm && isStatementTimeout(err)) {
semanticAvailable = false;
semanticReason = 'degraded';
this.logger.warn(
`search.semantic.degraded reason=degraded workspace=${opts.workspaceId} (vector statement timeout)`,
);
// Re-run the exact Phase-A lexical path (no vector arm, no timeout).
orderedIds = await this.runRankedQuery(
candidateSql,
parsed,
titleOnly,
null,
);
} else {
throw err;
}
}
let orderedIds = rankedRows.rows.map((r) => r.id);
const semantic: SearchSemanticDto = {
state: semanticAvailable ? 'full' : 'off',
available: semanticAvailable,
...(semanticReason ? { reason: semanticReason } : {}),
};
// --- Permission filter (fail-closed, exact total). ------------------------
// filterAccessiblePageIds runs the #348 hasRestricted pre-check internally:
@@ -355,14 +457,131 @@ export class SearchService {
const hasMore = offset + pageIds.length < window.length;
if (pageIds.length === 0) {
return { items: [], total, hasMore, truncatedAtCap, offset, query: queryMeta };
return {
items: [],
total,
hasMore,
truncatedAtCap,
offset,
query: queryMeta,
semantic,
};
}
// --- Detail fetch for the page slice only (ts_headline/snippet are costly, so
// compute them ONLY for the returned rows), preserving RRF order. -------
const items = await this.fetchDetails(pageIds, parsed, titleOnly);
return { items, total, hasMore, truncatedAtCap, offset, query: queryMeta };
return {
items,
total,
hasMore,
truncatedAtCap,
offset,
query: queryMeta,
semantic,
};
}
/**
* Run the ranked-candidate-ids query and return the ids in RRF order (#530).
*
* When `vectorArm` is null (degrade / semantic off / no query vector) this runs
* the BYTE-IDENTICAL Phase-A 2-branch lexical query the semantic layer must
* be a pure superset that never changes lexical behaviour on degrade.
*
* When a vector arm is supplied it is UNION ALL'd with the lexical arm into one
* `candidates` set; `agg` collapses each page to its best per-branch signal
* (MAX fts_score / MAX sub_tier / MIN vec_distance); `ranked` assigns a
* per-branch row_number; the final ORDER BY fuses the three legs with RRF,
* adding the vector leg ONLY for rows that have a vec_distance (a lexical-only
* hit contributes 0 to the vector leg, and vice-versa). W_VEC weights the
* vector leg.
*/
private async runRankedQuery(
candidateSql: RawBuilder<unknown>,
parsed: ParsedQuery,
titleOnly: boolean,
vectorArm: RawBuilder<unknown> | null,
): Promise<string[]> {
if (!vectorArm) {
// Phase-A lexical path — DO NOT change (byte-identical on degrade, #529).
const rankedRows = await sql<{ id: string }>`
WITH candidates AS (
SELECT pages.id AS id,
${this.ftsScoreExpr(parsed)} AS fts_score,
${this.subTierExpr(parsed, titleOnly)} AS sub_tier
FROM pages
WHERE ${candidateSql}
),
ranked AS (
SELECT id, fts_score, sub_tier,
row_number() OVER (ORDER BY fts_score DESC NULLS LAST, id) AS rn_fts,
row_number() OVER (ORDER BY sub_tier DESC NULLS LAST, id) AS rn_sub
FROM candidates
)
SELECT id
FROM ranked
ORDER BY
(CASE WHEN fts_score IS NOT NULL THEN 1.0/(${RRF_K} + rn_fts) ELSE 0 END)
+ (CASE WHEN sub_tier IS NOT NULL THEN 1.0/(${RRF_K} + rn_sub) ELSE 0 END) DESC,
id ASC
`.execute(this.db);
return rankedRows.rows.map((r) => r.id);
}
// #530 fused 3-branch RRF over lexical ∪ vector candidates. The lexical arm
// adds NULL::float vec_distance to stay UNION-compatible with the vector arm.
const wVec = getVectorWeight();
// Bound the brute-force vector scan with a per-statement timeout, scoped to
// THIS query only. `set_config(..., is_local := true)` = SET LOCAL semantics:
// it applies within this transaction and auto-resets at COMMIT/ROLLBACK, so
// it can NEVER leak onto the next query that reuses this pooled connection.
// On a cancellation the driver raises SQLSTATE 57014, which searchPage catches
// to degrade to lexical-only. Running inside a transaction also guarantees the
// SET and the ranked query share one connection.
const timeoutMs = getVectorStatementTimeoutMs();
return this.db.transaction().execute(async (trx) => {
await sql`SELECT set_config('statement_timeout', ${String(timeoutMs)}, true)`.execute(
trx,
);
const rankedRows = await sql<{ id: string }>`
WITH candidates AS (
(SELECT pages.id AS id,
${this.ftsScoreExpr(parsed)} AS fts_score,
${this.subTierExpr(parsed, titleOnly)} AS sub_tier,
NULL::float AS vec_distance
FROM pages
WHERE ${candidateSql})
UNION ALL
(${vectorArm})
),
agg AS (
SELECT id,
MAX(fts_score) AS fts_score,
MAX(sub_tier) AS sub_tier,
MIN(vec_distance) AS vec_distance
FROM candidates
GROUP BY id
),
ranked AS (
SELECT id, fts_score, sub_tier, vec_distance,
row_number() OVER (ORDER BY fts_score DESC NULLS LAST, id) AS rn_fts,
row_number() OVER (ORDER BY sub_tier DESC NULLS LAST, id) AS rn_sub,
row_number() OVER (ORDER BY vec_distance ASC NULLS LAST, id) AS rn_vec
FROM agg
)
SELECT id
FROM ranked
ORDER BY
(CASE WHEN fts_score IS NOT NULL THEN 1.0/(${RRF_K} + rn_fts) ELSE 0 END)
+ (CASE WHEN sub_tier IS NOT NULL THEN 1.0/(${RRF_K} + rn_sub) ELSE 0 END)
+ (CASE WHEN vec_distance IS NOT NULL THEN ${wVec}::float/(${RRF_K} + rn_vec) ELSE 0 END) DESC,
id ASC
`.execute(trx);
return rankedRows.rows.map((r) => r.id);
});
}
// Resolve the search scope: explicit space, the authed user's member spaces, or
@@ -0,0 +1,49 @@
import { type Kysely, sql } from 'kysely';
/**
* #530 Search Phase B (PR-1): add the embedding FINGERPRINT column.
*
* The fingerprint is a deterministic id of the embedding configuration that
* produced a row model id + revision + query/doc prefix scheme + dimensions
* (see AiService.computeEmbeddingFingerprint). Search filters vector candidates
* by the workspace's ACTIVE fingerprint so a revision bump or a prefix-scheme
* change never fuses incompatible vectors into results.
*
* PR-1 scope: this migration only ADDS the column (nullable existing rows stay
* NULL = legacy) and the composite index the vector-candidate scan uses. The full
* generational swap / GC lifecycle (target-fingerprint reindex, atomic flip,
* old-generation GC) is deliberately deferred to PR-2.
*
* Independent migration (per the #363 crash-loop net): it creates ONLY its own
* objects and never touches another migration's tables/indexes, so a partial
* failure cannot leave a shared object half-built.
*/
export async function up(db: Kysely<any>): Promise<void> {
// Nullable text column. Existing rows keep NULL (legacy generation) and are
// simply not matched by the active-fingerprint filter until re-indexed.
await sql`
ALTER TABLE page_embeddings
ADD COLUMN IF NOT EXISTS fingerprint text
`.execute(db);
// Composite btree supporting the scoped, dimension- + fingerprint-filtered
// vector-candidate scan (workspace_id + space_id + fingerprint + model_dimensions).
await db.schema
.createIndex('idx_page_embeddings_ws_space_fp_dim')
.ifNotExists()
.on('page_embeddings')
.columns(['workspace_id', 'space_id', 'fingerprint', 'model_dimensions'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('idx_page_embeddings_ws_space_fp_dim')
.ifExists()
.execute();
await sql`
ALTER TABLE page_embeddings
DROP COLUMN IF EXISTS fingerprint
`.execute(db);
}
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { RawBuilder, sql } from 'kysely';
import * as pgvector from 'pgvector';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
@@ -35,6 +35,9 @@ export interface PageEmbeddingChunkRow {
content: string;
modelName: string;
modelDimensions: number;
// #530 PR-1: the active embedding fingerprint (see computeEmbeddingFingerprint).
// null only when no provider resolves (the indexer no-ops in that case).
fingerprint: string | null;
embedding: number[];
}
@@ -120,6 +123,7 @@ export class PageEmbeddingRepo {
content: row.content,
modelName: row.modelName,
modelDimensions: row.modelDimensions,
fingerprint: row.fingerprint,
// pgvector.toSql -> '[1,2,3]'; cast the bound literal to vector.
embedding: sql`${pgvector.toSql(row.embedding)}::vector`,
})),
@@ -127,6 +131,82 @@ export class PageEmbeddingRepo {
.execute();
}
/**
* #530: build the VECTOR candidate arm for SearchService's fused RRF union a
* page-level nearest-neighbour sub-select. Returns a `sql` fragment (not an
* executed query) so the caller can UNION ALL it with the lexical arm inside a
* single ranked-ids query.
*
* SCALING BOUNDARY: the column is dimension-agnostic, so it carries NO ANN
* index this is a brute-force O(N) KNN seq scan with `<=>`. Accepted for the
* small-tenant / homelab fork target (ANN + a pinned-dimension column are
* deferred). It is NOT unbounded, though: the caller (SearchService) runs this
* fused query under a per-statement timeout (SEARCH_VECTOR_STATEMENT_TIMEOUT_MS)
* and DEGRADES to lexical-only on a 57014 cancellation, so a pathological scan
* can never hang the interactive search request.
*
* The arm collapses a page's chunks to its best (MIN) cosine distance:
* SELECT pages.id, NULL fts_score, NULL sub_tier,
* MIN(pe.embedding <=> $qvec) AS vec_distance
* FROM page_embeddings pe JOIN pages ON pages.id = pe.page_id
* WHERE <scope> AND pe.model_dimensions = $dim AND pe.fingerprint = $fp
* GROUP BY pages.id ORDER BY vec_distance LIMIT $limit
*
* `scope` is SearchService's shared scope predicate (workspace + space/id set +
* creator + descendants + deleted_at), referencing the `pages` table it must
* be spliced verbatim so the vector candidate set mirrors the lexical scope
* EXACTLY, minus the lexical text predicate (vector candidates need not match
* text). The vector is bound via pgvector's `toSql(...)::vector`, and both the
* dimension and the ACTIVE fingerprint are filtered so `<=>` only ever compares
* compatible, same-generation vectors (pgvector errors on a dimension
* mismatch; a fingerprint mismatch would fuse incomparable vectors).
*
* `workspaceId` is ALSO filtered directly on `page_embeddings` not only via
* the pages join. It is IMMUTABLE (there are no cross-workspace page moves), so
* an embedding row's workspace_id always matches its page's, and this drops NO
* legitimate hit; it lets the composite index idx_page_embeddings_ws_space_fp_dim
* (workspace_id, space_id, fingerprint, model_dimensions) bite from its LEADING
* column, and workspace-scopes candidates at the embedding level (defense in
* depth).
*
* SPACE is deliberately NOT filtered on page_embeddings: `page_embeddings.space_id`
* is stamped at index time and is NOT updated when a page is MOVED between spaces
* (movePageToSpace does not reindex, and PAGE_MOVED_TO_SPACE has no reindex
* consumer), so a moved page's rows carry the OLD space until the next reindex. A
* `page_embeddings.space_id = ANY(scope)` predicate would then wrongly drop a
* legitimate vector hit for a page moved INTO the searched space. Space scoping
* is therefore enforced ONLY by the join to `pages` (pages.space_id scope,
* always current) the same way the lexical arm scopes, so no space leak.
*
* The NULL fts_score / sub_tier columns keep the arm UNION-compatible with the
* lexical arm's column list.
*/
vectorCandidateArm(params: {
queryEmbedding: number[];
dimensions: number;
fingerprint: string;
scope: RawBuilder<unknown>;
limit: number;
workspaceId: string;
}): RawBuilder<unknown> {
const qvec = sql`${pgvector.toSql(params.queryEmbedding)}::vector`;
return sql`
SELECT pages.id AS id,
NULL::float AS fts_score,
NULL::int AS sub_tier,
MIN(page_embeddings.embedding <=> ${qvec}) AS vec_distance
FROM page_embeddings
JOIN pages ON pages.id = page_embeddings.page_id
WHERE ${params.scope}
AND page_embeddings.workspace_id = ${params.workspaceId}
AND page_embeddings.model_dimensions = ${params.dimensions}
AND page_embeddings.fingerprint = ${params.fingerprint}
GROUP BY pages.id
ORDER BY vec_distance ASC
LIMIT ${params.limit}
`;
}
/**
* Cosine search over the embeddings, scoped to a workspace AND a set of
* spaces the caller may read (see semanticSearch access-scoping). Orders by
@@ -7,6 +7,11 @@ export interface PageEmbeddings {
spaceId: string;
modelName: string;
modelDimensions: number;
// #530 PR-1: the active embedding fingerprint (model id + revision + prefix
// scheme + dimensions). NULL on legacy rows written before this column existed.
// Search filters candidates by the ACTIVE fingerprint so a revision/prefix
// change never mixes incompatible vectors.
fingerprint: string | null;
workspaceId: string;
// Nullable: page-body embeddings have no attachment (only attachment chunks set it).
attachmentId: string | null;
+17
View File
@@ -108,6 +108,23 @@ export function isUniqueViolation(err: unknown): boolean {
return (err as { code?: unknown } | null | undefined)?.code === PG_UNIQUE_VIOLATION;
}
/** Postgres `query_canceled` SQLSTATE — raised when statement_timeout fires. */
const PG_QUERY_CANCELED = '57014';
/**
* Whether `err` is a Postgres statement-timeout cancellation (SQLSTATE `57014`).
* Used by #530 semantic search to degrade a slow vector scan to lexical-only
* instead of hanging/500-ing. Matches on the SQLSTATE (the driver surfaces it as
* `.code`), with a message fallback for any wrapper that drops the code.
*/
export function isStatementTimeout(err: unknown): boolean {
if ((err as { code?: unknown } | null | undefined)?.code === PG_QUERY_CANCELED) {
return true;
}
const msg = err instanceof Error ? err.message : '';
return /canceling statement due to statement timeout/i.test(msg);
}
/**
* The name of the UNIQUE index/constraint a `23505` error violated, or
* undefined. The `kysely-postgres-js` / `postgres@3.x` driver surfaces it as
@@ -0,0 +1,143 @@
import { AiService, computeEmbeddingFingerprint } from './ai.service';
/**
* #530 Search Phase B (PR-1) unit coverage for the embedding layer:
* - computeEmbeddingFingerprint: a revision bump / prefix toggle MUST change the
* fingerprint even when the bare model name + dimension are unchanged (it is
* deliberately NOT the bare model name);
* - embedQuery: applies the QUERY prefix and embeds under the short SEARCH embed
* timeout (not the long batch timeout);
* - embedWithModel: a hung model + short timeout degrades with a clear timeout
* error (the search caller then falls back to the lexical path).
*/
describe('computeEmbeddingFingerprint (#530)', () => {
const base = {
modelId: 'intfloat/multilingual-e5-small',
revision: 'sha-aaa',
queryPrefix: 'query: ',
docPrefix: 'passage: ',
dimensions: 384,
};
it('is deterministic for identical inputs', () => {
expect(computeEmbeddingFingerprint(base)).toBe(
computeEmbeddingFingerprint({ ...base }),
);
});
it('CHANGES on a revision bump (same model name + dimension)', () => {
const a = computeEmbeddingFingerprint(base);
const b = computeEmbeddingFingerprint({ ...base, revision: 'sha-bbb' });
expect(b).not.toBe(a);
});
it('CHANGES on a query-prefix toggle (same model name + dimension + revision)', () => {
const a = computeEmbeddingFingerprint(base);
const b = computeEmbeddingFingerprint({ ...base, queryPrefix: '' });
expect(b).not.toBe(a);
});
it('CHANGES on a doc-prefix toggle', () => {
const a = computeEmbeddingFingerprint(base);
const b = computeEmbeddingFingerprint({ ...base, docPrefix: '' });
expect(b).not.toBe(a);
});
it('CHANGES on a dimension change', () => {
const a = computeEmbeddingFingerprint(base);
const b = computeEmbeddingFingerprint({ ...base, dimensions: 768 });
expect(b).not.toBe(a);
});
it('is NOT the bare model name (guards the mutation)', () => {
// If the fingerprint were the bare model name, a revision bump would not
// change it — the tests above would red. Assert it directly too.
expect(computeEmbeddingFingerprint(base)).not.toBe(base.modelId);
});
it('distinguishes swapped prefixes — ("q","") != ("","q")', () => {
const a = computeEmbeddingFingerprint({
...base,
queryPrefix: 'q',
docPrefix: '',
});
const b = computeEmbeddingFingerprint({
...base,
queryPrefix: '',
docPrefix: 'q',
});
expect(a).not.toBe(b);
});
});
describe('AiService.embedQuery (#530)', () => {
function makeService(): AiService {
// The constructor body only stores its deps; embedQuery is fully driven by
// the spied resolveEmbeddingProvider + embedWithModel below.
return new AiService({} as any, {} as any, {} as any);
}
it('prepends the query prefix and returns the provider fingerprint', async () => {
const svc = makeService();
jest.spyOn(svc, 'resolveEmbeddingProvider').mockResolvedValue({
model: { modelId: 'm' } as any,
queryPrefix: 'query: ',
docPrefix: 'passage: ',
fingerprint: 'fp-active',
});
const embedSpy = jest
.spyOn(svc, 'embedWithModel')
.mockResolvedValue([[0.1, 0.2, 0.3]]);
const res = await svc.embedQuery('ws-1', 'кофе');
// The single embedded value carries the query prefix.
expect(embedSpy).toHaveBeenCalledTimes(1);
const [, wsArg, valuesArg, timeoutArg] = embedSpy.mock.calls[0];
expect(wsArg).toBe('ws-1');
expect(valuesArg).toEqual(['query: кофе']);
// Bound by the SHORT search-embed timeout (default 800ms), not the 120000ms
// batch timeout.
expect(timeoutArg).toBe(800);
expect(res).toEqual({ vector: [0.1, 0.2, 0.3], fingerprint: 'fp-active' });
});
it('propagates a no-provider error (drives the no-provider degrade)', async () => {
const svc = makeService();
const { AiEmbeddingNotConfiguredException } = await import(
'./ai-embedding-not-configured.exception'
);
jest
.spyOn(svc, 'resolveEmbeddingProvider')
.mockRejectedValue(new AiEmbeddingNotConfiguredException());
await expect(svc.embedQuery('ws-1', 'x')).rejects.toBeInstanceOf(
AiEmbeddingNotConfiguredException,
);
});
});
describe('AiService.embedWithModel timeout (#530)', () => {
it('degrades a hung model with a timeout error under the short window', async () => {
const svc = new AiService({} as any, {} as any, {} as any);
// A model whose doEmbed never resolves but honours the abort signal, so the
// AbortSignal.timeout fires and embedWithModel raises a clear timeout error.
const hangingModel: any = {
specificationVersion: 'v2',
provider: 'fake',
modelId: 'fake',
maxEmbeddingsPerCall: 100,
supportsParallelCalls: true,
doEmbed: ({ abortSignal }: { abortSignal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
abortSignal?.addEventListener('abort', () => {
const err = new Error('The operation was aborted');
err.name = 'AbortError';
reject(err);
});
}),
};
await expect(
svc.embedWithModel(hangingModel, 'ws-1', ['x'], 30),
).rejects.toThrow(/timed out/i);
});
});
+162 -7
View File
@@ -23,6 +23,50 @@ import {
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { SecretBoxService } from '../crypto/secret-box';
import { AiDriver } from './ai.types';
import { createHash } from 'node:crypto';
/**
* A resolved embedding provider for #530 semantic search. `model` is the AI SDK
* embedding model; `queryPrefix`/`docPrefix` are prepended to a query / a stored
* chunk respectively (e5-style `"query: "` / `"passage: "`, empty for a non-e5
* provider); `fingerprint` is the deterministic id of the whole configuration.
*/
export interface ResolvedEmbeddingProvider {
model: EmbeddingModel;
queryPrefix: string;
docPrefix: string;
fingerprint: string;
}
/**
* Deterministic embedding FINGERPRINT (#530). Encodes model id + revision +
* prefix scheme + dimensions so that ANY of them changing (a revision bump, a
* prefix toggle) yields a DIFFERENT fingerprint even when the bare model name and
* dimension are unchanged. Deliberately NOT the bare model name: two rows from
* the same model but a different revision/prefix must not be fused together.
*
* Exported as a pure function so it can be unit-tested in isolation and reused by
* the indexer without a service instance.
*/
export function computeEmbeddingFingerprint(parts: {
modelId: string;
revision: string;
queryPrefix: string;
docPrefix: string;
dimensions: number | null;
}): string {
// The two prefixes are SEPARATE keys (not a concatenated string): JSON.stringify
// escapes each independently, so the prefix scheme ("a","") is distinct from
// ("","a") with no separator/collision hazard even if a prefix contains spaces.
const canonical = JSON.stringify({
m: parts.modelId,
r: parts.revision,
q: parts.queryPrefix,
d: parts.docPrefix,
dim: parts.dimensions ?? 0,
});
return createHash('sha256').update(canonical).digest('hex').slice(0, 32);
}
/**
* Optional chat-model override carried by an agent role (`ai_agent_roles.
@@ -362,11 +406,112 @@ export class AiService {
async embedTexts(workspaceId: string, texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
const model = await this.getEmbeddingModel(workspaceId);
// Bound the embedding call: a slow/hung embeddings endpoint must fail loudly
// (and let the caller move on to the next page) instead of blocking forever.
// The single signal caps the WHOLE call, including the SDK's internal
// retries/backoff (embedMany defaults to maxRetries: 2).
const timeoutMs = AiService.embeddingTimeoutMs();
return this.embedWithModel(model, workspaceId, texts);
}
/**
* #530: resolve the embedding provider for a workspace. Prefers the workspace's
* own configured embedding provider; falls back to the GLOBAL env provider (a
* TEI sidecar via the OpenAI-compatible path) when the workspace has none.
* Returns the model + the query/doc prefixes + the config fingerprint. Throws
* AiEmbeddingNotConfiguredException when NEITHER resolves, so callers can drive
* a `no-provider` degrade path.
*/
async resolveEmbeddingProvider(
workspaceId: string,
): Promise<ResolvedEmbeddingProvider> {
// 1. Per-workspace provider (uses the workspace's own creds/endpoint). When
// it is not configured getEmbeddingModel throws the not-configured
// exception; we swallow ONLY that and fall through to the global provider.
try {
const model = await this.getEmbeddingModel(workspaceId);
const modelId =
typeof model === 'string' ? model : (model.modelId ?? 'unknown');
// A per-workspace (typically non-e5) provider gets no e5-style prefixes.
return {
model,
queryPrefix: '',
docPrefix: '',
fingerprint: computeEmbeddingFingerprint({
modelId,
revision: 'workspace',
queryPrefix: '',
docPrefix: '',
dimensions: null,
}),
};
} catch (err) {
if (!(err instanceof AiEmbeddingNotConfiguredException)) throw err;
}
// 2. Global env provider (TEI sidecar). TEI is OpenAI-compatible, so reuse
// the existing openai path — no new SDK. A dummy key is fine for a
// keyless self-hosted sidecar.
const endpoint = process.env.EMBEDDING_ENDPOINT?.trim();
const globalModel = process.env.EMBEDDING_MODEL?.trim();
if (endpoint && globalModel) {
const model = createOpenAI({
baseURL: endpoint,
apiKey: process.env.EMBEDDING_API_KEY || 'unused',
}).textEmbeddingModel(globalModel);
const queryPrefix = process.env.EMBEDDING_QUERY_PREFIX ?? '';
const docPrefix = process.env.EMBEDDING_DOC_PREFIX ?? '';
const dimRaw = Number(process.env.EMBEDDING_DIMENSIONS);
const dimensions = Number.isFinite(dimRaw) && dimRaw > 0 ? dimRaw : null;
return {
model,
queryPrefix,
docPrefix,
fingerprint: computeEmbeddingFingerprint({
modelId: globalModel,
revision: process.env.EMBEDDING_REVISION ?? '',
queryPrefix,
docPrefix,
dimensions,
}),
};
}
// Neither resolved -> drives semantic.reason=no-provider.
throw new AiEmbeddingNotConfiguredException();
}
/**
* #530: embed a SEARCH QUERY. Resolves the provider, prepends its query prefix,
* and embeds the single value under its OWN short timeout
* (SEARCH_EMBED_TIMEOUT_MS, default 800ms) NOT the long batch-indexing
* timeout so a slow/hung sidecar degrades search fast. Throws on
* timeout/error (the caller degrades to the lexical-only path). Returns the
* vector plus the active fingerprint used to filter candidate rows.
*/
async embedQuery(
workspaceId: string,
text: string,
): Promise<{ vector: number[]; fingerprint: string }> {
const provider = await this.resolveEmbeddingProvider(workspaceId);
const [vector] = await this.embedWithModel(
provider.model,
workspaceId,
[provider.queryPrefix + text],
AiService.searchEmbedTimeoutMs(),
);
return { vector, fingerprint: provider.fingerprint };
}
/**
* Embed values with an EXPLICIT model, bounded by `timeoutMs` (default: the
* batch-indexing timeout). Shared core of embedTexts / embedQuery: a slow/hung
* embeddings endpoint must fail loudly instead of blocking forever. The single
* signal caps the WHOLE call, including the SDK's internal retries/backoff
* (embedMany defaults to maxRetries: 2).
*/
async embedWithModel(
model: EmbeddingModel,
workspaceId: string,
texts: string[],
timeoutMs: number = AiService.embeddingTimeoutMs(),
): Promise<number[][]> {
if (texts.length === 0) return [];
const signal = AbortSignal.timeout(timeoutMs);
try {
const { embeddings } = await embedMany({
@@ -391,8 +536,8 @@ export class AiService {
if (signal.aborted && abortLike) {
throw new Error(
`Embedding request timed out after ${timeoutMs}ms ` +
`(workspace ${workspaceId}, ${texts.length} chunk(s)). ` +
`Increase AI_EMBEDDING_TIMEOUT_MS or check the embeddings endpoint.`,
`(workspace ${workspaceId}, ${texts.length} value(s)). ` +
`Increase the embedding timeout or check the embeddings endpoint.`,
);
}
throw err;
@@ -408,6 +553,16 @@ export class AiService {
return Number.isFinite(raw) && raw > 0 ? raw : 120_000;
}
/**
* #530: per-call timeout for the interactive SEARCH query embed. Much shorter
* than the batch-indexing timeout (a search request cannot wait 2 minutes on
* the sidecar). Configurable via SEARCH_EMBED_TIMEOUT_MS; default 800ms.
*/
private static searchEmbedTimeoutMs(): number {
const raw = Number(process.env.SEARCH_EMBED_TIMEOUT_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 800;
}
// Build a tiny valid WAV (mono, 16-bit PCM, 16 kHz, ~1s of silence), used only
// as a connectivity probe for the STT endpoint in testConnection.
private static silentWavProbe(): Uint8Array {
@@ -1,7 +1,10 @@
import { randomUUID } from 'node:crypto';
import { Kysely, sql } from 'kysely';
import { Logger } from '@nestjs/common';
import { SearchService } from 'src/core/search/search.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
import { AiEmbeddingNotConfiguredException } from 'src/integrations/ai/ai-embedding-not-configured.exception';
import {
getTestDb,
destroyTestDb,
@@ -53,10 +56,19 @@ describe('SearchService #529 lexical overhaul [integration]', () => {
// Service wired to the real DB + real PageRepo (recursive descendants) with
// stubbed space-membership + permission repos so a test controls scope and the
// permission filter explicitly. `accessibleIds` (when set) is the KEEP list.
// #530: a FAKE AiService whose embedQuery is fully controlled per test. By
// DEFAULT it throws AiEmbeddingNotConfiguredException, so the semantic arm is
// omitted and every Phase-A case runs the BYTE-IDENTICAL lexical path — the
// semantic layer must never change lexical behaviour on degrade. Semantic cases
// pass `embedVector` (a deterministic 384-dim query vector) or `embedThrows`.
function buildService(opts?: {
userSpaceIds?: string[];
accessibleIds?: string[] | null;
filterThrows?: boolean;
embedVector?: number[];
embedFingerprint?: string;
embedThrows?: 'no-provider' | 'degraded';
slowVectorArm?: boolean;
}): SearchService {
const pageRepo = new PageRepo(db as any, null as any, null as any);
const spaceMemberRepo = {
@@ -71,15 +83,86 @@ describe('SearchService #529 lexical overhaul [integration]', () => {
: pageIds;
},
};
const aiService = {
embedQuery: async () => {
if (opts?.embedVector) {
return {
vector: opts.embedVector,
fingerprint: opts.embedFingerprint ?? ACTIVE_FP,
};
}
if (opts?.embedThrows === 'degraded') {
const err = new Error('embedding request timed out after 1ms');
err.name = 'TimeoutError';
throw err;
}
// Default (and explicit 'no-provider'): no embedding provider resolved.
throw new AiEmbeddingNotConfiguredException();
},
};
// Real repo on the migrated test DB — exercises the actual vector-candidate
// SQL (pgvector <=>, fingerprint + dimension filters). When slowVectorArm is
// set, wrap the arm so it sleeps well past the (tiny, test-set) statement
// timeout, forcing a 57014 cancellation to exercise the degrade-to-lexical net.
const realRepo = new PageEmbeddingRepo(db as any);
const pageEmbeddingRepo = opts?.slowVectorArm
? {
vectorCandidateArm: (p: any) => {
const inner = realRepo.vectorCandidateArm(p);
return sql`SELECT * FROM (${inner}) AS slowarm WHERE (SELECT true FROM pg_sleep(0.5))`;
},
}
: realRepo;
return new SearchService(
db as any,
pageRepo as any,
{} as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
aiService as any,
pageEmbeddingRepo as any,
);
}
// Active embedding fingerprint used by planted rows + the fake query embed.
const ACTIVE_FP = 'fp-active-530';
// A deterministic unit-ish 384-dim vector with a single 1 at `concept`. Two
// vectors of the same concept have cosine distance 0; different concepts are
// orthogonal (distance 1), so nearest-neighbour ordering is fully controlled.
function conceptVec(concept: number): number[] {
const v = new Array(384).fill(0);
v[concept % 384] = 1;
return v;
}
// Plant one chunk embedding for a page via the REAL repo (exercises the #530
// fingerprint insert path). Dimension is fixed at 384 to match conceptVec.
async function plantEmbedding(
pageId: string,
vector: number[],
fingerprint: string = ACTIVE_FP,
planSpaceId: string = spaceId,
): Promise<void> {
const repo = new PageEmbeddingRepo(db as any);
await repo.insertChunks([
{
pageId,
workspaceId,
spaceId: planSpaceId,
attachmentId: null,
chunkIndex: 0,
chunkStart: 0,
chunkLength: 1,
content: 'planted chunk',
modelName: 'test-model',
modelDimensions: 384,
fingerprint,
embedding: vector,
},
]);
}
const search = (service: SearchService, params: any) =>
service.searchPage(params, { userId: 'u-1', workspaceId }) as any;
@@ -609,4 +692,324 @@ describe('SearchService #529 lexical overhaul [integration]', () => {
expect(hit2).toBeDefined();
expect(hit2.path).toEqual([]);
});
// === #530 Phase B (PR-1): semantic fusion ==================================
// Deterministic fake embedder (no live model) + real PageEmbeddingRepo on the
// migrated DB with planted vectors + the active fingerprint, so cosine ordering
// is fully controlled. Each case runs in its OWN space so planted rows never
// leak across tests.
describe('#530 semantic fusion', () => {
// 1. Vector-only hit: nearest to a page that has NO lexical match for the
// query term — it appears via the vector arm, and a pure-lexical run does
// not return it. Mutation (a): drop the vector arm -> this reddens.
it('#530-1 a vector-only hit appears; a pure-lexical run would not return it', async () => {
const s = (await createSpace(db, workspaceId)).id;
const page = await insertPage({
title: 'нейтральный вект заголовок',
textContent: 'содержимое без искомого термина',
spaceId: s,
});
await plantEmbedding(page, conceptVec(1), ACTIVE_FP, s);
const svc = buildService({ embedVector: conceptVec(1), userSpaceIds: [s] });
const res = await search(svc, { query: 'квазонимикс', spaceId: s });
expect(res.items.map((i: any) => i.id)).toContain(page);
expect(res.semantic.available).toBe(true);
expect(res.semantic.state).toBe('full');
// Pure-lexical (default degrade): the vector-only page is absent.
const lex = buildService({ userSpaceIds: [s] });
const res2 = await search(lex, { query: 'квазонимикс', spaceId: s });
expect(res2.items.map((i: any) => i.id)).not.toContain(page);
expect(res2.semantic.available).toBe(false);
});
// 2. Sidecar down: embedQuery throws -> results = the lexical set; semantic
// unavailable; the fixed 'search.semantic.degraded' event is logged.
it('#530-2 sidecar-down degrades to lexical and logs search.semantic.degraded', async () => {
const s = (await createSpace(db, workspaceId)).id;
const lexHit = await insertPage({
title: 'семдвамаркер лексический',
textContent: 'обычный текст',
spaceId: s,
});
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as any);
try {
const svc = buildService({ embedThrows: 'degraded', userSpaceIds: [s] });
const res = await search(svc, { query: 'семдвамаркер', spaceId: s });
expect(res.items.map((i: any) => i.id)).toContain(lexHit);
expect(res.semantic.available).toBe(false);
expect(res.semantic.reason).toBe('degraded');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('search.semantic.degraded'),
);
} finally {
warnSpy.mockRestore();
}
});
// 3. No provider: resolveEmbeddingProvider yields none -> lexical results,
// reason no-provider, no 500.
it('#530-3 no-provider yields lexical results with reason no-provider', async () => {
const s = (await createSpace(db, workspaceId)).id;
const hit = await insertPage({
title: 'семтримаркер тема',
textContent: 'текст',
spaceId: s,
});
const svc = buildService({ embedThrows: 'no-provider', userSpaceIds: [s] });
const res = await search(svc, { query: 'семтримаркер', spaceId: s });
expect(res.items.map((i: any) => i.id)).toContain(hit);
expect(res.semantic.available).toBe(false);
expect(res.semantic.reason).toBe('no-provider');
});
// 4. Permission over the union: a restricted vector-only hit is dropped from
// items AND total; with the permission filter throwing the call rejects
// (fail-closed, never fail-open). Mutation (b): move filterAccessiblePageIds
// inside the semantic try/catch -> the reject assertion reddens.
it('#530-4 permission filters a vector-only hit from items AND total (fail-closed)', async () => {
const s = (await createSpace(db, workspaceId)).id;
const visibleLex = await insertPage({
title: 'семчетмаркер видимый',
textContent: 'доступный текст',
spaceId: s,
});
const hiddenVec = await insertPage({
title: 'скрытая вект страница',
textContent: 'содержимое без искомого термина',
spaceId: s,
});
await plantEmbedding(hiddenVec, conceptVec(4), ACTIVE_FP, s);
// Query hits visibleLex lexically; hiddenVec only via the vector arm.
const svc = buildService({
embedVector: conceptVec(4),
accessibleIds: [visibleLex],
userSpaceIds: [s],
});
const res = await search(svc, { query: 'семчетмаркер', spaceId: s });
const ids = res.items.map((i: any) => i.id);
expect(ids).toContain(visibleLex);
expect(ids).not.toContain(hiddenVec);
// The hidden vector hit does not leak into total either.
expect(res.total).toBe(1);
expect(ids).toHaveLength(res.total);
// A permission-query error PROPAGATES (never a fail-open empty result).
const boom = buildService({
embedVector: conceptVec(4),
filterThrows: true,
userSpaceIds: [s],
});
await expect(
search(boom, { query: 'семчетмаркер', spaceId: s }),
).rejects.toThrow(/permission query failed/);
});
// 5. Hung sidecar under a short embed timeout: graceful degrade (never a 500).
it('#530-5 a hung sidecar under a short timeout degrades gracefully', async () => {
const s = (await createSpace(db, workspaceId)).id;
const hit = await insertPage({
title: 'семпятьмаркер тема',
textContent: 'текст',
spaceId: s,
});
process.env.SEARCH_EMBED_TIMEOUT_MS = '1';
try {
const svc = buildService({ embedThrows: 'degraded', userSpaceIds: [s] });
const res = await search(svc, { query: 'семпятьмаркер', spaceId: s });
expect(res.items.map((i: any) => i.id)).toContain(hit);
expect(res.semantic.available).toBe(false);
expect(res.semantic.reason).toBe('degraded');
} finally {
delete process.env.SEARCH_EMBED_TIMEOUT_MS;
}
});
// 6. A page matched BOTH lexically and by vector is fused (de-duped), not
// returned twice — the agg CTE collapses it to one row.
it('#530-6 a page matched lexically AND by vector appears once', async () => {
const s = (await createSpace(db, workspaceId)).id;
const both = await insertPage({
title: 'семшестьмаркер общий',
textContent: 'и лексика и вектор',
spaceId: s,
});
await plantEmbedding(both, conceptVec(6), ACTIVE_FP, s);
const svc = buildService({ embedVector: conceptVec(6), userSpaceIds: [s] });
const res = await search(svc, { query: 'семшестьмаркер', spaceId: s });
const ids = res.items.map((i: any) => i.id);
expect(ids.filter((id: string) => id === both)).toHaveLength(1);
expect(res.total).toBe(1);
});
// 7. Fingerprint isolation: a planted row under a DIFFERENT fingerprint is not
// a vector candidate for the active-fingerprint query (guards mixing
// generations). It only appears if it also matches lexically (it does not).
it('#530-7 a stale-fingerprint vector row is not fused into results', async () => {
const s = (await createSpace(db, workspaceId)).id;
const stale = await insertPage({
title: 'нейтральный семь заголовок',
textContent: 'без искомого термина совсем',
spaceId: s,
});
// Same concept vector as the query, but an OLD generation fingerprint.
await plantEmbedding(stale, conceptVec(7), 'fp-stale-old', s);
const svc = buildService({ embedVector: conceptVec(7), userSpaceIds: [s] });
const res = await search(svc, { query: 'квазонимиксseven', spaceId: s });
expect(res.items.map((i: any) => i.id)).not.toContain(stale);
});
// 8. Stale embedding space_id (moved page): a page currently in space B whose
// embedding rows still carry space A (movePageToSpace does NOT reindex) is
// STILL a vector hit when searching B — space scoping is enforced only by
// the pages join (pages.space_id, always current), NOT by a stale
// page_embeddings.space_id. Guards against re-adding that predicate (which
// would wrongly drop the moved page's vector-only hit).
it('#530-8 a vector hit with a STALE embedding space_id (moved page) is still returned', async () => {
const newSpace = (await createSpace(db, workspaceId)).id;
const oldSpace = (await createSpace(db, workspaceId)).id;
// The page currently lives in newSpace (as after a move into newSpace).
const page = await insertPage({
title: 'перемещённая вект страница',
textContent: 'содержимое без искомого термина',
spaceId: newSpace,
});
// Its embedding was stamped with the OLD space and never reindexed.
await plantEmbedding(page, conceptVec(8), ACTIVE_FP, oldSpace);
const svc = buildService({
embedVector: conceptVec(8),
userSpaceIds: [newSpace],
});
const res = await search(svc, {
query: 'квазонимиксeight',
spaceId: newSpace,
});
// Survives despite page_embeddings.space_id (oldSpace) != pages.space_id.
expect(res.items.map((i: any) => i.id)).toContain(page);
expect(res.semantic.available).toBe(true);
});
// 9. Statement-timeout safety net: a pathologically slow vector scan is
// cancelled (SQLSTATE 57014) and search degrades to lexical-only — the
// lexical hit survives, the vector-only hit vanishes, no 500/hang, and the
// degraded event is logged. Mutation: make the fallback re-throw instead of
// degrading -> this test reds (the call rejects with 57014).
it('#530-9 a vector-query timeout degrades to lexical (57014), never 500', async () => {
const s = (await createSpace(db, workspaceId)).id;
const lexHit = await insertPage({
title: 'семдевятьмаркер лексический',
textContent: 'обычный текст',
spaceId: s,
});
const vecOnly = await insertPage({
title: 'нейтральный девять заголовок',
textContent: 'без искомого термина',
spaceId: s,
});
await plantEmbedding(vecOnly, conceptVec(9), ACTIVE_FP, s);
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as any);
// Tiny per-statement timeout so the 0.5s sleeping arm is cancelled.
process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS = '50';
try {
const svc = buildService({
embedVector: conceptVec(9),
slowVectorArm: true,
userSpaceIds: [s],
});
const res = await search(svc, { query: 'семдевятьмаркер', spaceId: s });
// Lexical result survives; the vector-only page does NOT (arm cancelled).
const ids = res.items.map((i: any) => i.id);
expect(ids).toContain(lexHit);
expect(ids).not.toContain(vecOnly);
expect(res.semantic.available).toBe(false);
expect(res.semantic.reason).toBe('degraded');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('search.semantic.degraded'),
);
} finally {
delete process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS;
warnSpy.mockRestore();
}
});
// 10. SET LOCAL scoping: the search's statement timeout must NOT leak onto a
// later query on the same pooled connection. After a search that set a
// tiny timeout, a deliberately-slow standalone query still completes.
it('#530-10 the per-query statement timeout does not leak to later queries', async () => {
const s = (await createSpace(db, workspaceId)).id;
await insertPage({
title: 'семдесятьмаркер тема',
textContent: 'текст',
spaceId: s,
});
process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS = '50';
try {
const svc = buildService({
embedVector: conceptVec(10),
slowVectorArm: true,
userSpaceIds: [s],
});
// This search trips + resets the local timeout (via SET LOCAL semantics).
await search(svc, { query: 'семдесятьмаркер', spaceId: s });
// A subsequent query that sleeps 200ms must NOT be cancelled — proving the
// 50ms search timeout did not persist on the connection (it would raise
// 57014 if it had leaked). SET LOCAL auto-resets at the search tx end.
await expect(sql`SELECT pg_sleep(0.2)`.execute(db)).resolves.toBeDefined();
} finally {
delete process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS;
}
});
// 11. COMMIT-path no-leak (locks is_local=true). #530-10 only covers the
// ROLLBACK path (a timed-out search rolls back — a plain session SET would
// ALSO be undone on rollback, so it would NOT catch is_local true->false).
// Here a NORMAL, FAST semantic search COMMITs its transaction; a leaked
// session-level statement_timeout would then persist on the pooled
// connection and cancel later queries. We prove it does NOT: after the
// committing search (bounded at a small 100ms), a 300ms query succeeds.
// Because postgres.js pools, we run several sequential slow queries so at
// least one reuses the connection the committed search ran on (which is
// where a leaked timeout would live); ALL must succeed. Mutation: flip
// is_local true->false -> the committed 100ms timeout leaks and the
// follow-up pg_sleep is cancelled (57014), reddening this test.
it('#530-11 a COMMITTED search does not leak its statement timeout (is_local)', async () => {
const s = (await createSpace(db, workspaceId)).id;
const page = await insertPage({
title: 'семодиннадцать вект страница',
textContent: 'содержимое без искомого термина',
spaceId: s,
});
await plantEmbedding(page, conceptVec(11), ACTIVE_FP, s);
// Small bound: the fast vector arm still completes (<100ms) so the search
// COMMITs, but small enough that a LEAKED timeout would cancel the 300ms
// follow-ups below.
process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS = '100';
try {
const svc = buildService({
embedVector: conceptVec(11),
userSpaceIds: [s],
});
const res = await search(svc, { query: 'семодиннадцать', spaceId: s });
// The (committing) vector path actually ran.
expect(res.semantic.available).toBe(true);
// Each 300ms query must SUCCEED — a leaked 100ms timeout would cancel the
// one that reuses the committed search's connection. 8 sweeps cover the
// whole pool (max 5 conns), so a leak on any connection is caught.
for (let i = 0; i < 8; i++) {
await expect(
sql`SELECT pg_sleep(0.3)`.execute(db),
).resolves.toBeDefined();
}
} finally {
delete process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS;
}
});
});
});
+41
View File
@@ -4,11 +4,26 @@ services:
depends_on:
- db
- redis
- embeddings
environment:
APP_URL: 'http://localhost:3000'
APP_SECRET: 'REPLACE_WITH_LONG_SECRET'
DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost'
REDIS_URL: 'redis://redis:6379'
# #530 semantic search: the GLOBAL embedding provider (the `embeddings` TEI
# sidecar below). A workspace that configures its own embedding provider
# overrides this. TEI is OpenAI-compatible, so the endpoint is /v1.
EMBEDDING_ENDPOINT: 'http://embeddings:80/v1'
EMBEDDING_MODEL: 'intfloat/multilingual-e5-small'
EMBEDDING_API_KEY: 'unused'
EMBEDDING_DIMENSIONS: '384'
# MUST match the --revision the sidecar pins (see EMBEDDING_REVISION in
# .env). A revision bump changes the embedding fingerprint (PR-2 swaps
# generations); keep the two in lockstep.
EMBEDDING_REVISION: '${EMBEDDING_REVISION}'
# e5 models require these input prefixes; empty them for a non-e5 model.
EMBEDDING_QUERY_PREFIX: 'query: '
EMBEDDING_DOC_PREFIX: 'passage: '
ports:
- "3000:3000"
restart: unless-stopped
@@ -39,7 +54,33 @@ services:
volumes:
- redis_data:/data
# #530 Text Embeddings Inference (TEI) sidecar — the GLOBAL embedding provider
# for semantic search. OpenAI-compatible, reached only on the internal network
# (no published port). The model + revision are PINNED so the embedding
# fingerprint is stable; set EMBEDDING_REVISION to a real commit sha in .env.
embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9
command:
- "--model-id"
- "intfloat/multilingual-e5-small"
- "--revision"
- "${EMBEDDING_REVISION}"
restart: unless-stopped
# Cache downloaded model weights so a restart does not re-download them.
volumes:
- tei-models:/data
# GET /health is TEI's readiness probe. (Drop this block if your TEI image
# variant ships without curl.)
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:80/health || exit 1"]
interval: 30s
timeout: 5s
retries: 5
# Model download on first boot can be slow; don't flap as unhealthy meanwhile.
start_period: 120s
volumes:
docmost:
db_data:
redis_data:
tei-models:
+20
View File
@@ -11,6 +11,7 @@ import {
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
savePageVersionRealtime,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
@@ -55,6 +56,7 @@ export interface IPagesMixin {
listPageHistory(pageId: string, cursor?: string): any;
getPageHistory(historyId: string): any;
restorePageVersion(historyId: string): any;
savePageVersion(pageId: string): any;
diffPageVersions(pageId: string, from?: string, to?: string): any;
}
@@ -587,6 +589,24 @@ export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Bas
};
}
/**
* Save an intentional NAMED version of a page's CURRENT live content (#370).
* The write goes over the same agent-authenticated collab session content edits
* use, so the server derives kind='agent' from the signed actor. Resolves a
* SaveVersionResult: `{ saved:true, historyId, kind, alreadySaved }` on success,
* or `{ saved:false, skipped:true, reason }` when the server had nothing to pin
* (e.g. an empty page). A stale/missing pageId throws (not a benign skip).
*/
async savePageVersion(pageId: string) {
const collabToken = await this.getCollabTokenWithReauth();
const pageUuid = await this.resolvePageId(pageId);
// Self-heal a rejected WS handshake once (#486): re-mint the collab token and
// retry, symmetric to the content-write path.
return this.writeWithCollabAuthRetry(collabToken, (token) =>
savePageVersionRealtime(pageUuid, token, this.apiUrl),
);
}
/**
* Diff two versions of a page and return a Docmost-equivalent change set.
* `from`/`to` each resolve to a ProseMirror doc:
+125
View File
@@ -139,7 +139,13 @@ export interface CollabProviderLike {
unsyncedChanges: number;
destroy(): void;
on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
on(event: "stateless", handler: (data: { payload: string }) => void): void;
off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
off(event: "stateless", handler: (data: { payload: string }) => void): void;
// Send a stateless (out-of-band, non-doc) message to the server. Used by the
// #370 explicit save-version path (payload `{type:'save-version'}`); the server
// replies with a broadcast `{type:'version.saved', …}` on the same channel.
sendStateless(payload: string): void;
}
/** The configuration object passed to the provider factory. */
@@ -564,6 +570,125 @@ export class CollabSession {
});
}
/**
* Send a stateless message over the live collaboration connection and resolve
* with the FIRST reply whose parsed payload satisfies `predicate`, rejecting on
* a bounded `timeoutMs`.
*
* Used by the #370 explicit save-version path: the client sends
* `{type:'save-version'}` and awaits the server's broadcast
* `{type:'version.saved', historyId, kind, alreadySaved}`. The stateless channel
* (not a REST read) is deliberate the server versions the LIVE in-memory ydoc,
* which the up-to-10s-stale page row would not yet reflect.
*
* `predicate` receives each incoming stateless message (already JSON-parsed) and
* returns the resolved value for a match or `undefined` to keep waiting; a
* malformed / unrelated payload is ignored. The listener is registered BEFORE
* the send so a fast reply is never missed.
*
* Lifecycle mirrors mutate(): it registers through `inflightReject` so a
* disconnect/close/auth-failure/teardown rejects THIS wait with the same
* connection-loss error, refuses to run on a non-ready session, fails fast on a
* concurrent in-flight op (the caller MUST hold the per-page lock), and re-arms
* the idle TTL (or self-destroys an ephemeral session) on completion.
*
* NOTE: the server broadcast carries no request-correlation id, so under a
* genuinely concurrent save on the SAME page (e.g. a human Cmd+S racing the
* agent) this resolves on whichever `version.saved` arrives first. The per-page
* lock serializes THIS process's saves; a cross-client race is inherent to the
* broadcast design and is benign (the result still describes a real save of this
* page's current content, and the server's save is promote-not-duplicate).
*/
sendStatelessAndAwait<T>(
payload: string,
predicate: (message: any) => T | undefined,
timeoutMs: number,
): Promise<T> {
// Belt-and-suspenders (acquire already validated): refuse to send on a
// session that is not in a live, synced, ready state.
if (
this.dead ||
this.state !== "ready" ||
this.connectionLost ||
!this.provider ||
this.provider.synced !== true
) {
return Promise.reject(
new Error("Collaboration session is not in a ready state"),
);
}
// Fail-fast on concurrent use: a second overlapping op would overwrite the
// first's inflightReject and hang it on disconnect (same guard as mutate).
if (this.inflightReject) {
return Promise.reject(
new Error(
"stateless op already in-flight; caller must serialize (hold the page lock)",
),
);
}
return new Promise<T>((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let statelessHandler: ((data: { payload: string }) => void) | undefined;
const localFinish = (err: Error | null, value?: T) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (statelessHandler && this.provider) {
try {
this.provider.off("stateless", statelessHandler);
} catch (e) {}
}
this.inflightReject = undefined;
if (err) reject(err);
else resolve(value as T);
// Post-settle lifecycle, identical to mutate's localFinish.
if (this.ephemeral) {
this.destroy("ephemeral op complete");
} else if (!this.dead) {
this.armIdle();
}
};
// Register so a disconnect/close/auth-failure/teardown rejects THIS op with
// the connection-loss error text (the `settled` guard makes a racing
// teardown + normal resolve safe — first one wins).
this.inflightReject = (e: Error) => localFinish(e);
// Register the reply listener BEFORE sending so a fast server broadcast is
// never missed.
statelessHandler = (data: { payload: string }) => {
if (settled) return;
let message: any;
try {
message = JSON.parse(data.payload);
} catch {
return; // unrelated / malformed stateless message — keep waiting
}
const matched = predicate(message);
if (matched !== undefined) localFinish(null, matched);
};
this.provider!.on("stateless", statelessHandler);
timer = setTimeout(() => {
localFinish(
new Error(
`Timeout waiting for a stateless reply from the collaboration server ${this.hint()}`,
),
);
}, timeoutMs);
try {
this.provider!.sendStateless(payload);
} catch (e) {
localFinish(e instanceof Error ? e : new Error(String(e)));
}
});
}
/** (Re)arm the idle TTL so the clock starts from the most recent activity. */
armIdle(): void {
if (this.dead || this.ephemeral) return;
+140
View File
@@ -324,6 +324,146 @@ export async function mutatePageContent(
});
}
/**
* Stateless-channel message types for the #370 explicit save-version handshake.
* These MUST match the server constants in
* apps/server/src/collaboration/extensions/persistence.extension.ts
* (SAVE_VERSION_MESSAGE_TYPE and the VERSION_SAVED / VERSION_SKIPPED replies)
* the mcp package cannot import server code, so the literals are duplicated here.
* KEEP THE TWO SIDES IN SYNC: the server broadcasts exactly ONE terminal reply per
* handled save (`version.saved` for a real save/promote, `version.skipped` for a
* reachable no-op an empty page or a missing page row); the client must match
* BOTH or it waits out the ack timeout on the skip case and misreports a healthy
* server as unreachable (#370 F2).
*/
const SAVE_VERSION_MESSAGE_TYPE = "save-version";
const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
const VERSION_SKIPPED_MESSAGE_TYPE = "version.skipped";
/**
* Bounded wait for the server's terminal reply after we ask it to save a version.
* The server flushes the live ydoc through its store path and writes a history row
* inside a DB transaction before broadcasting, so allow the same headroom as a
* persistence ack; reject (do NOT hang) past it. A timeout here means NO reply at
* all arrived the genuine "collab server unreachable/overloaded" case as
* distinct from a `version.skipped` reply, which resolves immediately.
*/
const SAVE_VERSION_ACK_TIMEOUT_MS = 20000;
/**
* The resolved outcome of an explicit save-version, surfaced to the tool caller.
* `saved:true` a version was created or promoted (historyId/kind/alreadySaved
* are present). `saved:false` the server had nothing to pin (a reachable no-op,
* e.g. an empty page); `skipped` + `reason` explain why. A page-not-found reply is
* NOT represented here it is surfaced as a thrown error (a bad/stale pageId).
*/
export interface SaveVersionResult {
saved: boolean;
historyId?: string;
kind?: string;
alreadySaved?: boolean;
skipped?: boolean;
reason?: string;
}
/**
* Parsed terminal reply the predicate hands back to savePageVersionRealtime. Kept
* internal: it carries the page-not-found case (mapped to a thrown error, never a
* returned result) that SaveVersionResult deliberately does not model.
*/
type SaveVersionReply =
| { outcome: "saved"; historyId: string; kind: string; alreadySaved: boolean }
| { outcome: "skipped"; reason: string };
/** Match the server's terminal reply (version.saved / version.skipped), ignoring
* any unrelated / cross-page stateless message so the wait is not resolved by
* noise. Returns `undefined` to keep waiting (#370 F1 predicate coverage). */
function matchSaveVersionReply(message: any): SaveVersionReply | undefined {
if (!message || typeof message !== "object") return undefined;
if (message.type === VERSION_SAVED_MESSAGE_TYPE) {
return {
outcome: "saved",
historyId: String(message.historyId),
kind: String(message.kind),
alreadySaved: !!message.alreadySaved,
};
}
if (message.type === VERSION_SKIPPED_MESSAGE_TYPE) {
return { outcome: "skipped", reason: String(message.reason ?? "unknown") };
}
return undefined;
}
/**
* Save an intentional version of a page's CURRENT live collaboration content
* (#370). Runs under the per-page lock, acquires the SAME cached CollabSession the
* content writes use (#400) so it authenticates with the caller's agent collab
* token, and the server derives kind='agent' from that signed actor then sends a
* `{type:'save-version'}` stateless message and awaits the server's terminal reply
* (`version.saved` or `version.skipped`) on the same channel.
*
* Deliberately does NOT read `pages.content` over REST: the versioned content is
* the live in-memory ydoc, which the debounced (up-to-10s-stale) page row would
* not yet reflect. The stateless round-trip is what makes the save exact.
*
* Outcomes:
* - a real save/promote `{ saved:true, historyId, kind, alreadySaved }`;
* - the server had nothing to pin (empty page) `{ saved:false, skipped:true,
* reason:'empty' }` — a clean, immediate no-op, NOT a stall;
* - the page row is gone (a stale/bad pageId) a thrown error, immediate and
* truthful (not the health-timeout path);
* - no reply within the timeout a thrown timeout error (the genuine "server
* unreachable/overloaded" case).
*
* On a TRANSPORT failure (timeout / disconnect) the session is destroyed so the
* next call reconnects fresh; a page-not-found is a healthy-connection terminal
* reply, so the session is left cached. A save is safe to retry the server
* promotes-not-duplicates an identical latest version so the caller (and its
* agent) may re-issue it without risking a duplicate heavy history row.
*/
export async function savePageVersionRealtime(
pageId: PageId,
collabToken: string,
baseUrl: string,
): Promise<SaveVersionResult> {
return withPageLock(pageId, async () => {
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
let reply: SaveVersionReply;
try {
reply = await session.sendStatelessAndAwait<SaveVersionReply>(
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
matchSaveVersionReply,
SAVE_VERSION_ACK_TIMEOUT_MS,
);
} catch (e) {
// TRANSPORT failure (no reply within the timeout, or a disconnect): drop the
// session so the next call reconnects fresh.
session.destroy("save-version failed");
throw e;
}
// A terminal reply arrived over a healthy connection — do NOT destroy the
// session; interpret the outcome.
if (reply.outcome === "skipped") {
if (reply.reason === "page-not-found") {
// A resolved pageId that the collab server no longer holds (deleted, or a
// stale id). Surface it immediately and truthfully, not as a health error.
throw new Error(
`savePageVersion: page ${pageId} was not found on the collaboration ` +
`server (it may have been deleted) — nothing was saved.`,
);
}
// Reachable benign no-op (e.g. an empty page): a clean result, not a throw.
return { saved: false, skipped: true, reason: reply.reason };
}
return {
saved: true,
historyId: reply.historyId,
kind: reply.kind,
alreadySaved: reply.alreadySaved,
};
});
}
/**
* Replace the live content of a page over the collaboration websocket.
* Accepts a ready ProseMirror JSON document; the caller controls whether
+2 -1
View File
@@ -44,7 +44,7 @@ export const ROUTING_PROSE =
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Pin the page's CURRENT content as a restorable named checkpoint when you finish a coherent editing pass -> savePageVersion (kind derived server-side as an agent version; an identical-to-last save is promoted/no-op'd, so a redundant call is harmless). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
/**
* Non-tool camelCase identifiers that legitimately appear in ROUTING_PROSE:
@@ -218,6 +218,7 @@ const TOOL_FAMILY: Record<string, Family> = {
diffPageVersions: "HISTORY",
listPageHistory: "HISTORY",
restorePageVersion: "HISTORY",
savePageVersion: "HISTORY",
exportPageMarkdown: "HISTORY",
// importPageMarkdown is now inAppOnly (#411) — it is not registered on the
// external MCP host, so it no longer appears in the generated inventory.
+25
View File
@@ -93,6 +93,7 @@ export type DocmostClientLike = Pick<
| 'sharePage'
| 'unsharePage'
| 'restorePageVersion'
| 'savePageVersion'
| 'stashPage'
| 'insertFootnote'
| 'insertImage'
@@ -743,6 +744,30 @@ export const SHARED_TOOL_SPECS = {
client.restorePageVersion(historyId as string),
},
savePageVersion: {
mcpName: 'savePageVersion',
inAppKey: 'savePageVersion',
writeClass: 'write',
description:
'Save an intentional, NAMED version (kind=agent) of the page\'s CURRENT ' +
'live content — a restorable checkpoint pinned into its history. Call it ' +
'when you have FINISHED a coherent editing pass (not after every small ' +
'edit), so the reader can see and roll back to the state you left. The ' +
'version type is derived SERVER-SIDE from your signed agent identity (you ' +
'cannot mislabel it); a save whose content is IDENTICAL to the last saved ' +
'version is promoted/no-op\'d server-side, so a redundant call is harmless ' +
'and never duplicates a version. Returns { saved:true, historyId, kind, ' +
'alreadySaved } on success, or { saved:false, skipped:true, reason:\'empty\' } ' +
'when the page had nothing to pin (e.g. it was empty).',
tier: 'deferred',
catalogLine:
'savePageVersion — pin the page\'s current content as a named agent version (restorable checkpoint).',
buildShape: (z) => ({
pageId: z.string().min(1),
}),
execute: (client, { pageId }) => client.savePageVersion(pageId as string),
},
// --- markdown round-trip ---
importPageMarkdown: {
@@ -0,0 +1,300 @@
import { test, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
acquireCollabSession,
destroyAllSessions,
__setCollabProviderFactory,
} from "../../build/lib/collab-session.js";
import { savePageVersionRealtime } from "../../build/lib/collaboration.js";
// #370 — unit coverage for the MCP client transport of the explicit save-version.
// There is no collaboration server in the test env, so a fake HocuspocusProvider
// drives the stateless round-trip: it auto-completes the connect/sync handshake on
// a microtask (like the real provider), records sends, and either auto-broadcasts a
// configured reply (`opts.reply`) or lets the test emit stateless messages /
// disconnects by hand (`_emitStateless` / `_disconnect`) to exercise the predicate
// filter, the teardown-mid-wait path, and the timeout.
class FakeProvider extends EventEmitter {
static instances = [];
static reset() {
FakeProvider.instances = [];
}
static last() {
return FakeProvider.instances[FakeProvider.instances.length - 1];
}
constructor(config, opts = {}) {
super();
this.config = config;
this.synced = false;
this.unsyncedChanges = 0;
this.destroyed = false;
this.sent = [];
this.opts = opts;
FakeProvider.instances.push(this);
// Real HocuspocusProvider fires onSynced asynchronously after the handshake;
// a microtask reproduces that without depending on timers (so mock.timers on
// setTimeout does not stall the connect).
queueMicrotask(() => {
if (this.destroyed) return;
this.config.onConnect?.();
this.synced = true;
this.config.onSynced?.();
});
}
destroy() {
this.destroyed = true;
}
sendStateless(payload) {
this.sent.push(payload);
const reply = this.opts.reply;
if (!reply) return; // silent → the caller times out, or the test drives it
// Broadcast the configured server reply on a microtask, mirroring
// document.broadcastStateless landing back on this provider's `stateless` event.
queueMicrotask(() => {
if (this.destroyed) return;
this.emit("stateless", { payload: JSON.stringify(reply) });
});
}
// --- test drivers ---
_emitStateless(obj) {
this.emit("stateless", { payload: JSON.stringify(obj) });
}
_disconnect() {
this.config.onDisconnect?.();
}
}
const ENV_KEYS = [
"MCP_COLLAB_SESSION_IDLE_MS",
"MCP_COLLAB_SESSION_MAX_AGE_MS",
"MCP_COLLAB_SESSION_MAX_ENTRIES",
"MCP_COLLAB_TOKEN_TTL_MS",
];
let savedEnv;
beforeEach(() => {
savedEnv = {};
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
FakeProvider.reset();
});
afterEach(() => {
destroyAllSessions();
__setCollabProviderFactory(null);
mock.timers.reset();
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
const PAGE_A = "11111111-1111-4111-8111-111111111111";
const PAGE_B = "22222222-2222-4222-8222-222222222222";
const PAGE_C = "33333333-3333-4333-8333-333333333333";
const PAGE_D = "44444444-4444-4444-8444-444444444444";
const PAGE_E = "55555555-5555-4555-8555-555555555555";
const PAGE_F = "66666666-6666-4666-8666-666666666666";
// --- happy paths (resolve with the server's terminal reply) -----------------
test("saved: resolves { saved:true, historyId, kind, alreadySaved }", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: {
type: "version.saved",
historyId: "hist-42",
kind: "agent",
alreadySaved: false,
},
}),
);
const result = await savePageVersionRealtime(PAGE_A, "collab-tok", "http://host/api");
assert.deepEqual(result, {
saved: true,
historyId: "hist-42",
kind: "agent",
alreadySaved: false,
});
// The client actually asked the server to save a version (the stateless send).
assert.deepEqual(
FakeProvider.last().sent.map((p) => JSON.parse(p)),
[{ type: "save-version" }],
);
});
test("saved: surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: {
type: "version.saved",
historyId: "hist-7",
kind: "manual",
alreadySaved: true,
},
}),
);
const result = await savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api");
assert.deepEqual(result, {
saved: true,
historyId: "hist-7",
kind: "manual",
alreadySaved: true,
});
});
// --- F2: terminal SKIP replies (no timeout, no false "unreachable") ---------
test("F2 skip: an empty-page reply resolves to a clean { saved:false, skipped:true, reason:'empty' } — no timeout", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: { type: "version.skipped", reason: "empty" },
}),
);
const result = await savePageVersionRealtime(PAGE_C, "collab-tok", "http://host/api");
assert.deepEqual(result, { saved: false, skipped: true, reason: "empty" });
});
test("F2 skip: a page-not-found reply throws an immediate, truthful error (not the health-timeout path)", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: { type: "version.skipped", reason: "page-not-found" },
}),
);
await assert.rejects(
savePageVersionRealtime(PAGE_D, "collab-tok", "http://host/api"),
/was not found on the collaboration server/,
);
});
// --- F1 (a): predicate filters unrelated messages, resolves on the real reply ---
test("F1(a) unrelated-then-real: an unrelated stateless message does NOT resolve; the later version.saved does", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // manual drive
const p = savePageVersionRealtime(PAGE_E, "collab-tok", "http://host/api");
// Let acquire's handshake + the stateless send complete so the listener is armed.
await new Promise((r) => setImmediate(r));
const provider = FakeProvider.last();
// Noise first: an unrelated stateless type (e.g. another feature's broadcast).
provider._emitStateless({ type: "something-else", historyId: "NOPE" });
// The wait must still be pending — a microtask turn would have resolved it if the
// predicate wrongly matched.
let settledEarly = false;
p.then(() => (settledEarly = true)).catch(() => (settledEarly = true));
await new Promise((r) => setImmediate(r));
assert.equal(settledEarly, false, "resolved on an unrelated stateless message");
// Now the real reply arrives → it resolves with that payload.
provider._emitStateless({
type: "version.saved",
historyId: "h-real",
kind: "agent",
alreadySaved: false,
});
const result = await p;
assert.deepEqual(result, {
saved: true,
historyId: "h-real",
kind: "agent",
alreadySaved: false,
});
});
// --- F1 (b): teardown mid-wait rejects AND removes the stateless listener -----
test("F1(b) teardown mid-wait: a disconnect rejects the wait with the connection-loss error AND removes the stateless listener (no leak)", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
const session = await acquireCollabSession(PAGE_F, "collab-tok", "http://host/api");
const provider = FakeProvider.last();
const p = session.sendStatelessAndAwait(
JSON.stringify({ type: "save-version" }),
(m) => (m && m.type === "version.saved" ? m : undefined),
20000,
);
// Swallow the eventual rejection so it does not race the assertions below.
p.catch(() => {});
await new Promise((r) => setImmediate(r));
// The wait armed exactly one stateless listener.
assert.equal(
provider.listenerCount("stateless"),
1,
"the wait must register one stateless listener",
);
// A disconnect tears the session down mid-wait.
provider._disconnect();
await assert.rejects(p, /connection closed before the update was persisted/);
// The listener was removed on teardown — dropping provider.off() reds this.
assert.equal(
provider.listenerCount("stateless"),
0,
"the stateless listener leaked after teardown",
);
});
// --- F1 (c): concurrent-in-flight guard fails fast, no cross-wiring -----------
test("F1(c) concurrent guard: a second sendStatelessAndAwait while one is in flight fails fast", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
const session = await acquireCollabSession(PAGE_A, "collab-tok", "http://host/api");
const first = session.sendStatelessAndAwait(
JSON.stringify({ type: "save-version" }),
(m) => (m && m.type === "version.saved" ? m : undefined),
20000,
);
first.catch(() => {}); // it stays pending until afterEach destroys the session
await assert.rejects(
session.sendStatelessAndAwait(
JSON.stringify({ type: "save-version" }),
(m) => (m && m.type === "version.saved" ? m : undefined),
20000,
),
/already in-flight/,
);
});
// --- timeout: NO reply at all is the genuine "server unreachable" path --------
test("timeout: rejects on a bounded timeout when the server never replies", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
mock.timers.enable({ apis: ["setTimeout"] });
const p = savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api");
// Swallow the eventual rejection so an unhandled-rejection does not race the tick.
p.catch(() => {});
// Flush the (microtask-driven) connect handshake + the stateless send so the
// ack timer is armed before we advance the clock.
await new Promise((r) => setImmediate(r));
// Advance well past the 20s ack window; the connect timer (25s) does not fire.
mock.timers.tick(20000 + 100);
await assert.rejects(p, /Timeout waiting for a stateless reply/);
});