Commit Graph

1731 Commits

Author SHA1 Message Date
agent_coder ae3dfd8de6 Merge remote-tracking branch 'gitea/develop' into feat/492-incremental-render 2026-07-12 05:34:07 +03:00
agent_vscode 03eafa6c68 Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 05:16:51 +03:00
agent_vscode a42f1ead48 test(ai-chat): align #332 deferred-tool test with #490 cross-turn persistence
The #332 integration test (ai-chat-stream.int-spec.ts) asserted a deferred
tool activated in one turn does NOT leak into the next ("cold start per
turn"). #490 (f5bbfdb2) deliberately reversed that: it persists the
activation set into chat metadata.activatedTools and seeds the next turn
from it, so the model need not re-run loadTools. The test only surfaced now
because the token-estimate CI fix let the integration step run again.

Adopt #490 persistence as the intended behavior:
- flip the turn-2 assertion to expect createPage IS active on the fresh
  turn's first step (seeded from metadata); keep turn-1 cold-start assertions.
- rewrite the test docstring, describe/it titles and comments accordingly.
- fix two stale "not persisted / per-turn" comments in ai-chat.service.ts
  (prepareAgentStep + the streaming-loop activation block); no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:16:47 +03:00
agent_coder 846341d7d4 fix(drawio): encode literal tab/newline/CR in content= as numeric char-refs (#507 review)
Review follow-up to the base64→entity-XML content= switch. The whole mxfile XML
now lives in one content="..." attribute; XML attribute-value normalization
collapses a LITERAL tab/newline/CR to a single space on DOM read (jsdom and the
real draw.io editor alike), silently flattening multi-line labels and
tab-bearing values that the old base64 form stored verbatim.

Finding 1 (data-loss): both encode paths — buildDrawioSvg's xmlEscape (mcp) and
the import service's escape — now append &#x9;/&#xa;/&#xd; after the four
&<>" replaces (numeric char-refs survive normalization, as draw.io's own export
does). The extractContentAttr regex fallback now decodes those char-refs (hex
case-insensitive plus decimal &#9;/&#10;/&#13;) so it agrees with the DOM path;
&amp; stays decoded last so an escaped &amp;#x9; reads back as literal text.

Finding 2 (dedup): the server's private xmlEscapeAttr is replaced by the shared
htmlEscape helper (& < > " ' — a strict superset, the extra ' is harmless in a
"-delimited value) wrapped in xmlEscapeContent, which adds the three control-char
char-refs on top (htmlEscape does not escape them).

Finding 3 (docs): narrow the CHANGELOG healing claim — only a diagram still
holding its original correct-UTF-8 base64 (not yet opened/autosaved) is
recoverable; one already opened in the editor persisted mojibake at rest and its
text is lost.

Tests: new mcp round-trip test with literal tab/newline/CR in a value (DOM path,
byte-stable) plus a fallback-branch test forcing a malformed wrapper so both
decode paths are proven to agree; new server spec asserting char-ref encoding.
Mutation-checked: dropping the encode replaces reddens both new mcp tests;
dropping only the fallback decode reddens just the fallback test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:05:52 +03:00
agent_coder 32e10ca6d3 fix(drawio): content= пишется entity-XML, не base64 — кириллица не мойбейкает в редакторе (#507)
Реviewer-filed баг: draw.io-редактор декодит base64 content= как Latin-1
(atob-семантика) → кириллица разваливается в мойбейк, автосейв редактора
персистит порчу и убивает превью. Нативная форма draw.io — entity-encoded
mxfile-XML (content="&lt;mxfile…"), DOM-декодится как UTF-8.

Фикс двух write-путей: buildDrawioSvg (mcp, все create/update) и
createDrawioSvg (server Confluence-импорт) теперь XML-эскейпят content=
вместо base64. Декодер уже различает startsWith("&lt;") vs base64 — обе формы
читаются, старые base64-файлы открываются (back-compat), byte-stable
round-trip. CHANGELOG + заметка про лечение старых диаграмм (drawioGet→Update).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:05:52 +03:00
vvzvlad 74d212cfd3 Merge pull request 'feat(auth): серверная API-key аутентификация агентов на /mcp — фаза 1 (#501)' (#526) from feat/501-mcp-apikey-auth into develop
Reviewed-on: #526
2026-07-12 05:02:51 +03:00
agent_coder c96fafc4ad perf(ai-chat): append-персист шагов — per-step INSERT вместо переписи строки (#492)
Раньше каждый onStepFinish переписывал ВСЮ строку ассистента (растущий
metadata.parts jsonb со всеми выводами инструментов) → O(n²) объёма записи
на прогон: под MVCC/TOAST апдейт jsonb переписывает всю версию строки, так
что шаг k пишет ~k×вывод. Прогон из 50 шагов по ~100 КБ = сотни МБ WAL и
мёртвых кортежей за ход, что молотит autovacuum. (#490 убрал только ВТОРУЮ
копию в tool_calls; сам metadata.parts всё ещё рос и переписывался.)

Теперь каждый завершённый шаг ДОПИСЫВАЕТСЯ отдельной строкой в лёгкую
таблицу ai_chat_run_steps (только парты этого шага), а строка сообщения
получает дешёвый маркер (stepsPersisted + toolTraceVersion, без растущего
блоба parts). Полный metadata.parts собирается ОДИН раз на финализации.
НЕ jsonb-append (||): апдейт всё равно переписывает всю TOAST-версию —
экономится только сетевой payload, а WAL/мёртвые кортежи остаются; поэтому
именно ОТДЕЛЬНАЯ таблица + INSERT.

Три обязательные интеграции:
- reconstructRunParts(row, stepRows) → { parts, stepsPersisted }: единый
  шов переключения бэкенда. Читает парты из СТРОКИ, если она уже несёт
  inline-parts (старые записи + ЛЮБАЯ финализированная), иначе из ТАБЛИЦЫ
  ШАГОВ (mid-run запись #492). Дискриминатор — наличие непустого
  metadata.parts (флаг схемы не нужен). Потребители (attach-seed,
  delta-poll, export, reconnect) прогоняют строки через hydrateAssistantParts
  на границе чтения — их контракт/вывод не меняется, старые и новые записи
  восстанавливаются идентично.
- сигнал ротации кольца реестра #491 (confirmPersistedStep) теперь стреляет
  на подтверждённый INSERT шага, под тем же контрактом (updateStreaming
  возвращает stepsPersisted / null).
- era-marker toolTraceVersion (#490) больше не ставится полной переписью —
  ставится в маркере шага и на финализации (flushAssistant), остаётся
  консистентным.

Полная обратная совместимость: прогон, записанный по-старому (полная строка,
без строк шагов), восстанавливается/attach/export идентично. При отсутствии
репозитория шагов (позиционные тест-конструкции) — фолбэк на прежний
полнострочный flush (без регрессии, только без выигрыша WAL).

Тесты (реальный pg, int-lane):
- WAL-дельта (pg_current_wal_lsn) на прогоне 40×100КБ: new=4.3МБ vs
  old=90.3МБ (20.8x) — O(Σ шагов) против O(n²); старый путь в тесте И есть
  ревертнутое поведение (мутация-проверка).
- reconstruct-контракт: new-style (таблица шагов) и old-style (inline) прогоны
  восстанавливаются в идентичные parts; hydrate заполняет строку.
- миграция up/down roundtrip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:02:25 +03:00
vvzvlad 9a6389133b Merge pull request 'fix(page-tree/realtime): insertByPosition теряет непоказанных детей (#525)' (#531) from fix/525-insert-unloaded into develop
Reviewed-on: #531
2026-07-12 05:01:38 +03:00
agent_coder 2f8c5d9a98 perf(client): инкрементальный рендер стрима ответа (#492)
Путь ответа ассистента теперь рендерится инкрементально, по образцу
StreamingPlainText из ветки reasoning (#492, волна C эпика #497).

Раньше MarkdownPart прогонял ВЕСЬ накопленный ответ через канонический
конвейер (markdownToProseMirrorSync → PMNode.fromJSON → DOMSerializer →
DOMPurify) на КАЖДОМ throttled-тике (~20 Гц). На синтетическом потоке
~100 КБ это 394 вызова renderChatMarkdown — O(числа тиков), причём каждый
вызов заново парсит всю растущую строку.

Теперь:
- StreamingMarkdownText делит текст на блоки по безопасному срезу
  (splitPlainChunks — та же append-only-инвариантность, что у reasoning):
  СТАБИЛИЗИРОВАННЫЕ блоки идут через канонический конвейер и мемоизируются
  (каждый блок парсится РОВНО ОДИН раз), живой ХВОСТ — дешёвый plain-text
  (React-escaped, без парсера/санитайзера/innerHTML) до стабилизации.
- На финализации (флип state → done или конец хода) — ОДИН полный
  канонический рендер всего текста: побайтовая визуальная паритетность с
  прежним выводом (включая <li><p>-обёртки схемы и scoped-CSS из #498).
- Гейт liveness тот же, что у ReasoningBlock: streaming =
  turnStreaming && part.state === "streaming".

Также цикл рендера частей переведён на ИСЧЕРПЫВАЮЩИЙ switch по видам частей
с never-проверкой в default (вместо прежнего WARNING-комментария): новый
закрытый вид части в UIMessagePart теперь ошибка компиляции.

Тесты:
- perf-smoke: на ~100 КБ потоке число вызовов renderChatMarkdown ≤ blocks+2
  и ≪ ticks (O(блоков), не O(тиков)); мутация (снять memo с MarkdownChunk)
  краснит ассерт (78631 вызовов).
- visual-regression: финальный рендер побайтово равен renderChatMarkdown
  всего текста (в т.ч. <li><p>), инкрементальный вид сходится к нему на
  финализации; учтён neutralizeInternalLinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:35:06 +03:00
vvzvlad 6f81067f4d Merge pull request 'feat(#370 PR-1): ядро версий страниц — kind + ручной Save/idle/boundary триггеры' (#374) from feat/370-page-versioning into develop
Reviewed-on: #374
2026-07-12 04:33:39 +03:00
agent_coder 55e8f61b3c Merge remote-tracking branch 'gitea/develop' into feat/501-mcp-apikey-auth 2026-07-12 04:33:17 +03:00
agent_coder d42ca8dc57 test(auth): cover collab-token api-key arm-seam; docs + dead-code cleanup (#501)
Hardening follow-ups from PR #526 review (no defect fixes):

- DO1: add auth.controller.spec tests for the collab-token handler, the
  arm-seam of the anti-laundering defense. Assert getCollabToken is invoked
  with { apiKeyId } only when the SIGNED req.raw marks an api_key principal,
  and undefined otherwise (session, missing apiKeyId, or a spoofed body).
  Verified non-vacuous: nulling the ternary reddens the ARMED test.
- DO2: document the API_KEYS_ENABLED kill-switch in .env.example next to the
  other feature flags (default ON; strict true/false; OFF denies api-key auth
  and 404s the management endpoints).
- DO3: remove dead bindAccessJwtVerifier (+ its now-orphaned AccessJwtVerifier
  interface and its dedicated spec block); prod switched to
  bindMcpBearerVerifier. verifyBearerAccess is retained (still used).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:19:52 +03:00
vvzvlad 25a31e6c0d Merge pull request 'perf(ai-chat): resume-стек #491 — восстановление в develop (осиротел при stacked-мерже #518)' (#540) from feat/491-resume-stack into develop
Reviewed-on: #540
2026-07-12 04:16:54 +03:00
agent_coder 0af7eb30c3 docs(page-tree): correct isUnloadedBranch comment — no DnD-guard consumer (#525 review)
The comment falsely listed a 'DnD move guard' consumer; no DnD path routes
through the predicate (local DnD/create-page use the raw index-based insert).
List the real consumers (handleToggle + realtime insertByPosition/placeByPosition)
and note the local raw-insert path as a #525 follow-up. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:13:49 +03:00
vvzvlad 9a94c8df18 Merge pull request 'perf(ai-chat): дисциплина данных — дедуп tool-outputs + токен-бюджет реплея (#490)' (#510) from feat/490-data-discipline into develop
Reviewed-on: #510
2026-07-12 04:11:08 +03:00
agent_coder 3550bfa411 fix(ai-chat): ре-ревью #518 — CI-фиделити delta, attach-дубль, чистки (#491)
Ребейз на обновлённый feat/490 (PR #510, Option-A): стек #491 перенесён с
6e872f2d на 6e42752a; run-fsm.ts/chat-thread.tsx приехали из develop-версии
(W1/S4: RUN_ALREADY_ACTIVE несёт activeRunId + supersede-CAS адопция) — мои
#491-правки легли сверху без конфликтов, W1 НЕ откачен (run-fsm.ts == develop
байт-в-байт; RUN_ALREADY_ACTIVE диспатчит activeRunId).

1. [CI-fidelity] Дельта-курсор спек падал в CI unit-лейне: `.spec.ts` дефолтил
   на НЕмигрированный docmost → 5/6 ERROR relation does not exist (skip-гард ловит
   только connection-fail). Переименован в `*.int-spec.ts` (исключается из unit-
   regex `.spec.ts$`, гоняется в test:int, чей global-setup мигрирует docmost_test)
   + DSN по умолчанию → docmost_test. Теперь 6/6 реально исполняются в CI-верном
   окружении; overlap-мутация роняет RACE-тесты (не вакуумен).

2. [regression #137/#161] attach: отсутствие `n` схлопывалось в frontier 0 →
   finished-неротированный ран (coverageFloor 0) отдавал ВЕСЬ tail вместо 204;
   парамслесс/легаси-вкладка допишет полный replay → дубль. Различаем ОТСУТСТВИЕ
   `n` (null — не tail-aware) от `n=0` (tail-aware): контроллер шлёт null при
   missing/invalid; registry.attach(n: number|null) 204-ит finished-ран при
   n===null (старый `finished && !expectLive` гейт), n=0 по-прежнему отдаёт хвост.
   Тесты (registry + controller) + mutation-verify: нейтрализация гейта роняет их.

4. [conventions] Ring-кап env-var переименован RUN_STREAM_MAX_BUFFER_BYTES →
   AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (префикс как у сиблингов) + запись в
   .env.example (дефолт 4MB, 0/invalid→дефолт, subscriber-cap=2×).

5. [docs] run-fsm.spec.md: item4 переписан («реализовано в #491 — дельта несёт
   run:{id,status}|null; клиент run-поле ещё не потребляет») + добавлена строка
   перехода POLL_IDLE_CAP stopping→idle (Review #4, редьюсер это делает).

6. [simplification] Удалена мёртвая цепочка reconstructRunParts /
   reconstructPartsFromRow (ноль прод-вызовов) + опц. messageRepo-инъекция в
   AiChatRunService + спек-блоки; вернётся с первым реальным вызывателем. Маркер
   metadata.stepsPersisted (реально используемый) сохранён.

DROP-пункты ревьюера (осиротевший import, DELTA_POLL_MAX_ROWS) не трогаю.
Прогон: server tsc 0, ai-chat unit 202, delta-int 6/6 (int-lane), int attach 6/6,
client vitest 403, tsc client 0 ai-chat, mcp 834/0. FSM-инварианты #488 сохранены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder a0ecf21cb5 fix(ai-chat): ре-ревью #491 — дубль на getRun-fail + epoch RUN_FACT + doc (#491)
Ре-ревью нашло регрессию класса #137/#161 на пути отказа getRun при tail-only
re-seed:

- НАХОДКА 1 (MEDIUM/HIGH): в onFinish-disconnect ветка .catch (отказ getRun) на
  локальном дропе входила в reconnect-ladder БЕЗ ре-сида и БЕЗ фильтра «живой»
  частичной строки. anchorRef оставался устаревшим (mount-инициализатор), живая
  частичная строка со шагами N..M-1 — последней; через ~1с реконнект строил
  ?anchor=&n=N_mount, и при живом ране с покрытием от N_mount (flaky-сеть: SSE и
  getRun упали, сеть поднялась за 1с) сервер отдавал кадры ≥N_mount → SDK дописывал
  их к строке, где они УЖЕ есть → дублирование (клиентского дедупа реплея против
  parts нет). Фикс: восстановлена структурная гарантия удалённого resumeStream-
  фильтра — на отказе getRun (и на no-persisted-row, и на no-cid) живая частичная
  строка удаляется из стора по id + anchorRef=null → реконнект реплеит со start в
  ЧИСТЫЙ стор (полная пересборка) либо 204→poll. Нет пути, где attach tail-applies
  на строку с уже присутствующими шагами. Тест: getRun-reject на локальном
  дисконнекте → живая строка отфильтрована + URL без параметров (mutation-verify:
  без фикса тест краснеет — фильтр не срабатывает).

- НАХОДКА 2 (LOW): RUN_FACT в enterReconnect теперь epoch-штампуется (epoch:
  stampEpoch), как везде (postRun): getRun-rtt расширяет окно onFinish→dispatch,
  конкурентный SEND_LOCAL во время rtt теперь дропает устаревший RUN_FACT по I1,
  а не перетирает runFact.runId нового хода.

- НАХОДКА 3 (LOW, doc): run-fsm.spec.md обновлён — stripRef/strippedRowRef →
  anchorRef {id, stepsPersisted}, tail-only + re-seed-from-persist.

FSM run-fsm.ts не тронут; инварианты #488 (epoch/honor-in-stopping/ownership-reset/
disconnect-first/render-gate) сохранены. Клиент ai-chat vitest 399 зелёный, tsc 0
ai-chat-ошибок; сервер delta(6, реально исполняется)/registry/step-marker/attach +
integration attach — зелёное.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder d81781aa27 feat(ai-chat): клиентская активация tail-only resume + delta-поллинг (#491)
Переводит клиент на серверный tail-only контракт resume (задел commit 3),
не трогая FSM run-fsm.ts — меняется только рантайм-обвязка в chat-thread.tsx.

A. Убран STRIP-механизм. Seed теперь содержит ВСЕ персистнутые строки без
   изъятия хвоста (стриминговый хвост — это шаги 0..N-1, к которым SDK-
   продолжение дописывает tail). stripRef/strippedRowRef заменены на anchorRef
   { id, stepsPersisted } — персистнутая assistant-строка, питающая
   ?anchor=<id>&n=<stepsPersisted>. Восстановления stripped-строки на
   204/NONE/starved удалены (строку никто не изымал — нечего восстанавливать);
   invalidateQueries + диспатчи FSM сохранены. Блок anchor-mismatch в reconcile
   сверяется по id из свежей персист-истории, а не по «живой» строке.

B. Вход в attaching/reconnecting — ВСЕГДА через re-seed из персиста; «живой»
   стор НИКОГДА не база для tail-apply. На локальном FINISH_DISCONNECT (и на
   live-follow повторном дропе observer-а) сначала getRun(chatId) → замена
   «живой» частичной строки персистнутой по id (mergeById) + установка anchor,
   и лишь ПОСЛЕ этого диспатч RUN_FACT + FINISH_DISCONNECT (который планирует
   реконнект). Так attach не может продублировать частичный шаг N. Фильтр
   «живой» строки в resumeStream-эффекте убран (его заменяет re-seed). Инварианты
   FSM (I1 epoch-штамп, I4 honor-in-stopping, DISCONNECT-FIRST, сброс ownership на
   терминалах, render-gate) сохранены.

C. URL attach: ?anchor=<id>&n=<stepsPersisted> при наличии якоря, без expect.

D. Degraded-поллинг переведён с полного рефетча всех страниц на дельту:
   useAiChatMessagesQuery больше не поллит (seed один раз), а окно при
   degradedPoll раз в 2.5с зовёт getAiChatMessagesDelta(chatId, cursor) и
   идемпотентно по id мёржит строки в тот же infinite-query кэш через новый
   чистый хелпер mergeDeltaRowsIntoPages. Арминг/разарм (onResumeFallback) и
   idle-cap не тронуты.

Хелперы: seedRows удалён; добавлены stepsPersistedOf и mergeDeltaRowsIntoPages
(+ юнит-тесты на идемпотентность). Тесты chat-thread обновлены под новый URL,
seed-без-стрипа, re-seed-из-персиста на дисконнекте (mutation-verify: падают
без re-seed и при n мимо персиста) и 204→poll-без-restore. Весь ai-chat vitest
зелёный (398), tsc без новых ошибок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder daca5ce8d6 fix(ai-chat): целостность delta-spec (молчаливый скип) + 2 hardening (#491)
Внутреннее ре-ревью: DB-backed delta-spec молча СКИПАЛСЯ — нулевое покрытие
инварианта DB-clock курсора.

- Импорт `import postgres from 'postgres'` (default) при tsconfig
  module:commonjs без esModuleInterop компилился в `postgres_1.default(...)`,
  а CJS-`postgres` не имеет `.default` → TypeError в beforeAll → пустой catch →
  reachable=false → все 6 тестов уходили в console.warn('SKIP') и return БЕЗ
  ассертов (suite оставался бы зелёным даже при регрессии на new Date()).
  Фикс: `import * as postgres from 'postgres'` (как в рабочем int-харнессе).
- Хардненг харнесса: реальная ошибка программирования в beforeAll больше НЕ
  маскируется под «DB unreachable» — скип легитимен только для сетевого отказа
  (ECONNREFUSED и т.п.), иначе rethrow → suite падает громко.
- Два DB-clock теста использовали jest.useFakeTimers() целиком, что замораживало
  внутренние таймеры postgres.js → awaited DB round-trip зависал на 5s-капе (и
  вешал afterAll). Фейкаем ТОЛЬКО Date (doNotFake всех таймеров) — запрос
  резолвится, а инвариант «стамп от часов БД, не app-clock» по-прежнему доказан
  (скос процесс-часов в 2099 → стамп остаётся на времени БД). Теперь все 6
  тестов РЕАЛЬНО исполняются и зелёные против живого Postgres.

Два дешёвых hardening из ревью:
- registry coverageFloor: пустая ветка возвращает max(currentStamp,
  persistedFloor) — инвариант «клиент с n=persistedFloor всегда покрыт»
  структурный, а не тайминг-зависимый.
- GetChatDeltaDto.cursor: @IsString → @IsISO8601 — битый курсор отсекается 400
  на уровне DTO, а не 500 на `::timestamptz`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder daeeb1f3f2 refactor(ai-chat): registry — step-aligned retention + tail-only attach (#491)
Реестр ран-стримов больше не буферизует до 32МБ сырых SSE-кадров на активный
ран и не выливает весь буфер в сокет синхронно при attach — это давало OOM на
1ГБ-контейнере при нескольких марафонских ранах. Теперь кольцо ограничено
(env-настраиваемо, по умолчанию 4МБ) и держится в границах за счёт ротации по
шагам.

Серверная часть (суть коммита):

- Штамповка кадров по шагам в ingestFrame. Штамп кадра = число `finish-step`
  кадров ДО него (с 0); сам finish-step несёт текущее значение, затем счётчик
  инкрементится. Так штамп совпадает с `metadata.stepsPersisted`: клиент с N
  персистнутыми шагами имеет 0..N-1 в сиде и просит хвост `stamp >= N`. Границу
  ловим дешёвым startsWith по `data: {"type":"finish-step"` — форма кадра
  проверена эмпирически против ai@6.0.207 (одна часть на кадр, type всегда
  первый ключ; кавычки в text-delta экранированы, ложных срабатываний нет).

- Кольцо ротируется ТОЛЬКО на подтверждённом персисте шага N
  (`confirmPersistedStep`), сбрасывая кадры `stamp < N` (эти шаги уже на диске и
  придут в свежем сиде). `updateStreaming` теперь СИГНАЛИЗИРУЕТ исход (число
  персистнутых шагов или null), и ротация вызывается лишь при не-null возврате —
  провал персиста ничего не ротирует, кольцо покрывает БОЛЬШЕ (анти-инверсия:
  наивная ротация в .then() после НЕзаписанного шага дырявила бы гарантию).

- Переполнение кольца сверх байтового капа вытесняет старейшие кадры; вытеснение
  ещё-не-персистнутого кадра открывает GAP. Гэп НЕ липкий: floor покрытия
  считается из кольца, поздний персист, проротировав дырявые шаги, его чистит.

- attach(chatId, anchor, n): маркер шага N приходит ТОЛЬКО от клиента (сервер не
  читает строку — N из устаревшего сида дал бы тихую дыру в один шаг). Покрытие
  ОК ⟺ coverageFloor <= n; иначе 204 → клиент рефетчит (больший N) и
  переподключается. Хвост = синтетический `start`-кадр (ран-факт runId/chatId) +
  кадры `stamp >= n`. Инвариант 6 (нет кросс-ран реплея) сохранён через anchor;
  инвариант 4 (снапшот+регистрация в один синхронный тик) сохранён. N-срез
  применяется во ВСЕХ ветках, включая finished-retained: finished + N=N_final →
  пустой хвост + finish-кадр, клиент закрывает стрим.

- Контроллер пишет хвост чанками с учётом drain (writeTailRespectingDrain), а не
  синхронным залпом (вторая половина OOM). Кап подписчика — производное 2× кап
  кольца, обе величины env-резолвятся на инстансе.

Клиент: в тип строки добавлен `metadata.stepsPersisted` (источник N). PIN-SPEC
трип-вайр на ai@6.0.207: `readUIMessageStream({ message })` продолжает последнее
сообщение, `start`-кадр не сбрасывает parts, текст не пересекает finish-step —
на этом держится продолжение при attach; апгрейд ai теперь падает громко.

Тесты (observable-property против РЕАЛЬНОГО реестра/БД): детектор границы на
реальной форме кадра, N-срез (в т.ч. посреди шага), ротация только на
подтверждённом персисте, «персист провалился но кольцо влезло → attach успешен /
провал + переполнение → 204», «устаревший N → 204 → после рефетча успех», очистка
гэпа поздним персистом, finished-retained + N_final, memory-bound (5 параллельных
марафонов сверх 32МБ, каждое кольцо ≤ кап). Обновлены registry/controller specs и
DB-backed интеграционный attach-spec под новую сигнатуру/семантику.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder a919c79cb2 feat(ai-chat): персист step-маркера (#491)
Персист отставал от live-стрима на целый текущий шаг, а run.stepCount как
источник границы шага НЕгоден: recordStep — fire-and-forget, не атомарен с
записью parts (рассинхрон сид↔маркер). Пишем маркер ТЕМ ЖЕ flush'ем, что и
parts:

- flushAssistant стампует metadata.stepsPersisted = число ЗАВЕРШЁННЫХ шагов,
  чьи parts лежат в ЭТОЙ строке (оба выводятся из finished → маркер не может
  разойтись с persisted parts). In-progress хвост (частичный шаг при
  error/abort или mid-stream flush) НЕ считается. Это step-alignment якорь, на
  котором строится resume-стек (ротация кольца по подтверждённому шагу N —
  коммит 3; attach режет хвост по «шаг > N»).
- Контракт AiChatRunService.reconstructRunParts(runId) → { parts,
  stepsPersisted } — единственный интерфейс чтения ЖИВОГО рана (run →
  assistantMessageId → строка → чистый reconstructPartsFromRow). null при
  отсутствии рана / связанной строки / удалённой строки; маркер 0 у pre-#491
  строки — безопасный пол. Потребители: attach (коммит 3), дельта (rows уже
  несут маркер в metadata), экспорт. messageRepo — опциональный 3-й параметр
  конструктора (2-арг тест-конструкции компилируются без изменений).
- /messages и дельта отдают маркер в row автоматически (он внутри metadata).

Тесты: property «stepsPersisted == число завершённых шагов для любого N + parts
согласованы»; частичный хвост не инкрементит маркер; reconstructPartsFromRow
(маркер, дефолт 0, фолбэк на content); reconstructRunParts (резолв + null-кейсы).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder 798a81abfe perf(ai-chat): дельта-эндпоинт поллинга + run-факт в ответе (#491)
Degraded-poll рефетчил ВСЕ страницы infinite-query каждые 2.5 c с полными
parts. Вводим дельта-эндпоинт «строки, изменённые после курсора»:

- POST /ai-chat/messages/delta → { rows, cursor, run: {id,status}|null }.
  Курсор — таймстамп часов БД (now()), клиент эхом возвращает его каждый
  поллинг. Окно перекрытия now()−5s ловит строку, закоммиченную с updatedAt
  чуть раньше момента снятия предыдущего курсора на другом autocommit-
  соединении (одиночные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ
  повторы → контракт: клиентский merge идемпотентен по id (mergeById).
- Run-факт едет В дельте (отдельный /run-поллинг удвоил бы QPS — отвергнуто).
- Все дельта-релевантные записи (message update/finalizeOwner/reconcile/sweep,
  run update/finalizeIfActive/markStopRequested/sweepRunning) стампуют
  updatedAt через SQL now(), а не app-clock new Date(): единая монотонная ось
  курсора, смешанные источники часов были независимым источником пропусков.
- Клиент: сервис-функция getAiChatMessagesDelta + фиксация контракта
  идемпотентности mergeById тестом (свап degraded-поллинга на дельту — в
  коммите 3, вместе с re-seed-путём attach).

Тесты (observable-property на живом Postgres, не моки):
- дельта-семантика + монотонность курсора;
- RACE «коммит позже с updatedAt раньше курсора» — ловится окном перекрытия
  (наивный updatedAt > cursor пропустил бы);
- окно гарантирует повторы → идемпотентный merge;
- updatedAt стампуется часами БД, а не app-clock (фейк системных часов в 2099
  — стамп остаётся на времени БД);
- контроллер: owner-gate, форма ответа, run-факт только {id,status}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
vvzvlad 8abde99611 Merge pull request 'ci: гейты наблюдаемых свойств перед publish — image-smoke, migration-order на push, allowlist fail-closed, property-тесты (#476)' (#477) from ci/476-publish-gates into develop
Reviewed-on: #477
2026-07-12 04:07:24 +03:00
vvzvlad d608311cae Merge pull request 'fix(ui/toasts): глобальная видимость (тон+рамка+тень) + перенос в top-center без перекрытия (#517)' (#528) from fix/517-toasts into develop
Reviewed-on: #528
2026-07-12 04:06:41 +03:00
agent_coder 6dad309b51 fix(ai-chat): ревью #510 — Опция A бюджета + 4 DO ревьюера
Эскалация (владелец) — Опция A: 100k только фолбэк для НЕсконфигурированных
инсталляций; при заданном chatContextWindow бюджет = floor(0.7×window) БЕЗ капа
(бюджетер — защита от брика об контекст-окно, не эконом-лимитер). Спек репиннут
resolveReplayBudget(1_000_000)→700_000.

DO1: агрессивный next-turn recovery ×0.5 вынесен в чистую resolveEffectiveReplayThreshold
+ тест линковки replayOverflow→0.5×бюджет (mutation-verified).
DO2: checkNewComments partial-failure — per-page reject скипается (→null), скан
резолвится, порядок выживших сохранён; тест #7 (mutation-verified).
DO3: ai-chat.write-volume.spec.ts → .int-spec.ts (WAL-гард не бежал НИ в одном
CI-lane) + маппер @docmost/token-estimate в jest-integration.json; реальный WAL
на pg:5432 зелёный (трейс v1 140MB→v2 0.04MB).
DO4: CHANGELOG [Unreleased] по #490.
Follow-up: issue #520 (эскалация агрессивной доли при незаданном окне + малом
реальном контексте).

Ребейзнут на develop (волна 1 смержена): только 6 коммитов #490 над develop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:06:24 +03:00
agent_coder b038d96708 perf(ai-chat): snapshotOpenPage fast-path (#490)
snapshotOpenPage делал полный экспорт Markdown + upsert каждый ход. Fast-path: если
снапшот уже существует на ТЕКУЩЕЙ версии страницы (тот же instant updated_at), его
контент уже актуален — пропускаем экспорт+upsert целиком. Ход, не тронувший
открытую страницу (частый случай), больше не делает работы по снапшоту.

Зеркалит read-side fast-path в detectPageChange (sameInstant): оба доверяют, что
правка страницы двигает updated_at. Когда агент/человек ПРАВИЛ страницу этим ходом,
updated_at продвинулся → не совпадает → экспортируем как раньше (правки агента
запекаются в снапшот, инвариант #274 сохранён).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder f5bbfdb2d4 perf(ai-chat): deferred-активация тулов в metadata чата (#490)
Активированный set сбрасывался каждый ход → модель заново гоняла loadTools, чтобы
переактивировать те же тулы (лишний round-trip на каждом ходу). Теперь набор
персистится в metadata чата и сидируется на следующем ходу.

- Миграция: jsonb-колонка metadata на ai_chats (default '{}'); db.d.ts дополнен
  вручную (AiChats.metadata: Generated<Json>).
- seedActivatedTools(metadata, validDeferredNames): читает сохранённый набор,
  ПЕРЕСЕКАЯ с актуальными validDeferredNames — смена allowlist/ролей не воскресит
  несуществующий тул (иначе prepareAgentStep получил бы фантомное активное имя).
  Сид только при deferredEnabled.
- Персист на завершении хода (once-guard, во всех терминальных ветках рядом со
  snapshotTurnEnd): детерминированно отсортированный набор, merge в существующий
  bag (другие ключи сохраняются), запись пропускается если ничего нового не
  активировано (обычный ход не даёт лишней записи).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder 875f6ba9c5 feat(ai-chat): токен-бюджет реплея истории + реактивная ветка (#490)
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат
рано или поздно упирается в контекстное окно и получает провайдерский 400 на
каждом ходу — навсегда (чат «кирпичится»). Бюджетер ограничивает РЕПЛЕЙ (никогда
не мутирует персист — в БД остаётся полная запись), детерминированно и byte-stable
(обрезанный префикс идентичен от хода к ходу → дружелюбно к prompt-cache).

Единый оценщик chars/2.5 (кириллица; chars/4 занижает вдвое) вынесен в shared-пакет
packages/token-estimate; клиентский count-stream-tokens.ts переведён на него ТЕМ ЖЕ
коммитом (два расходящихся оценщика = «бейдж 60%, а бюджет уже режет»).

history-budget.ts (чистый, покрыт тестами):
- resolveReplayBudget(raw): min(100k, 0.7×window) при заданном окне; флэт 100k при
  незаданном (именно эти инсталляции ловят терминальный overflow — warn-лог); 0 =
  явный off-switch. Читается СЫРОЙ chatContextWindow, т.к. parsePositiveInt схлопывает
  0 и unset в undefined (новое поле ResolvedAiConfig.chatContextWindowRaw).
- trimHistoryForReplay: первичный сигнал — провайдерский факт metadata.contextTokens
  прошлого хода; chars-оценка — дельта/раскройка/фолбэк. Порядок: обрезка tool-outputs
  старых ходов (head+tail+маркер) → механическое схлопывание старейших ходов
  (конкатенация, НЕ LLM) → текущий + последние N ходов всегда полные. Пейринг
  tool-call/result сохраняется (схлопывание убирает ОБЕ части).
- isContextOverflowError: классификация провайдерского 400 (статус + паттерны).

Реактивная ветка: превентивная оценка не даёт инварианта (первый переполняющий ход
не имеет usage). onError классифицирует context-overflow → пишет различимую причину
и штампует metadata.replayOverflow; следующий ход бюджетер режет агрессивно
(0.5×), что и раскирпичивает чат. Наблюдаемость: metadata.replayTrimmedToTokens.

ПРИМЕЧАНИЕ по реактивной ветке (форк, требует решения ревьюера): истинный in-turn
re-pipe (перезапуск streamText в тот же ответ) архитектурно несовместим с текущим
пайпом — pipeUIMessageStreamToResponse пишет writeHead СИНХРОННО (подтверждено в
ai@6.0.207), а suite ожидает await stream() c моком, не дёргающим колбэки, — так что
отложенный пайп/ожидание сигнала повесит тесты. Поэтому реализована реактивная
рекавери «классификация → штамп → агрессивный ре-трим на следующем ходу», что даёт
тот же инвариант (чат не кирпичится) без рискованного рефактора стрима.

Тесты (наблюдаемые свойства): объём записи через дельту pg_current_wal_lsn() на живой
gitmost-test-pg вокруг 50-шагового прогона (несжимаемые payload'ы) — trace-колонка
v1=140МБ → v2=0.04МБ (в 3206× меньше), полная строка 289МБ → 140МБ (−51%); dual-shape
не нужен здесь; «окно не задано → бюджет применяется»; реактивная классификация на
реальном 400-шейпе; parity клиент/сервер оценщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder 7741821ee3 perf(ai-chat): кэш compactToolOutput по identity шага (#490)
compactToolOutput делает JSON.stringify каждого output на КАЖДОМ flush. Т.к.
onStepFinish на шаге N перестраивает всю assistant-строку по всем N накопленным
шагам, а каждый output — 50–200 KB, это O(N²) stringify за ход.

Мемоизация по identity шага: finished-шаг в capturedSteps неизменен и держит
стабильную ссылку между flush'ами, поэтому его parts (и дорогой stringify output)
строятся ровно раз за ход. buildStepParts вынесен в чистую функцию; assistantParts
принимает опциональный StepPartsCache (WeakMap<step, parts>), flushAssistant
пробрасывает его, stream() заводит один WeakMap на ход и передаёт во все flush'и.
Промах кэша (или его отсутствие в тестах/легаси-вызовах) просто пересобирает —
байтового расхождения нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder 48a074e0d2 perf(ai-chat): формат трейса tool_calls v2 — outputs только в parts (#490)
Каждый tool-output хранился ДВАЖДЫ: в metadata.parts (assistantParts) И в
tool_calls (serializeSteps). При 50-шаговом ране с outputs по 50–200 KB это
127–510 МБ записи в Postgres за ход (+WAL/TOAST/dead tuples), т.к. onStepFinish
переписывает всю строку. Копия в parts — та, что реально реплеится модели и
рендерится UI/markdown-экспортом, так что копия в трейсе была чистым дублем.

Новый формат элементов tool_calls (v2), парно на каждый вызов:
  {toolName, input}                      — вызов
  {toolName, ok: true}                   — успех (БЕЗ output)
  {toolName, error, kind: 'thrown'}      — брошенный tool-error
  {toolName, error, kind: 'interrupted'} — прерван mid-step (abort/restart)

kind обязателен: синтетический «Tool call did not complete.» при прерывании иначе
неотличим от реального hard-fail и загрязняет error-rate. Различие структурное
(errorsById-хит против синтетической ветки), НЕ per-tool классификатор — soft-
маркеры в трейс не выносятся (остаются в metadata.parts).

metadata.toolTraceVersion: 2 — маркер эры; старые строки НЕ мигрируются
(перезапись гигантских jsonb — тот самый WAL-чарн). serializeSteps пейрит
результаты/ошибки по toolCallId (как assistantParts); общая константа
TOOL_CALL_INCOMPLETE_TEXT держит текст реплея и трейса в синхроне.

docs/reading-ai-logs.md переписан dual-shape: ветвление по toolTraceVersion,
soft-анализ v2 через metadata.parts, правило «не сравнивать агрегаты через границу
эр». UI action-log и markdown-экспорт читают только parts — не затронуты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
vvzvlad 17e3b3d882 Merge pull request 'fix(converter): block-escape + attribute contract + dedup mirrors (#493)' (#514) from feat/493-converter into develop
Reviewed-on: #514
2026-07-12 04:04:24 +03:00
vvzvlad 801add63e8 Merge pull request 'fix(ai-chat+security+cache): хвосты стабилизации — транспорт, share-alias, temp-notes, guards (#495)' (#516) from feat/495-tails into develop
Reviewed-on: #516
2026-07-12 04:03:55 +03:00
vvzvlad 7ad072ba2c Merge pull request 'feat(mcp): гарды зеркал реестра + write-целостность (#494)' (#513) from feat/494-mcp-registry-guards into develop
Reviewed-on: #513
2026-07-12 04:03:44 +03:00
agent_coder 216499d57b Merge remote-tracking branch 'gitea/develop' into feat/370-page-versioning
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:31:04 +03:00
agent_coder b041944a23 Merge remote-tracking branch 'gitea/develop' into feat/493-converter
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:28:31 +03:00
agent_coder 401d62119c Merge remote-tracking branch 'gitea/develop' into feat/493-converter
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:23:14 +03:00
agent_coder 51260793c0 fix(ui/toasts): глобальная видимость тостов + перенос в top-center (#517)
Тост-уведомления Mantine сливались с фоном: у бесцветных тостов фон
карточки == var(--mantine-color-body) (белый, как страница) при слабой
тени, поэтому на белых страницах у карточки не было видимого края. Плюс
тосты всплывали снизу по центру и перекрывали контент.

Чиним глобально, без правок в 213 местах вызова:

- notification-overrides.css: каждому тосту даём тонированный по типу фон,
  рамку с контрастом WCAG >= 3:1 и усиленную тень (shadow-xl). Селектор
  [data-mantine-color-scheme=...] .mantine-Notification-root имеет
  специфичность (0,2,0) и стабильно бьёт правила Mantine (0,1,0) (у Mantine
  атрибут схемы обёрнут в :where()) — независимо от порядка стилей. Тон/рамка
  идут от --notification-color (определён на том же элементе), поэтому следуют
  типу тоста и покрывают loading/импортный тост (полосы-акцента нет — несут
  тон+рамка+тень+цветной спиннер). Обе темы; текст-с-заголовком поднят до
  gray-7 ради AA-контраста на тонированном фоне.

- main.tsx: position bottom-center -> top-center. Вертикальное смещение
  контейнера ниже верхней хромы делаем НЕ инлайн-стилем, а CSS-правилом со
  скоупом по позиции: Mantine рендерит все шесть позиционных контейнеров
  одновременно, и корневой style-проп ушёл бы во все шесть — нижним (bottom:16)
  добавился бы top:96 → position:fixed + оба края + height:auto растянули бы их
  на весь вьюпорт; у корня нет pointer-events:none/фона → прозрачные оверлеи
  z-10000 перехватывали бы клики по всей странице.

- notification-overrides.css: .mantine-Notifications-root[data-position^='top']
  { top:96px } (шапка 45 + опц. тулбар 45 + зазор). Скоуп ^='top' смещает
  только верхние контейнеры; нижние остаются height:0 и кликов не перехватывают.
  Специфичность (0,2,0) бьёт mantine top:16px (0,1,0), тост z-10000 стоит ниже
  шапки/тулбара (z-99) и их не перекрывает.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:14:38 +03:00
agent_coder c765483947 fix(page-tree/realtime): insertByPosition теряет непоказанных детей — предикат «незагружено» приведён к gate
`insertByPosition` считал узел незагруженным только при `children === undefined`,
но канонический незагруженный вид в этом коде — `children: []` с `hasChildren: true`
(его ставит `pageToTreeNode`, к нему сбрасывает свёрнутые ветки `pruneCollapsedChildren`).
Для реального незагруженного узла guard `=== undefined` не срабатывал → в пустой
список вставлялся `[node]` → lazy-load gate видел `length !== 0` и не догружал
остальных серверных детей папки (скрыты до полной перезагрузки) — тот же класс
потери данных #159 #1.

- Новый общий предикат `treeModel.isUnloadedBranch(node)`:
  `hasChildren === true && (children == null || children.length === 0)` — единый
  источник истины для «отложить ли фетч/материализацию».
- `insertByPosition` использует его вместо `=== undefined`; для действительно
  пустой папки (`hasChildren: false`) вставка первого ребёнка сохраняется.
- gate в `handleToggle` (`space-tree.tsx`) переведён на тот же хелпер, чтобы
  предикаты больше не разъезжались (R3).

Тесты: юнит на `isUnloadedBranch`; `insertByPosition` не материализует
`[node]` в `children:[]`+`hasChildren:true` (и оставляет ветку незагруженной),
но вставляет в genuinely-empty. Мутация: старый `=== undefined` предикат
краснит эти тесты (в т.ч. на уровне `applyMoveTreeNode`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:06:42 +03:00
agent_coder e3ca2dc1d5 test(db): executeTx — покрыть rollback-ветку after-commit-хука
Ревью #516: fakeDb.execute всегда пушил commit — моделировался только commit-путь,
несущий негатив (тело кидает → tx реджектит → дренаж после awaited-tx не бежит →
хук НЕ фаerr) не был заперт. Исходник корректен, это была дыра в тесте.

Добавлен тест: тело бросает → executeTx реджектит, commit не случился, хук не
побежал. Mutation-verify: перенос дренажа в finally → тест краснеет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder f919ced8c9 fix(db): concurrent-pre-build индексов — переопределять f_unaccent ПЕРЕД циклом
Ревью #516: фича была мёртвой-при-рождении ровно на целевом кейсе. Все три
CONCURRENT_INDEXES используют LOWER(f_unaccent(col)), но index-инлайнящаяся
1-арг форма f_unaccent (SELECT public.unaccent($1)) создаётся ВНУТРИ миграции
20260705, а ensureConcurrentIndexes зовётся ПЕРЕД мигратором. На существующем
тенанте живой f_unaccent — ещё старая 2-арг форма из 20250729, которая НЕ
инлайнится: CREATE INDEX CONCURRENTLY падает («function unaccent(unknown, text)
does not exist … during inlining»), best-effort глотает, и мигратор строит
индексы НЕ-concurrently под тем самым SHARE-локом (эмпирически подтверждено
ревьюером на живом pg).

Фикс: перед циклом идемпотентно переопределяем f_unaccent в 1-арг форму
(output-identical, в lockstep с 20260705), в том же best-effort try/swallow.

Плюс честный докстринг: убрал ложное «worst case = previous behaviour».
Прерванный CONCURRENTLY оставляет INVALID-индекс, который name-based IF NOT
EXISTS не чинит — новый режим отказа (старый in-tx build такого не оставлял).

Тест: переопределялка вызывается ПЕРВОЙ (order), затем индексы CONCURRENTLY вне
транзакции; провал переопределялки не рвёт цикл. Mutation-verify: убрать
pre-loop redef → order-тест краснеет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 4109b2ef7f fix(security): share-alias reassign 409 — гейт раскрытия по view-праву (доведение #495 к.4)
Коммит 4 закрыл утечку currentPageId на анонимном /availability, но
эквивалентная (и более широкая) дыра оставалась на POST /aliases/set (reassign):
при занятом алиасе и confirmReassign=false 409 отдавал currentPageId И
currentPageTitle ЦЕЛЕВОЙ страницы, а контроллер гейтил только validateCanEdit на
ИСХОДНОЙ странице. Любой участник с одной редактируемой+расшаренной страницей мог
перебирать имена алиасов и мапить их на (id, title) чужих страниц без права
просмотра — тот же класс перечисления, плюс ещё и заголовок.

Фикс: setAlias теперь гейтит раскрытие. currentPageId НЕ отдаётся никогда
(клиент им не пользуется, это перечислимая идентичность). currentPageTitle —
только если validateCanView(целевая, user) проходит; иначе голый «занят» (клиент
и так показывает generic confirm-модалку без заголовка — UX не ломается).
Гейт живёт в сервисе, где строится раскрытие (PageAccessService — @Global, без
цикла); контроллер прокидывает user. Поправлен неточный коммент checkAvailability.

Тесты: viewer → 409 с title, БЕЗ id; не-viewer → 409 без title и без id.
Mutation-verify: вернул утечку (id + безусловный title) → оба теста краснеют.
Контроллер-спек и int-spec обновлены под новую сигнатуру; tsc чист.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 6b27ff0652 feat(ai,mcp): идентичность прогона реиндекса + брендированные PageId/SlugId (#495 item 14)
Аспект A — идентичность прогона реиндекса эмбеддингов.
У статуса/поллинга реиндекса не было идентичности КОНКРЕТНОГО прогона, из-за
чего класс багов «это тот же прогон или новый?» чинили дважды. Теперь каждый
прогон получает свой runId (crypto.randomUUID в start()), он хранится в Redis-
хэше рядом с total/done/startedAt и возвращается в ReindexProgress. Эндпоинт
статуса (getMasked -> MaskedAiSettings) отдаёт runId и reindexStartedAt. Клиент
кеит поллинг на (runId, startedAt): смена runId = НОВЫЙ прогон (сбрасываем
залатанное per-run состояние поллинга), тот же runId = тот же прогон. Всё
best-effort/косметика как и остальной код прогресса: пустой/отсутствующий runId
деградирует мягко и никогда не ломает реиндекс.

Аспект B — брендированные PageId/SlugId в MCP-клиенте + валидация формата в
серверных DTO (семейство инцидентов #435: двойная идентичность страницы —
внутренний id и slugId — гонялась как голая строка и молча путалась).
- packages/mcp/src/lib/page-id.ts: номинальные типы PageId/SlugId + валидирующие
  конструкторы asPageId/asSlugId и гварды isPageId/isSlugId (формат UUID и
  10-символьного slugId). PageId протянут через единственный узел канонизации и
  записи: resolvePageId() теперь возвращает PageId, а ключ per-page лока
  (withPageLock) и точки записи в collab (mutatePageContent/replacePageContent/
  updatePageContentRealtime) требуют бренд — сырой slugId/непроверенный id больше
  не проходит проверку типов (инвариант #260 «resolve-then-lock» теперь на уровне
  компилятора). Публичный вход методов остаётся строкой (это legitimно UUID ИЛИ
  slugId), брендируется каноническое значение.
- apps/server core/page/dto: PageIdDto.pageId получает валидацию формата
  (@Matches, UUID или 10-символьный slugId), так что кривая/подменённая
  идентичность отклоняется на границе, а не падает в repo голой строкой.

Тесты (все зелёные, с mutation-verify каждого):
- server: getMasked отдаёт runId/reindexStartedAt; стор пишет/читает runId и
  мягко деградирует его до ''; DTO отклоняет кривой pageId/slugId и принимает
  валидный.
- client: чистый хелпер reindexRunKey/isNewReindexRun (кеинг поллинга на runId).
- mcp: конструкторы/гварды PageId/SlugId (валидация формата).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 579c82617b i18n(ru): перевести недостающие строки в ru-RU
Добавлены переводы для 151 ключа, присутствовавшего в en-US, но
отсутствовавшего в ru-RU (настройки ИИ, диктовка, MCP, HTML-вставки,
роли агента и др.), включая ранее непереведённые «Streaming dictation»
и «Save and test». Технические токены и бренды (MCP, URL, Docmost AI,
плейсхолдеры версий) намеренно оставлены как есть. Добавлены русские
формы множественного числа (_few/_many) для «result found». Файл
переупорядочен в соответствии с порядком ключей en-US.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 173f35e473 fix(editor): защитить чтение приватных стеков y-undo в тулбаре
canUndo/canRedo в use-toolbar-state читали приватные внутренности y-undo
(undoManager.undoStack.length / redoStack.length). Апгрейд yjs / y-undo,
переименовавший или перестроивший эти поля, тихо сломал бы состояние кнопок
undo/redo (или упал бы на .length у undefined) без единой ошибки.

Оставляем дешёвое чтение длины стеков (сознательно не используем дорогой
editor.can().undo()/.redo(), который делает dry-run на каждый keystroke,
см. комментарий в файле), но теперь feature-detect: доверяем стекам только
если это реально массивы, иначе откатываемся на безопасный дефолт
(prosemirror-history undoDepth/redoDepth -> 0). Логика вынесена в чистую
функцию yHistoryAvailability.

Добавлен pin-test, фиксирующий текущую форму библиотеки: реальный
Y.UndoManager по-прежнему отдаёт undoStack/redoStack массивами. Апгрейд,
меняющий контракт, упадёт громко в тесте, а не тихо в UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 696b96ac18 fix(client): авто-перезагрузка чанков в окне 5 минут вместо one-shot флага
При ChunkLoadError граница перезагружала страницу один раз, гейтируя
булевым флагом в sessionStorage, который никогда не сбрасывался. Из-за
этого ВТОРОЙ деплой за время жизни вкладки не давал авто-восстановления:
пользователь застревал на битом чанке без перезагрузки.

Заменяю one-shot флаг на счётчик по временному окну: не более одной
авто-перезагрузки за 5 минут. В sessionStorage храню метку времени
последней перезагрузки; на ChunkLoadError перезагружаемся только если
прошлая была раньше окна (или её не было), иначе проваливаемся в ручной
UI без перезагрузки. Это восстанавливает работу через несколько деплоев,
но не даёт бесконечного цикла при навсегда битом lazy-чанке (сброс флага
после успешного маунта отвергнут: оболочка монтируется, чанк 404 —
и цикл).

Решение об окне вынесено в чистый хелпер shouldAutoReload(now,
lastReloadAt, windowMs) и покрыто юнит-тестами: никогда-не-грузили →
можно; 6 минут назад → можно; 1 минуту назад → нельзя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder a96ca8e26b perf(db): GIN trigram-индексы строятся CONCURRENTLY вне транзакции
2 GIN trigram-индекса (pages.title, pages.text_content) + users.name строились
plain CREATE INDEX внутри Kysely-транзакции миграции: SHARE-lock блокирует записи
на pages/users на минуты при автодеплое.

Kysely гоняет миграцию в транзакции, а CONCURRENTLY внутри транзакции нельзя.
Поэтому ensureConcurrentIndexes (concurrent-indexes.ts) пре-строит эти индексы
через CREATE INDEX CONCURRENTLY (raw, вне транзакции) ДО миграатора — на
существующей БД миграционный CREATE INDEX IF NOT EXISTS становится no-op и лок не
берётся. Best-effort: на свежей БД (нет pages/f_unaccent) пре-build молча
пропускается, а миграция строит индекс на пустой таблице. Худший случай = прежнее
поведение, лучший — без лока.

CONCURRENT_INDEXES — канонические определения; drift-guard тест сверяет их с
выражениями в миграциях. Тест раннера: CONCURRENTLY+IF NOT EXISTS, вне
транзакции, best-effort (падение одного не рвёт остальные).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder f84386f24a fix(temp-notes): свип на старте + FOR UPDATE SKIP LOCKED в транзакции
Две дырки свипера временных заметок:
- Не было свипа на старте: заметки, протухшие во время простоя, ждали до часа
  до первого тика @Interval. Добавил onApplicationBootstrap (best-effort, не
  блокирует boot).
- Гонка без блокировки: re-check и removePage не были атомарны, «Сделать
  постоянной» могла проскочить между ними. Теперь re-check идёт в транзакции с
  FOR UPDATE SKIP LOCKED: сериализуется с toggleTemporary (тот же row-lock) и
  пропускает строки, захваченные другим воркером/инстансом (без двойной
  обработки). Удаление идёт ВНУТРИ этой транзакции.

removePage получил опциональный trx: чтобы удалять под блокировкой без deadlock
на вложенной независимой транзакции. Броадкаст PAGE_SOFT_DELETED при переданном
trx отложен на commit через registerAfterCommit (откат больше не рассылает
фантомное удаление); без trx поведение прежнее.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder c0c44fddb9 fix(security): client-vitals — whitelist route/attr на анонимном эндпоинте
POST /api/telemetry/vitals анонимный, а route/attr не сверялись со словарём:
любой мог писать свободный текст в client_metrics (высокая кардинальность,
инъекция текста/PII/разметки).
- route: экспортировал полный словарь шаблонов из клиентского route-template.ts
  (KNOWN_ROUTE_TEMPLATES — канонический источник), сервер валидирует по зеркалу
  ALLOWED_ROUTE_TEMPLATES: нет в словаре → drop (null), событие остаётся.
- attr: это web-vitals attribution target (CSS-селектор), а не enum — ограничил
  консервативным charset CSS-селектора; всё вне набора → drop.
Клиентский self-consistency тест: templateRoute выдаёт ТОЛЬКО значения из
словаря (иначе легитимные метрики отбрасывались бы). Серверные тесты: raw-путь
и инъекция в route отброшены, PII/разметка в attr отброшены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 91e58e3c9f fix(cache): bustWorkspaceCache — после коммита, не внутри транзакции
bustWorkspaceCache звался сразу после write, но ВНУТРИ переданной транзакции
(до коммита). Окно: параллельный читатель промахивается мимо инвалидированного
ключа, читает ещё НЕ закоммиченную (старую) строку и репопулирует кэш старым
значением; после коммита кэш держит устаревшее до TTL (15 c).

Добавил post-commit-хук в executeTx: registerAfterCommit(trx, fn) регистрирует
side-effect, который дренится ТОЛЬКО после коммита транзакции — причём внешним
executeTx, владеющим trx (проброшенный existingTrx срабатывает на настоящей
границе коммита, а не во вложенном вызове). WeakMap по trx — без утечки.
bustWorkspaceCache теперь: без trx — del сразу (уже автокоммит); с trx —
регистрирует del на post-commit. Ошибка хука не валит уже закоммиченный write.

Тест: порядок body→commit→hook, дренаж на внешней границе, глушение падения хука.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 8d062728e5 fix(page): move-position — валидация charset вместо длины
MovePageDto.position — это fractional-indexing ключ (generateJitteredKeyBetween,
тот же генератор, что в page.service). @MinLength(5)/@MaxLength(12) не совпадали
с реальным диапазоном генератора: плотные between-вставки в глубоком дереве
растят ключ далеко за 12 символов (замерено >40), и валидный ключ, который
сервер сам сгенерил, отклонялся 400 (Gitea #139, п.6).

Теперь валидируем по charset — base-62 алфавит [0-9A-Za-z] — плюс щедрый
@MaxLength(256) как чистый DoS-guard, сильно выше любого реального ключа.
test.failing (bug-lock) распинен в обычный it; добавлен кейс на отклонение
символов вне алфавита (control/separator/инъекция/пустая строка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00