Модель органически вставляет `<!-- ... -->` в mxGraph-XML вопреки запрету
в описании тула (природа LLM, промптингом не лечится). Жёсткая ошибка
линтера [no-comments] заставляла её ПОЛНОСТЬЮ перегенерировать диаграмму —
впустую потраченный tool-call на почти каждой первой генерации.
Теперь в общем prepare-пути (`prepareModel`, через который идут
drawioCreate/drawioUpdate/drawioEditCells) комментарии срезаются ДО линта:
`xml.replace(/<!--[\s\S]*?-->/g, "")`. Комментарии не несут семантики, так
что стрип безопасен. Число срезанных уходит в `warnings[]` как
`stripped N XML comment(s)` (только при N > 0) — модель это видит, но НЕ
ретраит.
Стрип ГЕЙТИТСЯ двумя условиями, чтобы регэксп бил только по настоящим
comment-нодам:
1. well-formedness — сначала парсим модель; на malformed-входе (сырой
неэкранированный `<!--` внутри значения атрибута на пути create/update,
где normalizeInput отдаёт строку без парсинга) стрип НЕ выполняется,
иначе он молча вырезал бы текст автора. Вход отклоняют правила
value-escaping / well-formed-xml, автор видит реальную ошибку.
2. отсутствие CDATA — CDATA-секция well-formed, но её текст может содержать
литеральный `<!-- ... -->`, который НЕ является comment-нодой; стрип
молча удалил бы контент автора. Настоящий drawio держит подписи в
атрибутах `value=` (CDATA невозможен), так что исключение CDATA бесплатно
на легитимных моделях; CDATA-модель с реальным комментарием падает на
сохранённый backstop no-comments (явная ошибка), а не тихо портится.
Правило `no-comments` в линтере оставлено как defense-in-depth backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Эскалация (владелец) — Опция A: 100k только фолбэк для НЕсконфигурированных
инсталляций; при заданном chatContextWindow бюджет = floor(0.7×window) БЕЗ капа
(бюджетер — защита от брика об контекст-окно, не эконом-лимитер). Спек репиннут
resolveReplayBudget(1_000_000)→700_000.
DO1: агрессивный next-turn recovery ×0.5 вынесен в чистую resolveEffectiveReplayThreshold
+ тест линковки replayOverflow→0.5×бюджет (mutation-verified).
DO2: checkNewComments partial-failure — per-page reject скипается (→null), скан
резолвится, порядок выживших сохранён; тест #7 (mutation-verified).
DO3: ai-chat.write-volume.spec.ts → .int-spec.ts (WAL-гард не бежал НИ в одном
CI-lane) + маппер @docmost/token-estimate в jest-integration.json; реальный WAL
на pg:5432 зелёный (трейс v1 140MB→v2 0.04MB).
DO4: CHANGELOG [Unreleased] по #490.
Follow-up: issue #520 (эскалация агрессивной доли при незаданном окне + малом
реальном контексте).
Ребейзнут на develop (волна 1 смержена): только 6 коммитов #490 над develop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат
рано или поздно упирается в контекстное окно и получает провайдерский 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>
Ревью #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>
CRITICAL из ревью: escapeLeadingBlockTrigger не покрывал setext-underline
из ровно двух дефисов (--) и одиночного = → строка-продолжение после
hardBreak, равная -- или =, репарсилась как setext-heading, а текст
предыдущей строки терялся. Тот же класс потери данных, что PR и чинит.
Добавлена setext-рука после тематической: целая строка ^-+[ \t]*$ или
^=+[ \t]*$ экранирует ведущий символ. Заякорено на всю строку → mid-content
-/= не задевается; после тематической руки → ---/---- не двойно-экранируются;
идемпотентно против уже-экранированного \=\= (инлайн-escape отрабатывает
раньше). Пины --/----/=/==== + генеративный hardBreakThenSetextArb, оба
mutation-verified. CHANGELOG.
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>
Ревью #493 (HIGH): escapeLeadingBlockTrigger применялся к всему результату
renderInlineChildren один раз, а регэкспы якорены на ^ без флага m → защищалась
только ПЕРВАЯ строка. hardBreak сериализуется как ` \n`, поэтому триггер на
строке-продолжении не экранировался и на git-sync round-trip re-парсился в
другой блок; для setext/thematic `---` текст строки терялся ЦЕЛИКОМ
([text "a", hardBreak, text "---"] → heading, "---" пропадал).
Правка: параграф теперь бьётся на `\n`-строки и escapeLeadingBlockTrigger
применяется к КАЖДОЙ. Фаззер расширен: hardBreakThenTriggerArb вставляет
триггер ПОСЛЕ hardBreak в inlineContentArb, так что P1/P2/P3 структурно
покрывают подслучай (раньше триггер стоял только первым run'ом). Добавлен
детерминированный пин на continuation-line триггеры, включая `a \n---`
(текст «---» сохраняется, не setext).
Mutation-verify: со старым single-line escape новый пин + P1/P2 краснеют.
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>
Незнакомый тип ноды (default-ветка switch) молча схлопывался в свои дети, а
незнакомая марка молча выбрасывалась — тихая потеря данных. Теперь
сериализатор РЕПОРТИТ потерю:
- по умолчанию поведение байт-в-байт прежнее (graceful degrade), но при
переданном options.warnings в сток кладётся по одному сообщению на
незамапленный тип (дедуп по типу) — потеря наблюдаема;
- options.strict бросает ConverterLossError на ПЕРВОМ незнакомом типе
(warning = ошибка).
git-sync (lossless-путь) включает strict в stabilizePageBody: тип без
серизализующей ветки падает громко на записи, а не пишет lossy .md. Валидный
контент не затронут — у всех текущих типов схемы есть ветка.
Покрытие: converter-loss-warnings.test.ts (нода/марка × strict/non-strict,
дедуп, чистый контент) и strict-пин в git-sync stabilize.test.ts — всё через
реальный конвертер.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Name-level контракт ловил пропажу целой ноды/марки, но не дрейф АТРИБУТОВ
внутри вендоренной ноды — класс, из-за которого молча потерялся
subpages.recursive. Добавлен атрибутный контракт: для каждой ноды/марки
@docmost/editor-ext сравниваются её СОБСТВЕННЫЕ объявленные атрибуты (имена
+ дефолты, читаются из config.addAttributes) с spec.attrs собранной схемы
зеркала.
Направление editor-ext -> mirror: зеркало намеренно надмножество (глобальные
id/textAlign/indent, нормализация части дефолтов в null), поэтому обратное
сравнение — ложный дрейф. Значимый провал — атрибут, который зеркало РОНЯЕТ
(имя) или чей дефолт молча меняет. Два blessed вида расхождений вынесены в
обоснованные allowlist'ы (highlight.colorName — нет md-формы;
image.src/link.internal/pdf.width/height — null-нормализация непереносимых
атрибутов), оба со stale-guard, чтобы список не сгнил.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Строка параграфа, начинающаяся с блочного триггера (`#`/`-`/`*`/`+`/`>`,
упорядоченного `N.`/`N)`, фенса ```/~~~, таблицы `|` или тематического
разрыва `---`/`***`/`___`), на round-trip doc->markdown->doc молча
превращалась в heading/list/quote/code block/table/horizontalRule. Худший
случай — тематический разрыв: horizontalRule не несёт текста, и строка
теряла его целиком.
Сериализатор параграфа теперь backslash-экранирует ведущий блочный триггер
(escapeLeadingBlockTrigger): экранируется только ПЕРВЫЙ значащий символ,
токенизатор CommonMark декодирует `\` обратно в литерал И снимает блочную
интерпретацию, так что строка round-trip'ится байт-в-байт как параграф.
Emphasis `**x**`, inline-code и обычная проза триггерами не являются и не
трогаются (нет мусорных backslash).
Класс раньше не чинили, а ОБХОДИЛИ; обход убран у обоих потребителей:
- клиентский мост (gitmost-recording.ts) больше не подставляет ZWSP-хак;
- генеративный корпус (text-arbitraries.ts) снял самоцензуру — добавлен
blockTriggerLeadRunArb, параграф теперь МОЖЕТ открываться триггером, и
P1/P2/P3 сами доказывают закрытие класса.
Пины на каждый триггер — детерминированные round-trip через реальный
конвертер (gitmost-transcript-neutralization.test.ts). Обновлён
документировавший старую потерю gap-тест (spec 13).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retrospective of 22.06-10.07 merges showed one recurring miss class: local
logic verified, integration property never checked (#361, #353, #452, #172,
#435). This lands four gates so each of those classes fails BEFORE the
:develop image is pushed:
1. Image boot-smoke in the publish job (develop.yml + scripts/ci/image-smoke.sh):
the exact image watchtower pulls is booted against postgres/redis services
before the push — /api/health (startup migrator, #361-boot/#353), auth/setup,
client dist served, hashed assets immutable + brotli (#452).
2. migration-order gate now also runs on push (test.yml): direct pushes used to
bypass the PR-only gate; base = event.before, zero-SHA skips, force-push
fails closed.
3. External-MCP tool allowlist fails closed (#172 class): corrupt stored value
now reads as [] (deny-all) with an error log instead of null (allow-all);
[] round-trips as jsonb [] via jsonbBind({preserveEmpty}) and means deny-all
in the toolset filter. The settings form sends null for an empty tag field
so existing "unrestricted" servers are not silently narrowed.
4. Property tests for the silent-degradation classes: converter fixpoint
through the live server path (mcp e2e), and CollabSession cache-key
stability under per-call fresh tokens (#435/#439 lesson) incl. a negative
control with the token cache disabled.
Closes#476
Co-Authored-By: Claude Fable 5 <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>
paginate-abort-safepoint покрывал только READ-safe-point; WRITE-шов
(collaboration.mutatePageContent / context.mutateLiveContentUnlocked —
signal?.throwIfAborted() перед session.mutate) был без теста. Добавляю
мок-collab юнит через тест-сим __setCollabProviderFactory (фейковый провайдер
с мгновенным onSynced): предварительно abort-нутый сигнал → оба шва реджектят
ДО session.mutate, трансформ не вызывается; контрольные кейсы (живой сигнал)
подтверждают, что session.mutate достижим.
MUTATION-VERIFY: снятие throwIfAborted в обоих швах делает красными ровно два
abort-кейса, контрольные остаются зелёными.
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>
Стамп детектил build/src-skew только по tool-specs.ts — правка client.ts,
client/*-модуля, comment-signal или drawio-* без пересборки проходила молча,
и build/ отдавал старый код. Теперь codegen и рантайм-лоадер хэшируют весь
src/**/*.ts (кроме *.generated.ts — иначе цикл через собственный выход),
симметрично: одинаковый обход, POSIX-сортировка, нормализация и sha256.
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 продолжает
тикать во время раскладки).
Правки по 5 находкам ревью #498.
F1 (регрессия отступов списков в Reasoning-панели): добавлен `.reasoningText li p
{margin:0}` (зеркало существующего `.markdown li p`). Reasoning рендерит через тот
же renderChatMarkdown (теперь всегда <li><p>…</p></li>), но под .reasoningText, где
`.reasoningText p{margin:0 0 4px}` давал 4px на пункт. Обе поверхности покрыты.
F2 (глухой catch): `.catch` вставки был `()=>{}` → теперь `(err)=>console.error(
"markdown paste conversion failed, inserting raw text", err)` — тихая деградация в
raw-текст больше не невидима (покрывает и конвертер, и тело success-.then, напр.
PMNode.fromJSON при дрейфе схемы).
F3 (нет теста doc-changed guard): +3 теста в markdown-clipboard.paste.test.ts:
success-ветка при mid-flight изменении дока → вставка в живую selection (маркер
цел, без клоббера/throw); fail-open ветка при mid-flight + провале конверсии →
raw-текст в живую selection без RangeError; две вставки в полёте → инвариант «ни
один payload не потерян».
F4 (устаревшие комменты): исправлены ссылки на удалённый md-слой в markdown-
clipboard.ts, footnote-sync/util(+test), docmost-schema, foreign-markdown,
footnote-canonicalize → на @docmost/prosemirror-markdown / локальные символы.
F5 (внешние доки): AGENTS.md (apps/client как потребитель через browser-entry,
jsdom только в Node, удалён marked/turndown-слой); prosemirror-markdown/README
(секция Node vs browser entry, markdownToProseMirrorSync); CHANGELOG.
Тесты: client paste+canonicalize+ai-chat 61; pmd 744; editor-ext 196; клиентская
сборка успешна, grep бандла на JSDOM/parse5/happy-dom/turndown — 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Правки по ревью #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>
#413 changed patchNode/insertNode to take { markdown? | node? } (exactly
one), but test-e2e.mjs still passed the raw ProseMirror node directly,
so the e2e-mcp CI job died with the XOR guard error right after the
node_ops seed step. Wrap both call sites in { node: ... }.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>