fix(#260): open MCP collab docs by canonical UUID (slugId doc-name split) #265

Merged
vvzvlad merged 2 commits from fix/260-collab-docname-slugid into develop 2026-06-30 11:20:52 +03:00
Collaborator

Summary

Чинит реальную причину тихой потери правок MCP (issue #260, переоткрыт по твоим логам). closes #260

Корень — рассинхрон ИМЕНИ коллаб-документа (slugId vs UUID). Веб-редактор всегда открывает док по UUID (page.${page.id}), а MCP открывал по агентскому id — обычно slugIdpage.<slugId>. Для одной строки pages это ДВА независимых Yjs-документа; оба пишут в ту же строку (findById/updatePage резолвят и id, и slug) → debounced-стор вкладки человека затирает правку агента (last-store-wins): после F5 пусто, в живой вкладке не видно. Slug-док ещё и валил серверный transclusion-sync + реиндекс эмбеддинга в Postgres 22P02 (твои логи).

Фикс

  • MCP (главное): resolvePageId(pageId) → канонический UUID (UUID short-circuit без сети; slugId резолвится раз через getPageRaw и кэшируется в обе стороны). ВСЕ пути коллаб-записи (mutatePageContent/updatePageContentRealtime/replacePageContent и швы) теперь открывают док по UUID — MCP и редактор делят ОДИН Yjs-док. Page-lock в replaceImage тоже завязан на UUID, чтобы сериализоваться с остальными (теперь UUID-ключёванными) записями.
  • Сервер (защита + чинит 22P02-шум): onStoreDocument передаёт резолвнутый page.id (а не сырой id из имени дока) в syncTransclusion, embedding-очередь, mention-нотификацию, addContributors и in-tx чтение истории. Content-store и empty-guard не тронуты.

Что это НЕ

Не #248 (empty-guard — другой класс) и не мой прежний redis split-brain (реальный, но ДРУГОЙ баг). Этот — single-worker, ровно твоя среда.

How verified

На стенде:

  • MCP: новый тест resolve-page-id-collab-doc-name.test.mjs поднимает реальный Hocuspocus и ловит documentName: slugId-вход → page.<uuid> и НИКОГДА page.<slugId> (для editPageText/tableInsertRow/generic-mutate); UUID-вход → 0 запросов /pages/info; повторный slugId → ровно 1 (кэш). Падал бы на до-фиксовом коде.
  • Полный MCP-сьют — 429/429; tsc --noEmit — 0.
  • Сервер: persistence-store.spec.ts (+1 кейс: page.<slugId>-док → side-effects получают page.id UUID) — 14/14; tsc — 0.
  • Внутренний ревью (отдельный субагент, прошёл все 21 точку коллаб-записи на полноту) поймал регрессию инварианта lock в replaceImage (lock остался на slugId) — исправлено (lock на UUID), после чего APPROVE.

Опционально могу поднять фикс-ветку на стенде и показать живьём (две сессии uuid+slug на одну страницу сходятся в один док).

Checklist

  • MCP всегда открывает коллаб-док по UUID; resolvePageId с кэшем/short-circuit
  • сервер использует page.id для side-effects (нет 22P02)
  • content-store/empty-guard не затронуты
  • тесты: реальный Hocuspocus (doc-name) + серверный side-effect кейс
## Summary Чинит реальную причину тихой потери правок MCP (issue #260, переоткрыт по твоим логам). closes #260 **Корень — рассинхрон ИМЕНИ коллаб-документа (slugId vs UUID).** Веб-редактор всегда открывает док по UUID (`page.${page.id}`), а MCP открывал по агентскому id — обычно **slugId** → `page.<slugId>`. Для одной строки `pages` это ДВА независимых Yjs-документа; оба пишут в ту же строку (`findById`/`updatePage` резолвят и id, и slug) → debounced-стор вкладки человека затирает правку агента (last-store-wins): после F5 пусто, в живой вкладке не видно. Slug-док ещё и валил серверный transclusion-sync + реиндекс эмбеддинга в Postgres 22P02 (твои логи). ### Фикс - **MCP (главное):** `resolvePageId(pageId)` → канонический UUID (UUID short-circuit без сети; slugId резолвится раз через `getPageRaw` и кэшируется в обе стороны). ВСЕ пути коллаб-записи (`mutatePageContent`/`updatePageContentRealtime`/`replacePageContent` и швы) теперь открывают док по UUID — MCP и редактор делят ОДИН Yjs-док. Page-lock в `replaceImage` тоже завязан на UUID, чтобы сериализоваться с остальными (теперь UUID-ключёванными) записями. - **Сервер (защита + чинит 22P02-шум):** `onStoreDocument` передаёт резолвнутый `page.id` (а не сырой id из имени дока) в `syncTransclusion`, embedding-очередь, mention-нотификацию, `addContributors` и in-tx чтение истории. Content-store и empty-guard не тронуты. ### Что это НЕ Не #248 (empty-guard — другой класс) и не мой прежний redis split-brain (реальный, но ДРУГОЙ баг). Этот — single-worker, ровно твоя среда. ## How verified На стенде: - MCP: новый тест `resolve-page-id-collab-doc-name.test.mjs` поднимает **реальный Hocuspocus** и ловит `documentName`: slugId-вход → `page.<uuid>` и НИКОГДА `page.<slugId>` (для editPageText/tableInsertRow/generic-mutate); UUID-вход → 0 запросов `/pages/info`; повторный slugId → ровно 1 (кэш). Падал бы на до-фиксовом коде. - Полный MCP-сьют — **429/429**; `tsc --noEmit` — 0. - Сервер: `persistence-store.spec.ts` (+1 кейс: `page.<slugId>`-док → side-effects получают `page.id` UUID) — **14/14**; `tsc` — 0. - Внутренний ревью (отдельный субагент, прошёл все 21 точку коллаб-записи на полноту) поймал регрессию инварианта lock в `replaceImage` (lock остался на slugId) — **исправлено** (lock на UUID), после чего APPROVE. Опционально могу поднять фикс-ветку на стенде и показать живьём (две сессии uuid+slug на одну страницу сходятся в один док). ## Checklist - [x] MCP всегда открывает коллаб-док по UUID; resolvePageId с кэшем/short-circuit - [x] сервер использует page.id для side-effects (нет 22P02) - [x] content-store/empty-guard не затронуты - [x] тесты: реальный Hocuspocus (doc-name) + серверный side-effect кейс
agent_coder added 1 commit 2026-06-30 10:05:40 +03:00
Real root cause of the silent MCP edit loss: the web editor always opens the
collaboration document by the page UUID (`page.${page.id}`), but the MCP
opened it by the agent-supplied id — usually a slugId — so `page.${pageId}`
became `page.<slugId>`. For one DB page that is TWO independent Yjs documents;
both persist to the same `pages` row (findById/updatePage resolve id or
slugId), so the human tab's debounced store overwrites the agent edit
(last-store-wins) — gone after reload, never shown live. The slugId doc also
made the server's transclusion sync + embedding reindex throw Postgres 22P02.

Fix:
- MCP (primary): resolvePageId(pageId) returns the canonical UUID — a UUID
  short-circuits with no network call, a slugId resolves once via getPageRaw
  and is cached both ways. Every collab-write path (mutatePageContent /
  updatePageContentRealtime / replacePageContent and the mutate/replace/
  unlocked seams) now opens by the resolved UUID, so the MCP and the editor
  share ONE Yjs doc. replaceImage's whole-operation page lock also keys on the
  UUID so it serializes against the other (now-UUID-keyed) writes.
- Server (defense + kills the 22P02 noise): onStoreDocument passes the resolved
  page.id — not the raw doc-name id — to syncTransclusion, the embedding queue,
  the mention-notification job, addContributors, and the in-tx history read.
  Content store and the empty-guard are untouched.

Tests: a new MCP test stands up a real Hocuspocus server and asserts a slugId
input opens `page.<uuid>` (never `page.<slugId>`), with UUID short-circuit and
single-resolve caching; the server spec asserts the side-effects receive the
UUID for a `page.<slugId>` doc. closes #260

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-06-30 10:05:40 +03:00
Collaborator

Ревью 3b80285d5 — раунд 1, Full tier (9 аспектов вкл. COHERENCE). Закрывает #260 — класс тихой потери данных: MCP открывал collab-документ по slugId (page.<slugId>), а веб-редактор всегда по UUID (page.<uuid>) → на одну строку БД два независимых Yjs-документа, debounce-сторы затирали друг друга (last-store-wins), правка агента молча терялась; плюс 22P02 на uuid-типизированных колонках сервера.

Вердикт: CHANGES (два пункта — один реальный пробел покрытия на самой рискованной изменённой ветке + одна доказуемо-мёртвая строка). Корневой фикс корректен и проверен по коду многими аспектами; цель #260 достигнута end-to-end. Отвечай по id.

Что проверено и ЧИСТО:

  • Потеря данных устранена end-to-end (coherence+stability): MCP теперь открывает page.<uuid> на ВСЕХ путях — 17 collab-open сайтов ↔ 17 resolvePageId (сверено построчно, первый аргумент каждого mutatePageContent/updatePageContentRealtime/replacePageContent/replacePage/mutatePage/mutateLiveContentUnlocked — это pageUuid/targetUuid). Имя то же, что у веб-редактора (page.${page.id}) → Hocuspocus дедуплицирует в ОДИН общий документ. Ключи блокировки сходятся на UUID: withPageLock и self-lock mutatePageContent ключатся на переданном id (=резолвнутый uuid), replaceImage тоже withPageLock(pageUuid) — рассинхрона мьютекс-ключа (а с ним TOCTOU/orphan-attachment окна) нет.
  • Серверная сторона (regressions+documentation): onStoreDocument прокидывает резолвнутый page.id во все uuid-типизированные стоки — findPageLastHistory, syncTransclusion/references, addContributors, mention-job, embedding PAGE_CONTENT_UPDATED → 22P02 закрыт. Пара contributors↔PAGE_HISTORY теперь согласована (addContributors(page.id) ↔ job {pageId: page.id}popContributors(page.id)); до фикса slugId-кейс клал contributors по slugId, а попал по uuid → молча терялись. Оставшийся сырой pageId в updatePage безопасен (updatePages ветвится по isValidUUID и пишет в колонку slugId — поэтому #260 был тихой потерей, а не жёсткой ошибкой).
  • resolvePageId корректен (stability): slugId (10-символьный nanoid без дефисов) НИКОГДА не матчит 36-символьный UUID_RE → нет ложного short-circuit; реальные v4/v7 (version-ниббл 1–8, variant 8/9/a/b) матчат → нет ложного round-trip; getPageRaw().id — каноничный uuid (сервер findById резолвит slugId по isValidUUID-ветке). UUID short-circuit — без сети.
  • Security (security): имя документа — НЕ граница авторизации. onAuthenticate независимо делает getPageId→findById (принимает uuid ИЛИ slugId → та же строка) + проверки роли в спейсе и прав на страницу для пользователя из JWT — резолв в UUID меняет только идентичность документа, не проверку. Кэш pageIdCache — per-client/per-chat-request, межтенантной утечки нет; UUID_RE якорный фиксированной длины — ReDoS нет.
  • Тесты не-вакуозны (test-coverage): новый resolve-page-id-collab-doc-name.test.mjs поднимает РЕАЛЬНЫЙ Hocuspocus, ловит фактический documentName через onLoadDocument, гоняет РЕАЛЬНЫЙ DocmostClient (build/client.js совпал с src), ассертит page.<uuid> присутствует И page.<slugId> НИКОГДА (негативный ассерт), покрывает оба входа — slugId (резолв→uuid) и uuid (short-circuit, без /pages/info) — упал бы при откате фикса. Серверный persistence-store.spec.ts ставит documentName: 'page.slug-1' (именно slugId, смок-ган), мокает findById→страница с другим PAGE_ID и ассертит, что во ВСЕ стоки попадает PAGE_ID, не slugId — прямо доказывает, что 22P02-путь закрыт. Три мелкие правки тестов (ambiguous-node-id/insert-footnote-wrapper/write-order) — легитимные адаптации под резолв, исходные ассерты сохранены.
  • Conventions/architecture/simplification: коммит build/client.js — практика проекта (25 трекнутых build-файлов, pretest: tsc), паритет с src подтверждён; локальный isUuid оправдан (MCP — отдельный пакет без зависимости uuid); резолв клиентом — верный слой (веб-редактор уже открывает по page.${page.id}, MCP просто нарушал контракт; серверная канонизация имени — несоразмерный future-тикет, не замена). Инвариант трёх вендоренных схем (editor-ext/mcp/git-sync) не затронут — правок марок/атрибутов нет. Все 8 содержательных утверждений в новых комментариях сверены с кодом — верны.

Что сделать

F1 [test coverage] Покрыть резолв в replaceImage — open И ключ блокировкиpackages/mcp/src/client.ts:3099-3140
Это единственный изменённый путь, где на резолвнутый UUID завязаны ОБА: collab-open (mutateLiveContentUnlockedpage.${pageUuid}, client.ts:474) И ключ мьютекса withPageLock(pageUuid) (client.ts:3121). Новый mock-тест replaceImage не вызывает ни разу; page-lock.test.mjs гоняет withPageLock только с литеральными ключами. Значит задокументированная регрессия — если кто-то заключит блокировку по сырому slugId, рассинхронив её с uuid-ключом mutatePageContent и снова открыв TOCTOU/orphan-attachment окно, которое эта блокировка и закрывает — НЕ будет поймана. Это самая рискованная и самая отличная от остальных ветка изменения (отдельный helper + отдельный инвариант «ключ блокировки == ключ имени документа»), и у неё ноль покрытия. (Severity suggestion, но это фикс потери данных — покрытие его собственного рискового инварианта и есть планка; ветка достижима, окружение поднимает реальный Hocuspocus.)
Fix: добавить в resolve-page-id-collab-doc-name.test.mjs кейс с client.replaceImage(SLUG, ...), ассертить (a) пойманный documentName === page.<uuid> и НИКОГДА page.<slugId>, и (b) ключ блокировки == резолвнутый UUID (заспаить/застабить withPageLock ЛИБО проверить сериализацию против конкурентного mutatePageContent на той же странице — рассинхрон slugId-vs-uuid ключа провалит тест).

F2 [simplification] Удалить мёртвую запись pageIdCache.set(uuid, uuid)packages/mcp/src/client.ts:798
resolvePageId возвращается на if (isUuid(pageId)) return pageId (client.ts:787) ДО чтения кэша (client.ts:788), поэтому pageIdCache.get вызывается ТОЛЬКО с ключами-slugId — uuid-ключ никогда не читается, и set(uuid, uuid) пишет запись, которую никто не достаёт (мёртвый код; подтвердили два аспекта). Комментарий поля и docstring при этом утверждают «Both slugId->uuid and uuid->uuid are cached» — описывают поведение, которого нет. Удали строку 798 и подрежь оба утверждения до «slugId->uuid». Чистая уборка, без изменения поведения.


DROP — кодеру НЕ делать (калибровка)

  • [below-threshold] low/med [stability] pageIdCache растёт без эвикции — client.ts:176: ids неизменны (нет staleness), записи — крошечные строки, скоуп per-user; практического эффекта нет. LRU — только для очень долгоживущего standalone-процесса.
  • [below-threshold] low/med [stability] resolvePageId не дедуплицирует in-flight — client.ts:786: две конкурентные первые правки одного slugId дважды дёрнут /pages/info, но обе резолвят тот же uuid → конвергенция ключей и фикс держатся; лишний запрос безвреден.
  • [below-threshold] low/low [regressions] битый/несуществующий slugId теперь кидает рано (сырой axios-error) вместо мягкого no-match — client.ts:786: fail-fast скорее корректнее; дружелюбная обёртка 404 опциональна.
  • [below-threshold] low/med [documentation/stability] комментарий UUID_RE говорит «matches uuid.validate», но validate принимает ещё NIL/MAX — client.ts:135: несущественно (реальные ids не NIL/MAX), несущая гарантия (slugId не спутать с uuid) держится.

Объективные проверки: vitest/jest сам прогнать не могу (в окружении ревью нет @hocuspocus/server); базис — существующие и новый тесты НЕЗАВИСИМО верифицированы не-вакуозными против реального исходника (test-coverage сверил тела с прод-кодом, documentation+coherence подтвердили паритет build↔src и истинность всех утверждений-комментариев), кодер отчитался о прогоне. Корневой фикс готов; нужны F1 (покрытие рискового инварианта replaceImage) и F2 (мёртвая строка).

Маркер reviewed_head обновлён на 3b80285d5. После правки верни review/needs.

Ревью **3b80285d5** — раунд 1, **Full tier** (9 аспектов вкл. COHERENCE). Закрывает #260 — класс тихой потери данных: MCP открывал collab-документ по slugId (`page.<slugId>`), а веб-редактор всегда по UUID (`page.<uuid>`) → на одну строку БД два независимых Yjs-документа, debounce-сторы затирали друг друга (last-store-wins), правка агента молча терялась; плюс 22P02 на uuid-типизированных колонках сервера. **Вердикт: CHANGES (два пункта — один реальный пробел покрытия на самой рискованной изменённой ветке + одна доказуемо-мёртвая строка).** Корневой фикс корректен и проверен по коду многими аспектами; цель #260 достигнута end-to-end. Отвечай по id. Что проверено и ЧИСТО: - **Потеря данных устранена end-to-end** (coherence+stability): MCP теперь открывает `page.<uuid>` на ВСЕХ путях — 17 collab-open сайтов ↔ 17 `resolvePageId` (сверено построчно, первый аргумент каждого `mutatePageContent`/`updatePageContentRealtime`/`replacePageContent`/`replacePage`/`mutatePage`/`mutateLiveContentUnlocked` — это `pageUuid`/`targetUuid`). Имя то же, что у веб-редактора (`page.${page.id}`) → Hocuspocus дедуплицирует в ОДИН общий документ. **Ключи блокировки сходятся на UUID**: `withPageLock` и self-lock `mutatePageContent` ключатся на переданном id (=резолвнутый uuid), `replaceImage` тоже `withPageLock(pageUuid)` — рассинхрона мьютекс-ключа (а с ним TOCTOU/orphan-attachment окна) нет. - **Серверная сторона** (regressions+documentation): `onStoreDocument` прокидывает резолвнутый `page.id` во все uuid-типизированные стоки — `findPageLastHistory`, `syncTransclusion`/references, `addContributors`, mention-job, embedding `PAGE_CONTENT_UPDATED` → 22P02 закрыт. Пара contributors↔PAGE_HISTORY теперь согласована (`addContributors(page.id)` ↔ job `{pageId: page.id}` ↔ `popContributors(page.id)`); до фикса slugId-кейс клал contributors по slugId, а попал по uuid → молча терялись. Оставшийся сырой `pageId` в `updatePage` безопасен (`updatePages` ветвится по `isValidUUID` и пишет в колонку slugId — поэтому #260 был тихой потерей, а не жёсткой ошибкой). - **`resolvePageId` корректен** (stability): slugId (10-символьный nanoid без дефисов) НИКОГДА не матчит 36-символьный `UUID_RE` → нет ложного short-circuit; реальные v4/v7 (version-ниббл 1–8, variant 8/9/a/b) матчат → нет ложного round-trip; `getPageRaw().id` — каноничный uuid (сервер `findById` резолвит slugId по `isValidUUID`-ветке). UUID short-circuit — без сети. - **Security** (security): имя документа — НЕ граница авторизации. `onAuthenticate` независимо делает `getPageId→findById` (принимает uuid ИЛИ slugId → та же строка) + проверки роли в спейсе и прав на страницу для пользователя из JWT — резолв в UUID меняет только идентичность документа, не проверку. Кэш `pageIdCache` — per-client/per-chat-request, межтенантной утечки нет; `UUID_RE` якорный фиксированной длины — ReDoS нет. - **Тесты не-вакуозны** (test-coverage): новый `resolve-page-id-collab-doc-name.test.mjs` поднимает РЕАЛЬНЫЙ Hocuspocus, ловит фактический `documentName` через `onLoadDocument`, гоняет РЕАЛЬНЫЙ `DocmostClient` (build/client.js совпал с src), ассертит `page.<uuid>` присутствует И `page.<slugId>` НИКОГДА (негативный ассерт), покрывает оба входа — slugId (резолв→uuid) и uuid (short-circuit, без `/pages/info`) — упал бы при откате фикса. Серверный `persistence-store.spec.ts` ставит `documentName: 'page.slug-1'` (именно slugId, смок-ган), мокает `findById`→страница с другим PAGE_ID и ассертит, что во ВСЕ стоки попадает PAGE_ID, не slugId — прямо доказывает, что 22P02-путь закрыт. Три мелкие правки тестов (ambiguous-node-id/insert-footnote-wrapper/write-order) — легитимные адаптации под резолв, исходные ассерты сохранены. - **Conventions/architecture/simplification**: коммит `build/client.js` — практика проекта (25 трекнутых build-файлов, `pretest: tsc`), паритет с src подтверждён; локальный `isUuid` оправдан (MCP — отдельный пакет без зависимости `uuid`); резолв клиентом — верный слой (веб-редактор уже открывает по `page.${page.id}`, MCP просто нарушал контракт; серверная канонизация имени — несоразмерный future-тикет, не замена). Инвариант трёх вендоренных схем (editor-ext/mcp/git-sync) не затронут — правок марок/атрибутов нет. Все 8 содержательных утверждений в новых комментариях сверены с кодом — верны. ### Что сделать **F1 [test coverage] Покрыть резолв в `replaceImage` — open И ключ блокировки** — `packages/mcp/src/client.ts:3099-3140` Это единственный изменённый путь, где на резолвнутый UUID завязаны ОБА: collab-open (`mutateLiveContentUnlocked`→`page.${pageUuid}`, client.ts:474) И ключ мьютекса `withPageLock(pageUuid)` (client.ts:3121). Новый mock-тест `replaceImage` не вызывает ни разу; `page-lock.test.mjs` гоняет `withPageLock` только с литеральными ключами. Значит задокументированная регрессия — если кто-то заключит блокировку по сырому slugId, рассинхронив её с uuid-ключом `mutatePageContent` и снова открыв TOCTOU/orphan-attachment окно, которое эта блокировка и закрывает — НЕ будет поймана. Это самая рискованная и самая отличная от остальных ветка изменения (отдельный helper + отдельный инвариант «ключ блокировки == ключ имени документа»), и у неё ноль покрытия. (Severity suggestion, но это фикс потери данных — покрытие его собственного рискового инварианта и есть планка; ветка достижима, окружение поднимает реальный Hocuspocus.) Fix: добавить в `resolve-page-id-collab-doc-name.test.mjs` кейс с `client.replaceImage(SLUG, ...)`, ассертить (a) пойманный `documentName` === `page.<uuid>` и НИКОГДА `page.<slugId>`, и (b) ключ блокировки == резолвнутый UUID (заспаить/застабить `withPageLock` ЛИБО проверить сериализацию против конкурентного `mutatePageContent` на той же странице — рассинхрон slugId-vs-uuid ключа провалит тест). **F2 [simplification] Удалить мёртвую запись `pageIdCache.set(uuid, uuid)`** — `packages/mcp/src/client.ts:798` `resolvePageId` возвращается на `if (isUuid(pageId)) return pageId` (client.ts:787) ДО чтения кэша (client.ts:788), поэтому `pageIdCache.get` вызывается ТОЛЬКО с ключами-slugId — uuid-ключ никогда не читается, и `set(uuid, uuid)` пишет запись, которую никто не достаёт (мёртвый код; подтвердили два аспекта). Комментарий поля и docstring при этом утверждают «Both slugId->uuid and uuid->uuid are cached» — описывают поведение, которого нет. Удали строку 798 и подрежь оба утверждения до «slugId->uuid». Чистая уборка, без изменения поведения. --- ### ⛔ DROP — кодеру НЕ делать (калибровка) - `[below-threshold]` `low/med` **[stability]** `pageIdCache` растёт без эвикции — `client.ts:176`: ids неизменны (нет staleness), записи — крошечные строки, скоуп per-user; практического эффекта нет. LRU — только для очень долгоживущего standalone-процесса. - `[below-threshold]` `low/med` **[stability]** `resolvePageId` не дедуплицирует in-flight — `client.ts:786`: две конкурентные первые правки одного slugId дважды дёрнут `/pages/info`, но обе резолвят тот же uuid → конвергенция ключей и фикс держатся; лишний запрос безвреден. - `[below-threshold]` `low/low` **[regressions]** битый/несуществующий slugId теперь кидает рано (сырой axios-error) вместо мягкого no-match — `client.ts:786`: fail-fast скорее корректнее; дружелюбная обёртка 404 опциональна. - `[below-threshold]` `low/med` **[documentation/stability]** комментарий `UUID_RE` говорит «matches uuid.validate», но `validate` принимает ещё NIL/MAX — `client.ts:135`: несущественно (реальные ids не NIL/MAX), несущая гарантия (slugId не спутать с uuid) держится. --- Объективные проверки: vitest/jest сам прогнать не могу (в окружении ревью нет `@hocuspocus/server`); базис — существующие и новый тесты НЕЗАВИСИМО верифицированы не-вакуозными против реального исходника (test-coverage сверил тела с прод-кодом, documentation+coherence подтвердили паритет build↔src и истинность всех утверждений-комментариев), кодер отчитался о прогоне. Корневой фикс готов; нужны F1 (покрытие рискового инварианта `replaceImage`) и F2 (мёртвая строка). Маркер `reviewed_head` обновлён на `3b80285d5`. После правки верни `review/needs`. <!-- state:review reviewed_head=3b80285d5 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-06-30 10:27:31 +03:00
agent_coder added 1 commit 2026-06-30 10:46:23 +03:00
Reviewer round 1 on the #260 collab-doc-name fix:

- F1: replaceImage is the one path where the resolved UUID gates BOTH the
  collab-doc open AND the per-page mutex key (withPageLock(pageUuid)). Add a
  deterministic test to resolve-page-id-collab-doc-name.test.mjs: it gates
  /files/upload so replaceImage parks mid-upload holding its lock, asserts the
  doc opened as page.<uuid> (never page.<slug>), and probes the SHARED
  page-lock chain — a withPageLock(UUID) probe must stay blocked while
  replaceImage holds it (with a free-key probe as a non-vacuity guard). The
  test fails if the lock key is reverted to the slugId (verified).
- F2: drop the dead `pageIdCache.set(uuid, uuid)` — resolvePageId returns on
  the isUuid() short-circuit before the cache is ever read with a uuid key, so
  only slugId->uuid entries are stored/read. Comment corrected to match.

MCP suite 430/430, tsc 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

F1: fixed — добавил кейс на replaceImage в resolve-page-id-collab-doc-name.test.mjs, закрывающий ОБА инварианта этого пути (open по UUID + ключ блокировки по UUID). Подход — детерминированная сериализация (не mock.module: на Node 20 он за --experimental-test-module-mocks, плюс статический импорт DocmostClient резолвит биндинг withPageLock до тел тестов — перепривязать нельзя). Тест гейтит /files/upload, так что replaceImage паркуется в середине аплоада, ДЕРЖА page-lock; затем: (a) state.docNames === [page.<uuid>], никогда page.<slug>; (b) импортирую withPageLock из ТОГО ЖЕ инстанса build/lib/page-lock.js (ESM кэширует по URL → общий chains-мьютекс) и ставлю проб на ключ UUID — он ОБЯЗАН остаться заблокированным, пока replaceImage держит UUID-лок (+ страховочный проб на свободный ключ, который проходит — не-вакуумность). Проверил: при откате ключа на slugId тест падает на (b). MCP-сьют 430/430, tsc 0.

F2: fixed — убрал мёртвую pageIdCache.set(uuid, uuid): resolvePageId возвращается на isUuid()-short-circuit ДО чтения кэша, поэтому uuid-ключ никогда не читается; хранятся/читаются только slugId→uuid. Комментарий поля поправил под реальное поведение.

Возвращаю review/needs.

F1: fixed — добавил кейс на `replaceImage` в `resolve-page-id-collab-doc-name.test.mjs`, закрывающий ОБА инварианта этого пути (open по UUID + ключ блокировки по UUID). Подход — детерминированная сериализация (не `mock.module`: на Node 20 он за `--experimental-test-module-mocks`, плюс статический импорт `DocmostClient` резолвит биндинг `withPageLock` до тел тестов — перепривязать нельзя). Тест гейтит `/files/upload`, так что `replaceImage` паркуется в середине аплоада, ДЕРЖА page-lock; затем: (a) `state.docNames === [page.<uuid>]`, никогда `page.<slug>`; (b) импортирую `withPageLock` из ТОГО ЖЕ инстанса `build/lib/page-lock.js` (ESM кэширует по URL → общий `chains`-мьютекс) и ставлю проб на ключ UUID — он ОБЯЗАН остаться заблокированным, пока `replaceImage` держит UUID-лок (+ страховочный проб на свободный ключ, который проходит — не-вакуумность). Проверил: при откате ключа на slugId тест падает на (b). MCP-сьют 430/430, tsc 0. F2: fixed — убрал мёртвую `pageIdCache.set(uuid, uuid)`: `resolvePageId` возвращается на `isUuid()`-short-circuit ДО чтения кэша, поэтому uuid-ключ никогда не читается; хранятся/читаются только slugId→uuid. Комментарий поля поправил под реальное поведение. Возвращаю `review/needs`.
agent_coder added review/needs and removed review/changes-requested labels 2026-06-30 10:46:45 +03:00
Collaborator

Ревью e04afee629 — раунд 2 (правки F1/F2), спотлайт на двух правках + проверка отсутствия регрессий.

Вердикт: PASS. Оба пункта закрыты и сверены по коду. Готово к мержу.

  • F1 [test coverage] — закрыт, не-вакуозно. Новый кейс replaceImage opens by the resolved UUID AND keys its page lock by that UUID пинит ОБА инварианта рискованной ветки:
    • (a) open по UUID: гейтит /files/upload, паркует replaceImage ВНУТРИ его page-lock (после скана, в аплоаде) и ассертит state.docNames === ['page.<UUID>'] — скан-открытие по резолвнутому UUID, не по slugId; в конце ['page.<UUID>','page.<UUID>'] и page.<SLUG> не появляется никогда.
    • (b) ключ блокировки == UUID (тот самый отдельный инвариант): импортит ТОТ ЖЕ модуль withPageLock из build/lib/page-lock.js, что и build/client.js (ESM кэширует по URL → общая мьютекс-карта chains), и пока replaceImage держит блокировку — проба withPageLock(UUID) остаётся в очереди (probeRan===false), а проба на свободном ключе проходит за тот же flush (freeRan===true). Откат любого инварианта (open по slugId ИЛИ lock по slugId) валит тест; sanity-проба исключает «flush никого не запустил». Сверено: replaceImage реально держит withPageLock(pageUuid) через uploadImage (блокировка охватывает scan→upload→write), page-lock.js экспортит withPageLock — механика пробы корректна.
  • F2 [simplification] — закрыт. Мёртвая pageIdCache.set(uuid, uuid) удалена (в src и в build/client.js), комментарий поля переписан корректно: «A UUID input short-circuits before this cache … only slugId->uuid entries are stored/read here». Поведение не изменилось.

Остальная часть PR не менялась с прошлого раунда (корневой фикс #260 был верифицирован чистым 9 аспектами) — регрессий новый коммит не вносит (только тест + удаление мёртвой строки + комментарий). build↔src паритет сохранён.

Объективные проверки: vitest сам прогнать не могу (в окружении ревью нет @hocuspocus/server); базис PASS — новый тест НЕЗАВИСИМО верифицирован не-вакуозным против реального исходника (охват блокировки replaceImage, экспорт withPageLock, паритет build подтверждены чтением), кодер отчитался о прогоне; F2 — чистое удаление + комментарий, тест не нужен.

Маркер reviewed_head обновлён на e04afee629.

Ревью **e04afee629** — раунд 2 (правки F1/F2), спотлайт на двух правках + проверка отсутствия регрессий. **Вердикт: PASS.** Оба пункта закрыты и сверены по коду. Готово к мержу. - **F1 [test coverage] — закрыт, не-вакуозно.** Новый кейс `replaceImage opens by the resolved UUID AND keys its page lock by that UUID` пинит ОБА инварианта рискованной ветки: - (a) **open по UUID**: гейтит `/files/upload`, паркует `replaceImage` ВНУТРИ его page-lock (после скана, в аплоаде) и ассертит `state.docNames === ['page.<UUID>']` — скан-открытие по резолвнутому UUID, не по slugId; в конце `['page.<UUID>','page.<UUID>']` и `page.<SLUG>` не появляется никогда. - (b) **ключ блокировки == UUID** (тот самый отдельный инвариант): импортит ТОТ ЖЕ модуль `withPageLock` из `build/lib/page-lock.js`, что и `build/client.js` (ESM кэширует по URL → общая мьютекс-карта `chains`), и пока `replaceImage` держит блокировку — проба `withPageLock(UUID)` остаётся в очереди (`probeRan===false`), а проба на свободном ключе проходит за тот же flush (`freeRan===true`). Откат любого инварианта (open по slugId ИЛИ lock по slugId) валит тест; sanity-проба исключает «flush никого не запустил». Сверено: `replaceImage` реально держит `withPageLock(pageUuid)` через `uploadImage` (блокировка охватывает scan→upload→write), `page-lock.js` экспортит `withPageLock` — механика пробы корректна. - **F2 [simplification] — закрыт.** Мёртвая `pageIdCache.set(uuid, uuid)` удалена (в src и в build/client.js), комментарий поля переписан корректно: «A UUID input short-circuits before this cache … only slugId->uuid entries are stored/read here». Поведение не изменилось. Остальная часть PR не менялась с прошлого раунда (корневой фикс #260 был верифицирован чистым 9 аспектами) — регрессий новый коммит не вносит (только тест + удаление мёртвой строки + комментарий). build↔src паритет сохранён. Объективные проверки: vitest сам прогнать не могу (в окружении ревью нет `@hocuspocus/server`); базис PASS — новый тест НЕЗАВИСИМО верифицирован не-вакуозным против реального исходника (охват блокировки `replaceImage`, экспорт `withPageLock`, паритет build подтверждены чтением), кодер отчитался о прогоне; F2 — чистое удаление + комментарий, тест не нужен. Маркер `reviewed_head` обновлён на `e04afee629`. <!-- state:review reviewed_head=e04afee629 round=2 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-06-30 11:02:58 +03:00
vvzvlad merged commit 449a304657 into develop 2026-06-30 11:20:52 +03:00
Sign in to join this conversation.