A share can now be published in 'approved' mode: public readers by URL see
the last manually-saved version (page_history.kind='manual') instead of the
live draft. The content swap lives in the single canonical resolver
resolveReadableSharePage, so EVERY public read path is frozen consistently —
the shared-page view, the SEO controller, AND the share assistant's page-read
tool (owner decision: a visitor and the assistant see the same bytes). Swaps
only content+title; page.id/workspaceId stay LIVE so attachment tokens keep
minting for the right owner. MVP residue kept live: the assistant's page
LIST/TREE tools and transclusions.
- migration 20260712T130000: shares.published_mode varchar(20) NOT NULL
DEFAULT 'live' (additive, standalone; existing shares stay live)
- db.d.ts: publishedMode on Shares (Share/Insertable/Updatable derive)
- page-history.repo: findLatestByPageIdAndKind(pageId, kind, {includeContent})
- share.repo: thread publishedMode through baseFields/insert/update
- share.service.getShareForPage: 4-point CTE threading (anchor + union +
returned object + repo allowlist) — type-guarded, so dropping any point
fails the build/tests
- createShare/updateShare: accept publishedMode; reject approved+includeSubPages
(BadRequestException); auto-mint the first manual baseline ON ENABLE
- share.dto: publishedMode @IsOptional @IsIn(['live','approved'])
- client: "Publish only the saved version" toggle (XOR with sub-pages),
version caption + "unsaved changes newer than the published version" hint;
share types/api carry publishedMode
- tests: resolve-swap (content+title swapped, id+workspaceId preserved),
assistant getSharePage sees frozen content, getShareForPage threading
(direct + descendant), baseline auto-create, XOR reject, migration up/down
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- search.types.ts: IPageSearch is the #529 superset (pageId/snippet/score/
path/matchedFields/matchedTerms; rank/highlight nullable); add the
IPageSearchResponse pagination envelope and match/limit/offset params.
- search-spotlight.tsx: operator hint ("exact phrase", +required, -excluded).
The web list already reads response.items, so it picks up the new OR order +
superset with no code change; icon/space/highlight remain (acceptance #12).
Full spotlight pagination UI is deferred within this PR — the API + MCP fully
support total/hasMore/offset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: pad-clip adjacent different-class sessions at the raw-gap midpoint so
workMs and agentOnlyMs can never double-count the same wall-clock (default
agentTGap 7m < pIn+pOut 10m made a work-session-ending-in-agent overlap a
nearby agent_only run). Reword the config guard/comment: cross-class
disjointness is now structural, tGap≥pIn+pOut is a kept sanity bound. Add a
cross-class no-double-count test and a real seeded property/fuzz test (250
random timelines × 4 tz) asserting per-class union, cross-class disjointness,
and Σ per-day activeMs == workMs.
F2: page.controller.spec — add a ForbiddenException view-gate reject test that
asserts the rejection propagates and computeWorkTime is NOT reached, locking
validateCanView before compute.
F3: WorkTimeStat renders for agent-only pages (workMs==0, agentOnlyMs>0) with
an `agent:` headline so the punch-card stays reachable.
F4: drop the dead un-bucketed `sessions` list from the PageWorkTime response
and the client work-time types (no client reads it; bucketByDay still consumes
the core sessions internally).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the inline ProseMirror comment-mark clear out of the Undo toast's
onClick and into the reopen mutation's onSuccess branch. Clearing it
eagerly flipped the collab mark to unresolved before the reopen result
was known: on a non-404 failure the RQ cache rolls back to resolved but
the doc kept an active highlight the panel treats as resolved, and a
comment refetch can't heal a divergence that lives in the collab doc.
Mirrors the 404 branch's editor-liveness guard/try-catch and the safe
await-then-mark pattern in comment-list-item. The button-triggered
reopen already sets the mark, so the onSuccess call is an idempotent
no-op there.
Tests: reopen failure (500) leaves the mark untouched; null editorRef
on the success path degrades gracefully.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Оценка времени работы над страницей как сессионизация истории page_history по
паузам бездействия (WakaTime-подобно), а не span между крайними правками, плюс
drill-down в суточный таймлайн 24ч×дни. Зависит от #374 (kind + idle-пульс).
Server:
- Чистая computeWorkTime(rows, config) (§5): нормализация+дедуп → коллапс
агентских всплесков по aiChatId → ОДИН проход сессионизации (порог зависит от
пары: оба agent → agentTGap, иначе tGap; последняя сессия обязательно
закрывается) → класс готовой сессии (все agent → agent_only, иначе work) →
добивка (много-сэмпл → [first−P_in, last+P_out], одиночный скаляр →
[t−P_single, t]) → метрики = union wall-clock по классу. Детерминированная,
без БД.
- Чистая bucketByDay(sessions, tz) (§6.3): union work/agent_only РАЗДЕЛЬНО, разрез
по полуночи tz через нативный Intl (DST-корректно, без «+24ч»). Инвариант
Σ activeMs == workMs держится по построению.
- PageHistoryRepo.findTimelineByPageId — лёгкая проекция без content, ASC.
- PageHistoryService.computeWorkTime + POST /pages/history/time, гейт
validateCanView как у /history; атрибуция человек/агент по lastUpdatedSource.
Client:
- usePageWorkTime(pageId) шлёт tz зрителя (Intl локаль).
- Кликабельное число «≈ 4 ч 30 мин» в шапке страницы (порог в тултипе).
- Модалка-punch-card: 24-часовые дорожки по дням, окна work/agent разным цветом,
одиночные (P_single) приглушены, сумма за день, пустой день «—», сворачивание
длинных серий пустых дней, подпись tz + T_gap. i18n ru-RU + en-US.
Tests: 22 юнита на computeWorkTime/bucketByDay (§7-фикстура ≈1ч32м vs ≈60h наив,
закрытие последней сессии, коллапс/разрыв всплеска, idle-пульс, DST 23/25ч,
полуночный разрез, инвариант, union-без-задвоения); 5 юнитов гейта контроллера;
int-spec на реальном pg (проекция + совпадение с ядром); 6 клиентских на формат.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part 1 (#542): add an inline Undo (reopen) button to the resolve success
toast in useResolveCommentMutation. Undo re-invokes the same mutation via a
self-ref (declared before useMutation, read at click time — no cycle) and
clears the inline comment mark through an editor ref (survives the originating
CommentListItem unmounting). A closured `done` flag guards a fast double-click
(notifications.hide is async). Reopen keeps the plain toast (no Undo).
onError gains a terminal 404 branch: drop the comment from the cache and
unsetComment its orphaned mark (no rollback → no phantom in Resolved, neutral
"Comment no longer exists"). The generic-failure copy is now directional
(resolve vs. re-open). Autoclose policy const RESOLVE_UNDO_AUTOCLOSE_MS=10000.
Part 2: sort the Resolved tab by resolvedAt DESC via an exported
sortResolvedByResolvedAt helper; coerce with new Date(...) because resolvedAt
is an ISO string at runtime (Date only in the optimistic window).
i18n: add "Failed to re-open comment" and "Comment no longer exists" to
en-US/ru-RU. Tests: 6 resolve-undo cases + 4 sort cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: интеграционный тест на reactive путь при storage, который ЧИТАЕТ, но не
ПИШЕТ (quota / Safari private): getItem→null (бюджет доступен, проходим
hasAutoReloaded), setItem→throw → markAutoReloaded()===false → guard на
chunk-load-error-boundary.tsx:43 обязан отбить reload, иначе реактивный путь
зациклил бы reload без сохранённого бюджета. Симметричный proactive случай уже
покрыт guarded-reload.test.tsx. Мутация (снять guard :43) → тест краснеет.
F2: формулировки "per session / this session" в version-coherence.ts и
guarded-reload.tsx приведены к оконной модели ("per RELOAD_WINDOW_MS window" /
"window budget available") — reload-guard.ts и тест восстановления после окна
уже описывают именно окно, а не постоянный one-shot на сессию.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
После ребейза на develop версии-когерентность (#481, общий one-shot флаг)
и chunk-load boundary (#495, оконный guard) были сведены к ОДНОМУ общему
оконному бюджету в @/lib/reload-guard: не более одного авто-reload за
RELOAD_WINDOW_MS суммарно по обоим путям, при этом второй деплой в той же
вкладке восстанавливается.
- reload-guard.test.ts: переписаны под оконную семантику (ключ chunk-reload-at
хранит таймстамп, а не флаг); сюда же перенесены чистые тесты shouldAutoReload.
- chunk-load-error-boundary.test.ts: убран импорт shouldAutoReload (переехал в
reload-guard).
- chunk-load-error-boundary.tsx: handleError экспортирован для теста инварианта.
- reload-budget.integration.test.tsx: инвариант (a) на РЕАЛЬНОМ guard — reload
на одном пути тратит общий бюджет для другого в пределах окна, после окна
восстановление, при недоступном sessionStorage ни один путь не перезагружает.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
r1-коммент утверждал «auto-reload on a hidden tab», но вариант C (r2) убрал
авто-reload скрытой вкладки ради защиты несохранённого ввода. Переписал под
фактическое поведение: баннер + отложенный reload на следующей in-app навигации.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Правки по ревью #499. Владелец выбрал вариант C по вопросу потери несохранённого
ввода.
F1 (reload по навигации): убрал ветку visibilitychange→reload И немедленный reload
скрытой-при-получении вкладки — visibility больше не влияет на авто-reload вообще.
На реальном mismatch: баннер (кнопка «Обновить» — немедленный reload под тем же
one-shot гардом) + модульный флаг pendingNavReload. Хук useVersionReloadOnNavigation
(useLocation + useEffect по location.key, пропуск первого рендера через useRef;
react-router v7 BrowserRouter компонентный, объекта роутера нет — подписка изнутри
дерева) на СЛЕДУЮЩЕЙ навигации зовёт consumeNavigationReload → performAutoReload
(mark-перед-reload, при spent/непишущемся storage — только баннер). Скрытая вкладка
идёт тем же путём (C единообразно). chunk-load-error-boundary reload не тронут —
остаётся бэкапом для не-навигирующей вкладки. Так reload в безопасной точке (юзер
и так уходит со страницы), а не когда свернул с недописанным комментарием.
F2 (лог-след): общий recordReloadBreadcrumb/takeReloadBreadcrumb (sessionStorage,
переживает reload). performAutoReload пишет breadcrumb {path:proactive,server,
client} + console.warn перед reload; chunk-boundary пишет {path:chunk-boundary};
surfacePreviousReloadBreadcrumb на маунте UserProvider логирует прошлый след.
F3 (CHANGELOG + AGENTS.md): user-facing запись про version-coherence + строка про
артефакт client/dist/version.json.
Тесты: vitest version-coherence+reload-guard+guarded-reload 23 (переписан на
MemoryRouter+useNavigate: reload РОВНО раз на первой навигации после mismatch, НЕ
на 2-й навигации, НЕ на 2-м mismatch one-shot, НЕ от ухода в фон; скрытая вкладка
— только на навигации; Update — сразу; banner-only/write-fail — никогда). Шире:
features/user 34.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SPA — долгоживущий клиент: вкладку держат часами, сервер за это время
передеплоивают. Старый index.html ссылается на content-hashed чанки, которых в
новом образе нет → ленивый import() → catch-all отдаёт index.html как text/html →
WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Реактивный
предохранитель (chunk-error-boundary) ловит УЖЕ случившуюся ошибку. Делаем сигнал
ПРОАКТИВНЫМ: сервер сообщает версию по существующему WS, клиент сравнивает и
делает guarded reload ДО битого чанка. closes#481
- Единый источник версии: vite.config.ts вычисляет resolveAppVersion РОВНО раз →
и в define.APP_VERSION, и в плагине, пишущем dist/version.json. Бандл и файл
тождественны by construction. Сервер читает client/dist/version.json при старте
(client-version.ts: resolveClientDistPath вынесен из static.module — единый
источник пути; readClientBuildVersion → версия или '' на любой ошибке,
fail-safe). НИКАКИХ Docker/ENV-изменений.
- WS: отдельное событие app-version {version} (НЕ в message/WebSocketEvent —
член без operation сломал бы union). ws.gateway читает версию в onModuleInit +
boot-лог ACTIVE/DISABLED; emit в handleConnection (success-ветка, после join).
ТОЛЬКО per-connect анонс, БЕЗ broadcast (broadcast в кластере → thundering-herd
reload флота; естественный реконнект покрывает и single-container, и кластер).
- Клиент: listener навешан СИНХРОННО в том же useEffect, что создаёт сокет (до
connect — иначе гонка с немедленным emit сервера). Чистая decideVersionAction
(reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка
triggerGuardedReload: модульный latch, hidden→немедленный reload, visible→баннер
+ reload-on-hidden {once}, фикс-id закрываемый баннер с кнопкой «Обновить».
- Общий one-shot флаг: reload-guard.ts (hasAutoReloaded/markAutoReloaded над
существующим chunk-reload-attempted); chunk-load-error-boundary переведён на
него. Проактивный + реактивный reload делят ОДИН счётчик → максимум одна
авто-перезагрузка за сессию суммарно; permanent skew / oscillation / disabled
storage → после первого reload только баннер, петли нет (проверено трассировкой).
Проверка: server jest client-version 7/7; client vitest version-coherence+
reload-guard+guarded-reload 16/16 (coverage 97.9%). Build-proof единого источника:
APP_VERSION=test-A → dist/version.json {"version":"test-A"} + вшито в бандл.
Полный staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб) — вне
автостенда, шаги приёмки #481 (крит.1-4) на ручную проверку.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Путь ответа ассистента теперь рендерится инкрементально, по образцу
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>
При живом обрыве SSE вход в лестницу reconnect происходил только
после резолва getRun(cid) (ре-сид из персиста). Путь REJECT
обрабатывался через .catch, но ЗАВИСШИЙ getRun (соединение есть,
ответа нет) был не ограничен: axios-клиент (lib/api-client.ts) не
имеет timeout, а stalled-idle-cap взводится только ПОСЛЕ входа в
reconnecting/polling. Итог — FSM залипала в `streaming` без баннера
и без поллинга до сокет-таймаута браузера (минуты). Это тот самый
класс тихого зависания, который устраняет эпик #497.
Оборачиваю ожидание getRun гонкой с таймаутом
(RECONNECT_RESEED_TIMEOUT_MS = 4s): по таймауту берётся ТОТ ЖЕ
фолбэк, что и на reject — dropLivePartialAndReplayFromStart() +
enterReconnect(runId), так что лестница/поллинг стартуют и
stalled-idle-cap взводится. Локальный флаг `settled` делает ветви
resolve/reject/timeout взаимоисключающими: поздний резолв getRun
после сработавшего таймаута полностью игнорируется (не входит в
reconnect повторно, не перетирает replay-from-start устаревшим
ре-сидом, ничего не перевзводит). Таймер живёт в reseedTimerRef и
очищается при размонтировании (никаких висящих setTimeout).
Тесты: hang-кейс (getRun не резолвится -> по таймауту FSM в
reconnecting, live-partial сброшен, replay-from-start) и
late-resolve safety (поздний резолв — no-op). Мутация: замена гонки
на голый getRun краснит оба #541-теста.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Ре-ревью нашло регрессию класса #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>
Переводит клиент на серверный 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>
Реестр ран-стримов больше не буферизует до 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>
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>
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат
рано или поздно упирается в контекстное окно и получает провайдерский 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>
Тост-уведомления 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>
`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>
Аспект 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>
Добавлены переводы для 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>
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>
При 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>
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>
Проба доступности алиаса возвращала currentPageId — id страницы, на которую
алиас уже указывает — ЛЮБОМУ аутентифицированному участнику воркспейса без
проверки прав на просмотр этой страницы. Перебором имён алиасов можно было
смапить их на id страниц, к которым доступа нет.
Теперь checkAvailability отдаёт только {alias, valid, available}. Бита
taken/free достаточно для пробы; заголовок целевой страницы всплывает лишь
ПОСЛЕ реальной попытки setAlias (путь 409 ALIAS_REASSIGN_REQUIRED), который
проверяет права. Клиент currentPageId нигде не использовал — убран из типа,
стейта и теста. Серверный спек утверждает отсутствие currentPageId в ответе.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Три хвоста детектора петель (#444):
- Пороги детектора дегенерации теперь конфигурируются из env (по образцу
AI_CHAT_FINAL_STEP_LOCKDOWN): AI_CHAT_DEGENERATION_REPEATED_LINES /
_PERIOD_MAX_LEN / _PERIOD_MIN_REPEATS / _CHECK_STEP. Резолвер читает сырую
строку (пусто = unset → компилируемый дефолт), требует ≥1, иначе безопасный
откат к дефолту (0/отриц. сломал бы детектор). Оператор перенастраивает
анти-babble-guard без редеплоя.
- Единый маркер: при дегенерации live-стрим показывает нейтральное «Response
stopped.» (клиент не отличает от ручного Stop), а персист-баннер после refetch
падал в дженерик. Классифицировал OUTPUT_DEGENERATION_ERROR на клиенте под тот
же заголовок «Response stopped.» + деталь про петлю — live и refetch больше не
расходятся.
- STEP_LIMIT_NO_ANSWER_MARKER: вместо хардкодной русской строки в content —
локаль-нейтральная английская (базовая локаль = i18n-ключ этого репо, читаема
моделью на реплее); не-русские юзеры больше не видят русский текст.
Тесты: env-пороги гоняются против реальных форм повторов с mutation-проверкой
(поднятый checkStep глушит burst; сниженный repeatedLines триггерит короткий
ран); клиентская классификация — новый кейс в error-message.test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 [BLOCKER] persistence-store.spec used Array.prototype.at(-1) (ES2022) but the
server targets ES2021, so server tsc failed (TS2550) and ts-jest could not
compile the suite — 22 core manual-save/idle/boundary tests silently did not run
in CI. Replaced with [length - 1] index access.
F2 [WARNING] The idle burst-reset used a hardcoded IDLE_MAX_WAIT_USER for both
tiers, but computeHistoryJob's ceiling is source-specific. On a continuously
agent-edited page the burst marker stayed stale for 5..10m, forcing delay=0 on
every store and writing one idle row per store — the exact per-store bloat the
debounce prevents. The reset now uses the same source-specific max-wait.
F3 [WARNING] The processor did an unlocked findPageLastHistory -> saveHistory,
which TOCTOU-races a concurrent manual-save (that runs under a page-row lock),
producing two page_history rows with identical content (one idle, one manual) and
defeating promote-not-dup. The snapshot decision is now wrapped in executeTx with
the same page-row lock, so the second writer observes the first's committed row
and the isDeepStrictEqual gate collapses the duplicate.
F4 [WARNING] The risky client filtered-index -> full-list mapping had no tests.
Extracted it to a pure resolvePrevSnapshotId(fullItems, id) helper (diff/restore
baseline against the true previous snapshot in the FULL list, never the previous
visible version) and unit-tested it; removed the now-vestigial index threading.
F5/F6 [low] Renamed the misleading ceiling test + fixed its comment; added a
CHANGELOG entry for the user-facing versioning feature.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR-1 'core' of #370: introduces page_history.kind ('manual'|'agent'|'idle'|
'boundary'; legacy null = autosave) and rebuilds the snapshot triggers around a
three-tier intentionality model. Draft durability (pages/ydoc hocuspocus
autosave) is unchanged; only the frequency and labelling of history points change.
- Migration 20260705T120000: page_history.kind nullable varchar(20), no default.
- Manual Save: one stateless 'save-version' path for human AND agent; kind is
derived SERVER-SIDE from the signed context.actor (never the payload), readOnly
connections rejected, the fresh ydoc runs through the existing store path (no
REST race), then broadcasts version.saved.
- Idle-flush: trailing debounce (one BullMQ job per page, remove-then-readd) with
IDLE_INTERVAL_USER=60m / AGENT=15m AND a max-wait ceiling
(IDLE_MAX_WAIT_USER=10m / AGENT=5m) so a continuous editing session can't starve
the autosnapshot (review round-1 WARNING).
- Boundary: generalized from the user→agent special-case to ANY lastUpdatedSource
transition (user↔agent↔git), same isDeepStrictEqual gate — covers git-sync free.
- Removed the agent delay=0 fast path and the old HISTORY_FAST_* constants; the
agent joins the common idle pipeline.
- Promote-not-dup: a manual save on unchanged content promotes the latest
autosave's kind in place (or no-ops if already manual) instead of duplicating a
heavy content row.
- Client: mod+S hotkey + menu button (hidden when readOnly), history-panel kind
badges, dimmed autosaves, a 'versions only' filter (indices map to the full
list so diff/restore still target the true previous snapshot), live refresh on
version.saved.
Internal review: APPROVE-with-suggestions; the round-1 WARNING (idle starvation)
is fixed here via the max-wait ceiling, and the generalized-boundary + ceiling
behaviours are pinned with new tests (115 collab/repo specs green, server tsc 0).
Deferred to later PRs: shares.published_mode (PR-2), the save_page_version MCP
tool + role prompts (PR-3), actor='git' wiring into #359 (PR-4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревью волны 1 (agent_vscode): сервер эмитит id текущего рана в activeRunId
на обоих 409-бранчах (SUPERSEDE_TARGET_MISMATCH, A_RUN_ALREADY_ACTIVE), а
клиентский read409 читал runId → SUPERSEDE_MISMATCH{currentRunId} всегда
undefined: быстрый supersede-хинт мёртв в проде, а клиентские тесты
ложно-зелёные (мокали поле runId, которого сервер не шлёт).
- read409 читает activeRunId (undefined-safe); оба мока — на реальную форму
+ ассерт на усвоенный currentRunId (mutation: revert→runId краснит тесты).
- S4 (groundwork): activeRunId из A_RUN_ALREADY_ACTIVE поглощается в runFact
(to(), без бампа эпохи/ownership — инварианты #488 целы). Полная обвязка
supersede чужой вкладки из фазы error требует расширения gate в sendNow —
отдельным follow-up; сейчас это безопасная заготовка, не рабочая фича.
- spec.md: 2 строки контракт-таблицы приведены к activeRunId.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>