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 	/
/
 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 	/ / ) so it agrees with the DOM path;
& stays decoded last so an escaped &#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>
Реviewer-filed баг: draw.io-редактор декодит base64 content= как Latin-1
(atob-семантика) → кириллица разваливается в мойбейк, автосейв редактора
персистит порчу и убивает превью. Нативная форма draw.io — entity-encoded
mxfile-XML (content="<mxfile…"), DOM-декодится как UTF-8.
Фикс двух write-путей: buildDrawioSvg (mcp, все create/update) и
createDrawioSvg (server Confluence-импорт) теперь XML-эскейпят content=
вместо base64. Декодер уже различает startsWith("<") vs base64 — обе формы
читаются, старые base64-файлы открываются (back-compat), byte-stable
round-trip. CHANGELOG + заметка про лечение старых диаграмм (drawioGet→Update).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
checkNewComments делал O(N) последовательных REST-вызовов listComments по страницам
working set — большой space линеен по round-trip'ам. Теперь per-page фетчи идут с
ограниченным параллелизмом (cap 6, середина полосы 5–8): независимые чтения не ждут
друг друга, но и не заваливают сервер/сокеты.
mapWithConcurrency — крошечный пул без зависимости от p-limit: N воркеров тянут
следующий индекс с общего курсора. Порядок результатов сохраняется (по входному
порядку страниц), поэтому вывод детерминирован независимо от того, какой фетч
завершился первым. Серверный batch-эндпоинт «comments updated since T по space» —
опционально, отдельным заходом.
Тест (mock-HTTP): 13 страниц, задержанный /api/comments — maxInFlight > 1 и <= 6
(последовательная реализация дала бы 1), порядок результатов = порядок обхода.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревью #516: isPageId/asPageId/isSlugId/asSlugId/SLUG_ID_RE + типы SlugId/PageRef
имели НОЛЬ вызовов в монорепе — только барные ре-экспорты в client.ts и кейсы
теста. Несущий смысл — только compile-time бренд PageId (минтится as PageId в
resolvePageId, единственном узле канонизации; asPageId туда намеренно не звался).
Докстринг заявлял «asPageId() guards the untrusted PUBLIC boundary» — но никто не
звал: спекулятивный вес.
Удалил мёртвые runtime-символы + типы SlugId/PageRef + ре-экспорты + их тест-файл.
Оставил тип PageId + касты. Поправил врущий коммент в resolvePageId (бренд —
чистый compile-time маркер, гарантия — что этот узел единственный производитель).
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>
WARNING [stability]: эвикция могла убить ЧУЖУЮ ещё-connecting сессию как
idle-жертву — isBusy()=false во время open(), сессия вставлена в мапу до
await open(); параллельный acquire на ДРУГУЮ страницу при насыщении выселял
её → спурьёзный reject не-начатой записи / старвейшн новых записей. Фикс:
idle-victim скан теперь — connecting
падает в last-resort oldestBusy, честный idle по-прежнему предпочитается,
кап держится, коалесинг того же ключа (per-page lock) не задет. Тест на
интерливинг (connecting не выселяется под насыщением), mutation-verified.
Доводки (проза, логику не меняют): drawioCreate description (nodeId:null на
nested-вставке); forward-коммент у writeWithCollabAuthRetry (ретрай только
auth; при расширении — проверять isCollabIndeterminateError, #435); хедер
comment-anchor (все 4 точки делегируют в resolveAnchorSelection); CHANGELOG.
Ребейзнут на develop (волна 1 смержена): только 4 коммита #494.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 4. При достижении cap реестра live-сессий эвиктился LRU-кандидат через
destroy(), что реджектило его in-flight mutate терминальной ошибкой. Но update
такого mutate мог УЖЕ дойти до сервера и персиститься → эвикция превращала
удачную запись в ложный proval → retry-склонный агент повторял запись →
ДУБЛИКАТ (тот же механизм, что в инциденте #435).
Правка:
- цикл эвикции теперь ПРЕДПОЧИТАЕТ idle-жертву: идёт по LRU-порядку, пропускает
busy-сессии (isBusy() — есть in-flight mutate) и эвиктит старейшую idle;
- если ВСЕ сессии busy (эвикция неизбежна для приёма новой записи) — эвиктит
LRU busy через evictForCap(), который реджектит in-flight op ПОМЕЧЕННОЙ
ошибкой INDETERMINATE «write may have applied — verify before retry», а не
плоским failure. Маркер collabIndeterminate + guard isCollabIndeterminateError
(симметрично isCollabAuthFailedError), чтобы «проверь перед ретраем» можно
было отличить от чистого провала.
Тесты (мутационные): busy LRU-сессия сохраняется, эвиктится younger idle, и её
запись доезжает на ack; when-all-busy — эвикция реджектит запись именно
INDETERMINATE-ошибкой с маркером и текстом verify-before-retry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 3. При вставке диаграммы во вложенный контейнер (анкор внутри
callout/ячейки таблицы) узел УЖЕ записан и закоммичен мутацией, но тул кидал
ошибку «no addressable #<index> handle». Retry-склонный агент воспринимал это
как провал записи и повторял drawioCreate → ДУБЛИКАТ диаграммы (тот же класс
double-apply, что в инциденте #435).
Правка: ветка insertedIndex<0 возвращает success:true с nodeId:null и
warning'ом «written NESTED, saved — do NOT re-create; re-read via
getOutline/getPageJson (attachmentId …)» вместо throw. Запись подтверждается,
агент знает, что хендла нет и как перечитать — и не ретраит приземлившуюся
запись. nodeId стал string|null в drawioCreate и наследующих drawioFromGraph/
drawioFromMermaid (там тот же путь) + в интерфейсе IDrawioMixin.
Мок-тест: вложенная вставка не кидает, отдаёт success/nodeId:null/warning и
пишет диаграмму РОВНО один раз вложенной (краснеет, если вернуть throw).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 2. Каждое ручное зеркало получает настоящий гард/деривацию/parity-тест
вместо комментария «mirror this»:
- ROUTING_PROSE → ОБРАТНЫЙ гард (server-instructions.ts): прямой уже покрыт
генерируемым <tool_inventory> (каждый зарегистрированный тул в списке); теперь
`unregisteredProseToolMentions` краснеет, если проза ссылается на
несуществующий/переименованный тул (camelCase-токены прозы ⊆ реестр, минус
явный список не-тул-терминов PROSE_NON_TOOL_TERMS). Раньше мёртвая ссылка в
прозе не краснела. Мутационный тест: `getPageContentz` ловится.
- LABELS экспорта чата (chat-markdown.util.ts) → parity-тест: каждый ключ-ярлык
обязан быть реальным in-app тулом (иначе переименованный тул молча
сваливается на generic «Ran tool <name>»), и оба языка (en/ru) размечают
ОДИН набор тулов.
- зонд comment-signal ×2 (оба хоста) → общий `createListCommentsProbe` в
packages/mcp: index.ts и ai-chat-tools.service.ts (через loader) строят
tracker.probe из ОДНОЙ фабрики — тела больше не могут разойтись (например,
один считает resolved-комментарии, другой нет). Проброшен через loader-границу
как опциональный (отсутствует на устаревшем билде → сигнал выключен).
- countAnchorMatches (comment-anchor.ts) → делегирует решение
exact-wins/strip-fallback единственному резолверу resolveAnchorSelection
вместо параллельной копии; поведение идентично (rawCanAnchor ⟺ rawCount>0),
parity-тест по корпусу краснеет при расхождении count↔resolve.
- normalize+sha256 ×2 (gen-registry-stamp.mjs + docmost-client.loader.ts):
зеркало УЖЕ закрыто cross-impl parity-тестом (CROSS_IMPL_TREE/EXPECTED
проверяется с обеих сторон) — критерий issue «либо parity-тест» уже выполнен;
извлечение общего модуля через границу пакета/билд-шага регрессионно-опасно
для load-bearing integrity-проверки (#486), поэтому оставлено как есть.
Тесты: mcp node --test unit+mock зелёные (844); затронутые server-specs
(chat-markdown, comment-signal-inapp, loader, service, tiers, contract, cap)
зелёные (351).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 1. Спек без execute падал по-разному на двух хостах: MCP-хост
(index.ts) в цикле регистрации делает `mcpExecute` иначе `spec.execute!` —
без execute это TypeError в момент ВЫЗОВА тула (то есть в проде, только когда
модель выберет именно этот тул); in-app-хост (ai-chat-tools.service.ts) делает
`inAppExecute ?? execute`, затем `if (!run) continue` — то есть МОЛЧА роняет
тул, он просто исчезает у агента без единой ошибки.
Комментарий «mirror this» гардом не считается: закрываем зеркало настоящим
структурным assert'ом. `assertEverySpecIsRegisterable()` гоняется при загрузке
модуля tool-specs на ОБОИХ хостах (оба его импортируют) и кидает исключение,
если не-inline спек, который хост регистрирует, не несёт исполнителя для этого
хоста — латентный рантайм-TypeError / тихий дроп превращается в громкий отказ
на старте. `inlineBothHosts` освобождён (оба хоста регистрируют его inline,
execute у него намеренно нет); `inAppOnly`/`mcpOnly` проверяются только для
своего хоста.
Тест по реестру с мутационной проверкой: синтетические плохие реестры
(без execute; inAppOnly без inAppExecute) обязаны кидать, а mcpExecute-only /
inAppExecute-only / inlineBothHosts — проходить. Часть (б) — assert
объявления write-класса — уже приземлилась в #489.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
expectedText брался из debounced REST-снапшота (getAnchoredText по
pages/info), а метка ставилась в live-доке — при расхождении (док ушёл
вперёд за окно дебаунса) apply строго сравнивал текст под меткой с
устаревшим stored selection и давал 409 на каждый вызов.
MCP-клиент теперь в transform-фазе (та же версия live-дока, где ставится
метка) перечитывает фактическую подстроку под меткой и, если она
отличается от сохранённого selection, синкает её через новый эндпоинт
POST /comments/resync-suggestion-anchor. Best-effort: сбой синка не
откатывает уже заякоренный комментарий, а лишь выдаёт мягкое
предупреждение. Совпадающий снапшот не делает лишнего round-trip.
Сервер: resyncSuggestionAnchor правит только stored selection
незаселённой suggestion своего автора (guards: top-level, есть
suggestedText, не applied/resolved, отличается от suggestedText),
идемпотентно, без ws-бродкаста.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревью #493 (MEDIUM): вшив normalizeForeignMarkdown первым шагом в
markdownToProseMirrorCanonical, коммит 4 распространил срез YAML front-matter
(YAML_FRONT_MATTER_RE) на КАЖДЫЙ agent-write путь. convertProseMirrorToMarkdown
эмитит `---` для horizontalRule, поэтому страница, начинающаяся с
horizontalRule и содержащая второй `---`, при полном agent-write теряла всё до
второго `---` — молчаливая потеря ранее сохранённого контента.
Правка: разделил нормализацию. normalizeForeignMarkdown (серверный file-import
boundary) по-прежнему срезает front-matter. Новый normalizeAgentMarkdown
(agent-write, markdownToProseMirrorCanonical) делает ТОЛЬКО CRLF-нормализацию +
rewrite GFM reference-сносок (тот самый drift, ради которого коммит 4) и НЕ
трогает ведущий `---…---`. На каноническом сериализованном контенте rewrite —
no-op (он не эмитит `[^id]:`-строк).
Тесты: agent-write horizontalRule-led дока со вторым `---` сохраняет весь
контент (round-trip); file-import с реальным YAML front-matter его по-прежнему
срезает; agent-write всё ещё канонизирует GFM reference-сноски.
Mutation-verify: strip обратно на agent-write → тесты потери контента краснеют.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read-путь прячет resolved comment-анкоры (#337), поэтому markdown, который
агент шлёт в updatePageMarkdown, их уже не содержит — наивный full-write
стирал ВСЕ resolved comment-марки (потеря данных). Активные комментарии
переживают round-trip сами (read отдаёт их <span data-comment-id>), а
resolved — нет.
Правка: в write-пути (updatePageContentRealtime) пере-прививаем resolved-
марки из ЖИВОГО дока на совпадающие текстовые диапазоны свежеимпортированного
тела, механикой анкоринга comment-anchor. spliceCommentMark обобщён на
произвольную марку; добавлены applyCommentMarkInDoc (сохраняет resolved:true
+ attrs), collectResolvedCommentSpans и regraftResolvedComments (чистая, не
мутирует входы). Спан, чей текст агент изменил/удалил, просто не
переанкорится и отбрасывается (он и так resolved). first-occurrence-семантика
как у остального анкоринга.
Проверено: 6 новых наблюдаемых тестов + весь MCP unit-suite (691) зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Локаторная нормализация markdown (stripInlineMarkdown + примитив
stripWrappersAndLinks с WRAPPER_PATTERNS/LINK_IMAGE_RE) была ФОРКНУТА один-в-
один в packages/mcp/src/lib/text-normalize.ts и в каноническом
@docmost/prosemirror-markdown (где ей пользуется node-ops). MCP теперь
импортирует оба примитива из пакета (mcp и так от него зависит — цикла нет) и
держит на них лишь свои тонкие надстройки stripBalancedWrappers/
closestBlockHint. ~60 строк дубля удалено, дрейф закрыт.
Проверено: пакет node-ops (103) + весь MCP unit-suite (685) зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Нормализация чужого markdown (GFM reference-сноски [^id] -> инлайн ^[body],
срез ведущего YAML front-matter) жила только в apps/server, поэтому MCP-путь
записи страницы (updatePageMarkdown -> markdownToProseMirrorCanonical) тот же
ввод обрабатывал ИНАЧЕ, чем серверный импорт: front-matter и [^id] утекали
как литеральный текст / битая ссылка.
Перенёс normalizeForeignMarkdown в @docmost/prosemirror-markdown и вызвал его
первым шагом в MCP markdownToProseMirrorCanonical — теперь агентский
updatePageMarkdown нормализуется точно как серверный импорт. Серверные
импортёры (import.service, file-import-task.service, page.service) берут
функцию из пакета. Тест-корпус перенесён в пакет (foreign-markdown.test.ts).
Проверено: пакет (17 тестов corpus) + весь MCP unit-suite (685) зелёные,
включая reference-footnote/fence-кейсы на canonical-пути.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Кэш внешних MCP-клиентов не делал health-check/reconnect-on-error: после
разрыва SSE-транспорта (bodyTimeout режет при тишине МЕЖДУ вызовами) отдавал
труп до конца TTL, а модель жгла шаги на ретраях ВНУТРИ текущего рана.
- Новое поле writeClass ('readOnly'|'write') на КАЖДОМ SHARED_TOOL_SPEC +
registration-time assert (и compile-time через satisfies). Все 48 спеков
расклассифицированы: чтения → readOnly, любая мутация страницы/коммента/шэра/
диаграммы → write. Экспортированы SHARED_TOOL_WRITE_CLASS + isRetryableWriteClass.
- Per-run обёртка восстановления транспорта: при транспортной ошибке readOnly-тул
реконнектит свой сервер и ретраит РОВНО 1 раз ВНУТРИ рана; write-тул НЕ
авторетраится (indeterminate — «могло примениться, проверь», класс инцидента
#435). CAS-своп байндинга по identity (проигравший конкурентный вызов ретраит
на текущем клиенте, не минтит второй). Лизы не освобождаются mid-run — ран
копит set (старая+новая) и релизит на turn-end.
- Проверка abortSignal ПЕРЕД ретраем И ПЕРЕД чеканкой свежего клиента; per-call
cap покрывает оба attempt'а + connect.
- Классификация транспортной ошибки по РЕАЛЬНЫМ шейпам undici (SocketError/
BodyTimeoutError, cause-цепочка), не по мок-ошибкам.
- Отдельный, поднятый bodyTimeout для SSE-транспорта MCP (тишина между вызовами
легальна) — DEFAULT 10 мин, AI_MCP_SSE_BODY_TIMEOUT_MS.
write-class map грузится в mcp-clients лениво через dynamic import (пакет ESM),
type-only импорт — без static require ESM из commonjs.
Тесты на РЕАЛЬНЫХ error-шейпах: «повтор после обрыва ВНУТРИ рана получает живой
клиент», «write-тул не авторетраится», «ретрай после Stop не происходит»,
+ writeClass-контракт в mcp node --test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
После ребейза в src/client/context.ts:209 остался одиночный маркер
`>>>>>>> 917c4064` без парного открытия — он попал в коммит и ломал сборку
всего пакета mcp (TS1185 Merge conflict marker), из-за чего не запускались
никакие node --test. Код выше маркера цел; удаляю только строку маркера.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Внутреннее ревью: docstring поля/метода toolAbortSignal утверждал «restores the
prior value on unwind», но wrapInAppToolWithCap намеренно НЕ восстанавливает
сигнал (set-and-leave) — именно это заставляет брошенного проигравшего race
бросить на следующем safe-point; корректность держится на свежем клиенте на ход +
перезаписи следующим вызовом. Комментарии приведены в соответствие с механизмом.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-app тул-обёртки отбрасывали второй аргумент options с abortSignal — это был
единственный класс тулов без отмены и без wall-clock cap. Контент-мутации идут
через collab-WS (mutatePageContent), не через axios, поэтому «прокинуть signal в
axios» главный write-путь не покрывал.
- Переиспользован race-паттерн wrapToolWithCallTimeout: каждый in-app тул гонится
против композитного сигнала (Stop + per-call cap); на abort — реджект
немедленно, проигравший промис отбрасывается (латентность мс, не сетевой
teardown — от этого зависит таймаут supersede в коммите 3).
- Safe-point проверки сигнала между последовательными вызовами paginateAll и
пре-коммитная проверка в mutatePageContent (+ реентрантный близнец) через
DocmostClient.setToolAbortSignal, который обёртка публикует перед каждым вызовом.
- Per-call cap покрывает весь вызов, env-tunable (AI_CHAT_INAPP_TOOL_CALL_CAP_MS,
дефолт 2 мин).
- Задокументировано ограничение (#487): abort между вызовами многовызовного
write-тула оставляет частично применённую операцию — отмена гарантирует «новый
вызов не стартует», не «запись не доехала».
Тесты (честное свойство «после Stop не стартует новый HTTP/WS-вызов»): юнит
wrapInAppToolWithCap (реджект-на-abort, отсутствие новых вызовов, per-call cap,
публикация сигнала) + mock-HTTP тест реального paginateAll safe-point.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
B1: переименование строки ошибки (#394) не обновило 4 предсуществующих
share-ассерта → полный серверный сьют был красный. Обновлены ассерты в
public-share-chat.spec.ts и public-share-chat-tools.service.spec.ts под
новую классифицированную строку.
B2: /health первая проба после старта врала DOWN при живом Redis
(lazyConnect + enableOfflineQueue:false + maxRetriesPerRequest:1 → первый
ping до открытия сокета). Добавлен ensureConnected() с bounded-таймаутом
перед первым ping; покрывает и путь пересоздания после onModuleDestroy.
Тест UP-с-первой-пробы против реального ioredis (mutation-verified).
Устранён open-handle leak в redis.health.spec (drain ioredis
force-destroy-таймера в teardown; без forceExit) и окно ложного DOWN при
конкурентных пробах (мемоизированный connectingPromise).
B3: комментарий про orphan-чат при провале beginRun (insert до begin).
B4: описание listPages упоминает поле truncated в tree-режиме.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onModuleDestroy чистил только sweep-таймер; loopback-collab-сессии держали доки
открытыми на collab-сервере до идла — рестарт мог гонять с доком, запиненным
умирающим воркером. Теперь дергает destroyAllSessions() через переопределяемый
шов (для юнит-теста без ESM-пакета), best-effort. Плюс удалён мёртвый код:
неиспользуемый импорт parseNodeArg в mcp/index.ts и мёртвые enum-члены
SEARCH_REMOVE_* в queue.constants (подтверждено grep'ом — ни диспетчера, ни
процессора).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
listPages(tree:true) деструктурировал только pages из enumerateSpacePages и
возвращал голое дерево, теряя truncated — на неполном дереве (stdio-fallback BFS
упёрся в node-cap) вызывающий не знал, что страницы потеряны. Возвращаем
{ tree, truncated } (по образцу check_new_comments); основной /pages/tree путь
беспредельный, там false.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Кэш collab-токена (#435) инвалидировался только на HTTP-401/403 (REST-
интерцептор и login()); отклонённый Hocuspocus-handshake оставлял протухший
токен в кэше — каждая последующая мутация переотправляла тот же битый токен до
истечения TTL (минуты) без self-heal. collab-session помечает ошибку
onAuthenticationFailed маркером; клиентские write-швы (mutatePage/replacePage/
mutateLiveContentUnlocked) обёрнуты в writeWithCollabAuthRetry: на помеченной
ошибке кэш сбрасывается и запись ретраится ровно один раз со свежим токеном —
симметрично HTTP-пути.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
elkjs.layout() возвращает Promise, но саму раскладку крутит СИНХРОННО и
блокирует поток целиком. На in-app хосте это был главный event loop:
патологический граф у капа 500 узлов вешал ВСЕ HTTP/SSE/loopback. Прежняя
защита (Promise.race с setTimeout(5s)) была иллюзией — таймер физически не
мог сработать, пока тот же поток заблокирован внутри elkjs (комментарий в
коде это сам признавал).
Теперь elk.layout() исполняется в worker_thread, а таймаут форсится
worker.terminate() — единственный способ прервать синхронный JS. Главный
поток остаётся свободным; на таймауте/ошибке — best-effort откат к
исходной модели, как и раньше. Лживый комментарий «can never wedge the
server» убран.
Тесты: unit на terminate-по-таймауту (крошечный ceiling → hard-kill →
исходная модель нетронута) и бенчмарк-гард на worst-case графе у капа
(500 узлов/~1000 рёбер раскладывается, а главный event loop продолжает
тикать во время раскладки).
Правки по ревью #480.
F1 (мёртвые метрики): счётчики mcp_getpage_cache_hits_total/_misses_total
эмитились через onMetricFn, но серверный синк mcp.service.ts диспатчил по имени
через if/else-if БЕЗ default и знал только 2 имени → мои дропались молча; в
metrics.registry их вообще не было. Починка по существующему 3-частному паттерну
(как collab_connect_timeouts_total): имена-константы в metrics.constants.ts; два
Counter'а + incGetPageCacheHit/Miss в metrics.registry.ts; два else-if в
mcp.service.ts, роутящие ровно эти имена (существующие 2 ветки не тронуты).
Проверено end-to-end: скрейп prom-реестра показывает hits=2/misses=1 после
прогона роутинга.
F2 (нет теста на пропуск конверсии): добавлен overridable seam
convertPageMarkdown в read.ts (идиома проекта для юнит-тестируемости ESM-импортов);
getPage miss-ветка идёт через него. Тест мокает convertPageMarkdown и ассертит
callCount===1 через MISS→HIT одной страницы (конверсия один раз на промахе, ноль
на хите). Мутационно доказан: пропатчил hit-ветку на повторную конверсию → тест
покраснел (callCount=2), откатил → зелёный.
mcp node --test 814/814 (+1); pmd+mcp tsc чисто; серверные metrics-файлы
компилируются изолированно, runtime-тест счётчиков зелёный.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getPage — доминирующая операция агентского цикла (812 вызовов/2ч, p95 840мс):
полный обход ProseMirror-дерева convertProseMirrorToMarkdown на КАЖДЫЙ вызов,
кэша нет. При 812 read против 28 update большинство — повторная конверсия того
же неизменившегося контента в тот же Markdown на общем event loop. closes#479
- getpage-cache.ts: LRU-кэш (класс, не синглтон) результата конверсии. Ключ
(canonical pageId UUID, updatedAt, optionsHash). updatedAt из ТОГО ЖЕ ответа
/pages/info, что и content → инвалидация бесплатная и точная (страница
изменилась → новый ключ). optionsHash — стабильная сериализация опций
(dropResolvedCommentAnchors #328), getPage и export не коллизят. Границы: LRU
по количеству (50) И по байтам (10МБ, Buffer.byteLength), вытеснение по любому;
oversized-запись хранится, не заклинивает.
- Кэш — protected инстанс-поле в context.ts (один DocmostClient на сессию/
идентичность) → изоляция как у клиента, межпользовательской утечки контента
нет. Байт-идентичный вывод: кэшируется строка ДО подстановки {{SUBPAGES}},
подстановка на живых subpages выполняется на hit и miss одинаково.
- Счётчики через существующий onMetricFn-синк: mcp_getpage_cache_hits_total /
misses_total (honest hit-rate: miss на реальной конверсии, вкл. non-cacheable).
- Subpages НЕ параллелизуемы: listSidebarPages требует spaceId из ответа
page-fetch (resolvePageId даёт UUID, но не spaceId) → последовательность
сохранена (задокументировано); кэш — основной выигрыш.
Тесты: mcp node --test 813/813 (9 unit: hit/miss по updatedAt+options, вытеснение
по count И byte, recency, oversized, hash order-insensitive; 4 mock: MISS→HIT
байт-идентично + конверсия один раз, смена updatedAt→свежий MISS, slugId+UUID
одна запись, дифф-тест живой подстановки subpages на hit). tsc чисто.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Механика конкурентной записи (withPageLock → acquireCollabSession → mutate)
держалась на цепочке конвенций в комментариях. Новый write-метод без знания
правил компилировался и уходил в прод (класс #260/#152/#159). Закрепляем ключевые
инварианты кодом.
- page-lock.ts: экспортированы UUID_RE/isUuid (тот же regex, что resolvePageId,
UUID v1–8/v7). withPageLock FAIL-FAST кидает при не-UUID ключе ДО любой работы
(комментарий-инвариант #260/#449) — забытый resolve/slugId больше не даёт тихую
потерю сериализации под другим ключом. client.ts импортирует isUuid оттуда
(убран локальный дубль — resolver и assert не разъедутся).
- mutatePage/replacePage seams стали async и сами вызывают resolvePageId — ключ
лока/кэша канонический даже если вызывающий забыл (для уже-UUID это cached
no-op; все 7 текущих вызывающих и так резолвят). replaceImage (один внешний
лок + mutateLiveContentUnlocked) не тронут, deadlock невозможен.
- collab-session.ts: машинно-проверяемые маркеры MUTATE-CRITICAL-WINDOW
BEGIN/END вокруг синхронного блока fromYdoc→applyDocToFragment (INVARIANT 1).
Тест no-await-critical-window читает исходник и краснеет на await/yield в окне
(проверено нейтером). Случайный await больше не тихо клоббит живые правки.
- Документация осознанной позиции: single-instance/sticky-sessions —
требование деплоя (Dockerfile + README EN/RU + .env.example), т.к. мьютекс и
stash — per-process. Окно устаревших прав (кэш-сессия пишет под токеном
момента connect до MCP_COLLAB_SESSION_MAX_AGE_MS=10мин) — задокументированный
trade-off в .env.example; push-инвалидации нет (осознанно).
Тесты: page-lock fail-fast (slugId/пусто/non-string → throw; канонический UUID
принят), no-away-guard, обновлённые фикстуры на валидные UUID. #449-специфичные
37/37 зелёные; mcp tsc чисто. closes#449.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Сырой XML остаётся escape-hatch, но для 90% случаев модель не видит ни координат,
ни style-строк — весь класс ошибок лейаута и иконок уходит by construction: эти
решения принимает сервер, а не LLM. Стоит на #443, переиспользует конвейер #423 и
shape-index/elkjs/линтер #424.
- drawioFromGraph(pageId, where, graph, direction?, preset?, layout?): граф
узлов/групп/связей → резолв иконок (shape-index #424; неизвестная → generic по
kind с подписью, не пустой квадрат), стили из пресета (kind→палитра),
elkjs-layered с compound-группами, ассемблер XML (линтер-чистый by construction:
зазоры >=150, прозрачные контейнеры, относительные координаты детей,
cross-container рёбра parent=1, эскейп меток). Хинты pinned/sameLayerAs/layer и
layout none/full/incremental — детерминированным post-pass'ом (ELK-констрейнты
оказались ненадёжны). Пресеты default/dark/colorblind-safe (Okabe-Ito) — данные.
- drawioFromMermaid(pageId, where, mermaid): чистый парсер flowchart (без
браузера/CLI) → graph → тот же конвейер. Формы/направление/пунктир=async/
subgraph→группы/цепочки; не-flowchart отвергает внятно.
- drawioEditCells(pageId, node, operations, baseHash): ID-based add/update/delete,
delete каскадит на детей контейнера и связанные рёбра; baseHash обязателен
(optimistic lock как drawioUpdate); сентинелы 0/1 от delete защищены.
DoS-границы (LLM-вход): MAX_GRAPH_EDGES=1000/GROUPS=500 в validateGraph (узлы уже
500), mermaid MAX_CHARS=200k/LINES=20k/GROUPS=500, chain 500 — все с быстрым
throw ДО лейаута/ассемблера (ассемблер и маппер вне ELK-таймаута, иначе OOM
воркера). incremental сохраняет неперечисленные существующие ячейки (mergeExisting
Cells) — «добавь узел» не стирает ручную расстановку. sameLayerAs/layer после
снапа раскладываются по перпендикулярной оси с зазором >=150 → 0 quality-warnings;
pinned — точные пользовательские координаты (clamp>=0, могут дать warning, гарантия
«0 by construction» относится к авто-лейауту).
Регистрация: 3 shared-spec на оба хоста (camelCase, execute-in-spec, inlineBoth
Hosts не понадобился), DocmostClientLike/Method += 3, contract, routing-проза
(fromGraph→архитектуры/облака, fromMermaid→стандартные flowchart, raw xml→экзотика).
Тесты: mcp node --test 782/782 (57 новых) — приёмка (15+ узлов/2 вложенные группы/
AWS-иконки→0 lint/0 warnings/иконки резолвятся, hints, incremental без сдвига,
edit_cells update/cascade/stale-baseHash, снапшоты пресетов + colorblind-safe,
mermaid ветвление+subgraph) + регрессии на DoS-границы. tsc чисто; server jest
(contract + ai-chat) 290/290. closes#425.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Финальная из трёх частей #443. Есть pageId (из поиска, из ссылки) — нужно понять
местоположение и окружение. Раньше это get_page + полное дерево. Только чтение,
только метаданные (без контента). closes#443.
- client.ts getPageContext(pageId): resolvePageId (slugId→uuid, для uuid без
round-trip) → POST /pages/breadcrumbs + listSidebarPages(spaceId, uuid). Два
запроса для uuid-входа (spaceId берётся из ответа breadcrumbs), +1 для slugId.
Выход {page:{pageId,title,spaceId}, breadcrumbs:[{pageId,title}] (root→parent;
[] для корня), children:[{pageId,title,hasChildren}]}.
- Порядок breadcrumbs выверен по серверному источнику (getPageBreadCrumbs: CTE
сидится на childPageId, идёт вверх, .reverse() → root→page; страница —
ПОСЛЕДНИЙ элемент). page = chain[last], breadcrumbs = chain.slice(0,-1); корень
→ []. Проекция явная — наружу только pageId (UUID), slugId/icon/position не
утекают. Пустой chain / 404 / 403 → явная ошибка, не пустой объект.
- children — прямые дети через listSidebarPages (cursor-пагинация #442/#451,
страница с >20 детьми отдаёт всех без дублей, порядок по position, hasChildren).
- Общий реестр: getPageContext на ОБОИХ хостах через цикл спеков (mcpName===
inAppKey==="getPageContext", camelCase); DocmostClientLike/DocmostClientMethod
+= it; compile-time contract; TOOL_FAMILY READ + routing-проза.
Тесты: get-page-context.test.mjs +6 (3-й уровень split, no-slugId-leak + 2
запроса, корень→[], slugId-resolve, >20 детей без cap/дублей, плохой id→ошибка,
пустой chain→throw), tool-specs +1. mcp node --test 725/725, tsc чисто; server
jest (contract + ai-chat) 265/265.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раньше единственный способ получить дерево — listPages tree:true: BFS по
sidebar-эндпоинту, десятки-сотни HTTP-вызовов и молчаливая потеря страниц
(#442). Бэкенд форка уже отдаёт всё одним POST /pages/tree (getSidebarPagesTree,
merged #442/#451), так что это MCP-сторона + общий реестр. Только чтение.
- lib/tree.ts: buildPageTree(nodes) аддитивно расширен до buildPageTree(nodes,
options?) с {shape?: "lean"|"getTree"; maxDepth?}. Дефолт/{} — байт-идентичный
lean {id,slugId,title,children?} (существующие вызыватели listPages tree:true
и subtree-BFS не тронуты, есть регрессионный тест на точный набор полей).
shape:"getTree" проецирует {pageId, title, children?, hasChildren?} (id→pageId;
slugId/icon/position/parentPageId не утекают ни на одной глубине).
- client.ts: getTree(spaceId, rootPageId?, maxDepth?) — тот же код-путь, что
listPages tree:true: один enumerateSpacePages(spaceId, rootPageId) (единичный
/pages/tree + cursor-BFS фолбэк для stock upstream) → buildPageTree(pages,
{shape:"getTree", maxDepth}). listPages tree:true помечен DEPRECATED в JSDoc
(BFS-фолбэк на месте, поведение не тронуто).
- maxDepth/hasChildren: полное дерево строится, потом обрезается. Корни = глубина
1; maxDepth:1 → только корни; hasChildren:true ТОЛЬКО на срезанном узле с
серверным hasChildren (source of truth), опущен у листьев и раскрытых узлов.
Схема тула min(1) отбраковывает 0/отрицательные.
- tool-specs.ts: shared-спек getTree (оба хоста через цикл реестра),
DocmostClientLike Pick += getTree, listPages desc/catalogLine — про депрекацию.
server-instructions.ts: getTree:"READ" + routing-проза. Loader
DocmostClientMethod += getTree, compile-time client-call contract += getTree.
Циклы/self-ref безопасны: project рекурсирует только от корней, циклический
компонент недостижим из корней (нет бесконечной рекурсии). Orphan (родитель
отфильтрован пермишенами) всплывает как корень.
Тесты: tree.test.mjs +9 (nesting+порядок+no-leak, maxDepth:1/2 + hasChildren,
orphan→root, seeded rootPageId, ≤0/нефинитный = без среза, lean-байт-идентичность),
tool-specs.test.mjs (схема getTree + депрекация listPages). mcp node --test
718/718, tsc чисто; server jest (shared-tool-specs.contract + ai-chat) 263/263.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MCP-сервером пользуются LLM-агенты; основной паттерн — lookup: найти страницу по
обрывку технической строки и сразу понять, где она лежит и что в ней. Раньше на
такой вопрос уходило 3–5 вызовов. Эта часть закрывает `search` (часть 1 из 3;
get_tree и get_page_context — следующими PR стопкой). Только чтение.
Серверная часть — opt-in, веб-UI байт-в-байт неизменен:
- SearchDTO: опциональные substring/parentPageId/titleOnly (default-off; без
флагов путь FTS не тронут — guard `if (substring) return searchPageLookup`).
- searchPageLookup: один скан pages, WHERE = title LIKE '%q%' OR (если не
titleOnly) text LIKE '%q%' OR (если tsquery непустой) tsv @@ ... . LIKE-
метасимволы %/_/\ экранируются (escapeLikePattern, ESCAPE '\') — `%`/`_` не
матчат всё; substring-ветка работает даже при пустом tsquery (кейс 10.0.12).
- Ранжирование тирами (TITLE_EXACT > TITLE_SUBSTRING > TEXT), вторичный сигнал
ts_rank / позиция; score∈(0,1] только для сортировки одной выдачи (формула в
комментарии). 200-cap упорядочен по SQL-прокси тира ДО среза (иначе Postgres
отдаёт произвольные 200 и сильный хит мог выпасть). Пермишен-фильтр к
merged-набору ДО limit. path — одна рекурсивная CTE на все хиты (не N+1).
- snippet оконный в SQL (~500 символов вокруг первого совпадения). Позиция и
срез в ОДНОМ пространстве LOWER(f_unaccent(...)) — f_unaccent не length-
preserving (ß→ss, лигатуры, …→...), иначе окно смещалось/пустело. titleOnly →
пустой snippet. Компромисс задокументирован.
- Миграция: GIN gin_trgm_ops по LOWER(f_unaccent(text_content)); title-trgm
индекс #348 переиспользован (IF NOT EXISTS), down() дропает только новый.
MCP: схема search (spaceId/parentPageId/titleOnly/limit 1–50, default 10),
client.search прокидывает substring:true, filterSearchResult → {pageId, title,
path, snippet, score}. Инвариант: наружу только pageId (UUID), slugId/id
никогда. Комментарии про намеренное расхождение с in-app hybrid-RRF (не тронут)
и про деградацию на stock-upstream/Typesense (substring→plain FTS, без path/
snippet).
Проверка на реальном Postgres: server integration 16/16 (вся acceptance-таблица
#443 + регрессии на смещение snippet и cap-200), server unit 27/27, mcp
node --test 708/708, tsc чисто.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Описания двух тулов устарели после #345 (канонический конвертер, #293/#351):
«LOSSY…approximated» у getPage и «lossless» у exportPageMarkdown искажали
роутинг агентов и молчаливо скрывали реальную потерю данных. Четвёртый линк
breaking-окна, стоит на #413.
- tool-specs.ts: getPage — вместо «LOSSY…approximated» точный закрытый список
потерь (canonical for text; теряет лишь id блоков, resolved-якоря
комментариев и фиксированный набор ACCEPTED-атрибутов без markdown-
представления: спаны/colwidth/фон ячеек таблиц, indent, callout.icon,
orderedList.type, link internal/target/rel/class). exportPageMarkdown —
убрано «lossless»: round-trip перегенерирует id блоков и молча отбрасывает
тот же набор (в первую очередь merge-спаны ячеек); держать в page-JSON, если
нужны. Список выведен из источника истины (ATTR_VALUE_FUZZ_ALLOWLIST +
MARK_ATTR_ALLOWLIST); opaque carried-verbatim токены (attachmentId, mime,
slugId и пр.) намеренно НЕ в списке потерь — они round-trip'ятся.
- server-instructions.ts: READ-строка ROUTING_PROSE приведена к тому же
точному списку, без противоречия markdown-default роутингу #413.
- Исправлена вторая латентная неточность: getPage описывался как
сохраняющий resolved-якоря — но client.ts передаёт dropResolvedCommentAnchors:
true, resolved-якоря скрыты (getNode, наоборот, их сохраняет — другой тул).
- README пакета (EN + RU-зеркало) приведены в соответствие passage-for-passage:
убраны безоговорочные «lossless/lossy» для Markdown round-trip; genuinely-
lossless ссылки на raw-JSON (getPageJson/getNode) оставлены.
Логику не трогает. server-instructions.test.mjs удалён и заменён
tool-inventory.test.mjs (без пинов на старую формулировку). mcp tsc чисто,
node --test 702/702, tool-inventory 5/5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Правки по внутреннему ревью #413.
1. Orphan-сноска (нарушение «no second canon»). mergeFootnoteDefinitions
раньше делал ранний return при пустых definitions, пропуская
canonicalizeFootnotes. Если markdown-фрагмент БЕЗ сносок заменял/удалял
блок, бывший последним referrer'ом существующей сноски, её определение
оставалось orphan в хвостовом списке (полный ре-импорт его бы убрал).
Теперь fast-path (возврат doc по ссылке без clone) только когда
defs.length===0 И !hasFootnoteArtifacts(doc); иначе клон → append (no-op
при пустых) → normalizeAndMergeFootnotes → canonicalizeFootnotes, как при
полном импорте. Новый предикат hasFootnoteArtifacts обходит дерево на
любой footnotesList/footnoteReference (существующие walk/isObject).
Идемпотентно: несвязанный plain-патч на странице со сносками не трогает
их топологию (canonicalizeFootnotes шаг 6 возвращает как есть).
2. Block-id disjoint от страницы. Новый экспорт reassignCollidingBlockIds
(liveDoc, blocks, skipIndex?) в prosemirror-markdown/node-ops (переиспользует
collectIds/makeFreshId) + barrel. Зовётся перед сплайсами в client.ts:
patch — (liveDoc, threaded, 0) (skip 0 = блок, унаследовавший id цели);
insert — (liveDoc, blocks) без skip. used-аккумулятор ловит и коллизию со
страницей, и внутрифрагментную. freshBlockId/freshId без изменений.
Тесты (+6, markdown-patch-insert): orphan-repro (0 defs/0 list/0 refs +
docsCanonicallyEqual полному импорту), fast-path (footnote-free patch не трогает
топологию, соседи byte-identical), insert канонизирует при пустых defs со
страничной сноской, уникальность top-level block-id для patch 1→N и insert N.
mcp node --test 702/702, pmd vitest 736, tsc чисто.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Боевой инцидент: diffDocs синхронно зовёт recreateTransform (rfc6902), чей
array-diff — O(n·m) по числу нод и O(w²) по длине прогонов слов, и который
НИКОГДА не бросает. Поэтому существующий catch→coarseDiff не спасает event loop:
большая агентская правка блокирует воркер на секунды→минуты (бенч: 401 нода→1.3с,
801→5.5с, 1601→OOM; отдельная ось — байт-тяжёлый вход: 309КБ→OOM), а это душит
все остальные запросы воркера. Отсюда живой прод-хэнг.
Фикс — pre-flight size-guard ПЕРЕД дорогим вызовом:
- readPositiveIntEnv(name, dflt): парс env каждый вызов; мусор/пусто/≤0/не-finite →
дефолт, так что guard нельзя случайно отключить.
- exceedsDiffSizeGuard(old, new): срабатывает по max(old,new) на ОБЕИХ осях —
countNodes и JSON.stringify().length — так что асимметричная пара (крошечный
new / огромный old и наоборот) тоже триггерит. Дёшево: один обход + один
stringify на документ.
- Дефолты MCP_DIFF_MAX_NODES=150, MCP_DIFF_MAX_BYTES=12288 (12 КиБ),
env-переопределяемы. Подобраны бенчмарком под бюджет ~200мс на ЛЮБОЙ форме
входа на границе cap'а (исходные догадки тикета 3000-5000 нод / 512КБ-1МБ были
в 20-30× завышены — пропускали бы многоминутные блоки).
- Рефактор: выделены coarseDiffTally (единый источник формы fellBack:true) и
preciseDiffTally. Новый поток diffDocs: computeIntegrity (без изменений, для
обоих путей) → если exceedsDiffSizeGuard → coarseDiffTally(fellBack=true) →
иначе try preciseDiffTally / catch → coarseDiffTally. Обе деградации дают
идентичную форму результата.
Fail-closed: единственный путь к recreateTransform — ветка else, достижимая
только когда guard НЕ сработал; огромный документ физически до transform не
доходит. computeIntegrity (корректностный сигнал) считается ВСЕГДА. Все три
call-site потребляют DiffResult как информационный preview, не гейтят запись —
coarse-фоллбэк контракт-сохраняющий.
Тесты (11): over/under-порог, обе асимметрии, байт-ось (нод-мало/байт-много),
env-override низким cap'ом, garbage-env→дефолт (guard не отключается), integrity
корректен на tripped-документе; behavioral-proxy что transform пропущен над
cap'ом (guarded ~5мс vs caps-raised ~3.3с, ≥5× + <200мс бюджет). node --test
688/688. tsc --noEmit чисто. Только packages/mcp, внешний симлинк не тронут.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Канонический конвертер (#293/#345/#351) зрел для блочного уровня: markdown
становится дефолтом чтения/записи ОДНОГО блока, PM JSON — опция «для тонких
работ». Новых тулов нет, поверхность не растёт. Имена уже camelCase (стоит на #412).
- getNode(pageId, nodeId, format='markdown'): дефолт markdown — обёртка
{type:doc,content:[node]} → convertProseMirrorToMarkdown, comment-якоря
(ВКЛЮЧАЯ resolved) сохранены (это чтение под редактирование, не getPage).
format:'json' — сырой сабтри. Авто-фолбэк не-топ-левел типов (tableRow/Cell/
Header по #<index>) в JSON через canBeDocChild = docmostSchema.nodes.doc.
contentMatch.matchType (не рукописный список); поле format на каждом ответе.
- patchNode/insertNode: XOR-вход {markdown?|node?} (оба optional в схеме, XOR
на рантайме). markdown → импорт фрагмента → 1→N сплайс: первый блок наследует
id цели, остальные свежие; dry replaceNodeById для #159-ambiguity ДО сплайса;
соседние блоки byte-identical. Guard findUnrepresentableTableAttrs: цель со
span/colwidth/backgroundColor → отказ с указанием на table-тулы/node-JSON.
insertNode — insertNodesRelative (N блоков по порядку); голый tableRow/Cell
JSON-only.
- Сноски: ^[...] во фрагменте → канон-импортёр; importMarkdownFragment делит
блоки от footnotesList, РЕМАПИТ id сносок фрагмента в свежие uuid (fn-1
фрагмента не коллизит с fn-1 страницы), mergeFootnoteDefinitions добавляет
через ту же машинерию appendDefinition→normalizeAndMergeFootnotes→
canonicalizeFootnotes, что insertFootnote. Сырые JSON-пути не тронуты.
- node-ops: replaceNodeByIdWithMany/insertNodesRelative (сплайс массива) в
prosemirror-markdown/node-ops (канон после #414) + barrel.
- ROUTING_PROSE (READ/EDIT), compile-time client-call contract, CHANGELOG
(getNode default→markdown, breaking для внешних клиентов, в окне #411/#412).
Тесты: сходимость (patchNode(markdown) блок docsCanonicallyEqual полному
импорту — нет «второго канона»); id-нить 1→N (первый наследует, остальные
свежие, соседи byte-identical); XOR; getNode markdown/json/non-top-level-fallback;
сохранение comment-якорей (active+resolved); ^[...]→хвостовой список+перенумерация;
guard. mcp node --test 697/697; pmd vitest 736; tsc чисто; server jest 273.
Третий линк breaking-окна, стоит на #412 (#411→#412→ЭТОТ→#415).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Один логический тул жил под двумя именами: внешний MCP snake_case
(edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница
при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца:
единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey.
- tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех
43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName
читается только структурно — цикл регистрации, генератор <tool_inventory>,
TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/
docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль
подчёркиваний.
- Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec
`mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test
и tool-inventory.test обновлены.
- ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) →
camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах —
без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с
дублей snake+camel до camelCase.
- Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node")
в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку;
литерал-юнион + call-sites → "patchNode"|"deleteNode".
- Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase
(whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк +
миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы,
.mcp.json, метрики по tool-label); релизится вместе с #411.
Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты —
переименованы только ИМЕНА ТУЛОВ.
Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools.
service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt +
comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Поверхности записи «целым телом» были несимметричны: у in-app агента полная
замена тела markdown называлась updatePageContent (имя не про формат, тогда как
парный updatePageJson — про JSON), а у внешнего MCP голого plain-body-replace
не было вовсе (только import_page_markdown — на деле парсер round-trip к
export_page_markdown, не plain-replace). Пара должна быть updatePageMarkdown /
updatePageJson.
Пост-Фаза-1б архитектура (реестр + циклы по обоим хостам):
- новая shared-спека updatePageMarkdown (mcpName update_page_markdown, inAppKey
updatePageMarkdown, tier как у updatePageJson) с execute (client, {pageId,
content, title}) => client.updatePage(...) — тот же путь updatePageContentRealtime
→ markdownToProseMirrorCanonical, ^[...]-сноски парсятся. Реестровый цикл
регистрирует её на ОБОИХ хостах автоматически. Добавлен 'updatePage' в
Pick DocmostClientLike.
- import_page_markdown убран с внешнего MCP через inAppOnly:true у спеки
importPageMarkdown — MCP-цикл и генератор инвентаря её пропускают, in-app
агент сохраняет importPageMarkdown; спека и client-метод НЕ удалены.
- удалён inline in-app updatePageContent tool (теперь из реестра под inAppKey
updatePageMarkdown) + его INLINE_TOOL_TIERS-энтри.
- ROUTING_PROSE: bulk-rewrite ссылается на update_page_markdown|update_page_json;
убрано упоминание import_page_markdown; инвентарь генерируется из catalogLine.
- лейбл-мапы chat-markdown.util (en/ru), человекочитаемые метки не тронуты.
- НЕ тронуты одноимённые внутренности: PageService.updatePageContent,
updatePageContentRealtime, collaboration.handler — переименовано только имя тула.
Тесты: updatePageMarkdown на обеих поверхностях с идентичной схемой, forward в
client.updatePage; import_page_markdown ОТСУТСТВУЕТ на MCP, присутствует in-app;
^[...]→сноски покрыт через collaboration.test. CHANGELOG BREAKING + миграция;
README/README.ru пакета обновлены. Гейт: mcp node --test 646/646, server jest
259, tsc чисто. Первый линк breaking-окна #416 (#411→#412→#413→#415).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 1б (#445/#446/#447/#448) влилась ПЕРЕД этим PR, поэтому явная проводка
drawio через registerShared/sharedTool конфликтовала с реестровыми циклами.
Фолд:
- drawioGet/Create/Update → канонический execute в спеке (это client-методы):
execute возвращает сырой результат, цикл оборачивает jsonContent на MCP и
отдаёт как есть in-app — байт-в-байт как старые тела, overrides не нужны.
layout сохранён: добавлен в buildShape create/update + 5-м аргументом
(layout as 'elk'|undefined) в обоих execute.
- drawioShapes/drawioGuide → остаются inline на ОБОИХ хостах. Гайд архитектора
«execute в спеке с импортом searchShapes/getGuideSection в tool-specs.ts»
оказался невозможен: drawio-shapes.ts использует import.meta.url, а
tool-specs.ts тайпчекается из исходника под module:commonjs (contract-спека)
→ TS1343 на статический value-import, а import.meta не индиректится. Введён
флаг спеки inlineBothHosts: спеки остаются в SHARED_TOOL_SPECS (contract
пинит имя/описание/схему), но без execute; ОБА цикла их пропускают (добавлен
симметричный guard в MCP-цикл), каждый хост регистрирует их inline через
чистые хелперы — поведение байт-в-байт как до ребейза.
- docmost-client.loader: взята develop-форма DocmostClientLike = Pick<
DocmostClient> (#446); ручное зеркало убрано, паритет layout наследуется из
реальной сигнатуры client.
- ROUTING_PROSE: drawio intent-подсказки (shapes-first, guide, layout:elk);
инвентарные строки убраны (генерируются из catalogLine).
Гейт: mcp node --test 674/674; server jest ai-chat-tools.service + contract
(211, все 5 drawio на обоих хостах, идентичная схема) + tool-tiers → 264;
tsc чисто; layout-passthrough тест 3/3. ELK DoS-кап и layout-фикс из round 3/4
сохранены.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applyElkLayout крутит elkjs СИНХРОННО в процессе на mxGraph-XML от LLM
(layout:'elk' в drawio_create/update) без лимита размера и без таймаута —
большой граф (тысячи узлов, ~1МБ XML проходит stage-1 cap 16МБ) блокирует
event-loop MCP-сервера на секунды-минуты. try/catch ловил только брошенную
ошибку, но не зависание.
- кап ДО построения графа: >500 узлов или >1000 рёбер → вернуть исходный XML
(best-effort, как существующий catch); синхронный elkjs → кап и есть
реальная защита;
- Promise.race с 5s-таймаутом (defense-in-depth на случай async-elkjs); таймер
гасится в finally → нет утечки хендла и unhandled-rejection (проигравший
timeout остаётся pending с погашенным таймером);
- тест: 600-узловой граф возвращается без изменений и быстро (<2s) — кап-путь.
79/79 drawio-тестов зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Стадия-1 (#434) уже была довяжена in-app в develop (f46d89ea, agent_vscode)
для CRUD-тулов; два новых чистых read-only хелпера стадии-2 остались
незаброшенными → contract-parity спека падала 6 ассертами (по 3 на
drawio_shapes/drawio_guide). В отличие от CRUD-тулов это ЧИСТЫЕ функции без
сетевого вызова, поэтому НЕ client-методы:
- реэкспорт searchShapes / getGuideSection (+ тип SearchShapesOptions) из
entry пакета @docmost/mcp; loadDocmostMcp() пробрасывает их так же, как
sharedToolSpecs (типы SearchShapesFn/GetGuideSectionFn);
- две записи sharedTool(...) в forUser() после drawioUpdate: drawioShapes
повторяет серверный вызов searchShapes(query,{category,limit}) и форму
{ query, count, results }; drawioGuide — getGuideSection(section)
(omit section -> index); голый объект без jsonContent-envelope, как у
соседних in-app хендлеров;
- DocmostClientLike и HOST_CONTRACT_METHODS-вайтлист НЕ тронуты (это не
методы клиента);
- три тест-мока (contract/service/tool-tiers) получили type-only no-op
заглушки под расширенный тип loadDocmostMcp() — тела инструментов в этих
тестах не исполняются, contract-спека реально гоняет настоящий
SHARED_TOOL_SPECS.
Внутреннее ревью обвязки: APPROVE, 0 находок. shared-tool-specs.contract:
211/211 (было 6 падений); client-host-contract drift-guard 3/0; tsc EXIT 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>